diff --git a/src/bus.rs b/src/bus.rs index f55011f..97a7307 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -17,17 +17,30 @@ pub enum Flow { Break, } +/// Who spoke a turn's input. The record attributes the line to its +/// speaker, so a reader of the transcript can tell what the player asked +/// for from what the engine asked for on its behalf. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Speaker { + /// The player typed the line. + Player, + /// The engine wrote the line and handed it to the DM. The player + /// never typed it and never saw it. + Engine, +} + /// A thing that reacts to turn events as they happen. /// Every method has a default no-op implementation. /// /// `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) {} + /// Called before the turn's first delta, with the line that started + /// the turn and who spoke it. 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, _speaker: Speaker) {} fn delta(&mut self, _text: &str) -> Flow { Flow::Continue } @@ -54,9 +67,9 @@ impl TurnBus { self.listeners.push(listener); } - pub fn turn_start(&mut self, input: &str) { + pub fn turn_start(&mut self, input: &str, speaker: Speaker) { for listener in &mut self.listeners { - listener.turn_start(input); + listener.turn_start(input, speaker); } } @@ -149,6 +162,9 @@ impl Listener for ScreenListener { pub struct CampaignListener { campaign: Option, input: String, + /// Who spoke the line this turn started from, so the transcript + /// stamps it with the right prefix. + speaker: Speaker, narration: String, } @@ -157,14 +173,16 @@ impl CampaignListener { Self { campaign, input: String::new(), + speaker: Speaker::Player, narration: String::new(), } } } impl Listener for CampaignListener { - fn turn_start(&mut self, input: &str) { + fn turn_start(&mut self, input: &str, speaker: Speaker) { self.input = input.to_string(); + self.speaker = speaker; } fn delta(&mut self, text: &str) -> Flow { @@ -186,11 +204,12 @@ impl Listener for CampaignListener { return; }; - // Record the player's line. The clock at turn-start is captured - // in new(), before any `mark` mid-turn advances it. + // Record the line the turn started from, under its speaker. The + // clock at turn-start is captured in new(), before any `mark` + // mid-turn advances it. let start_time = campaign.current_time().ok(); if let Some(time) = start_time { - let _ = campaign.append_player(time, &self.input); + let _ = campaign.append_spoken(time, self.speaker, &self.input); } // Record narration. The clock may have advanced via mark() diff --git a/src/campaign/campaign_tests.rs b/src/campaign/campaign_tests.rs index 6edb841..03f4c9e 100644 --- a/src/campaign/campaign_tests.rs +++ b/src/campaign/campaign_tests.rs @@ -2,6 +2,7 @@ //! transcript sections, and the line-based campaign log. use super::*; +use crate::bus::Speaker; use std::fs; /// A fresh campaign in a temp directory. @@ -193,7 +194,11 @@ fn player_input_appears_in_the_transcript_with_a_prefix() { let (campaign, _dir) = campaign(); campaign - .append_player(time("#d1-0830"), "I jump across the stream.") + .append_spoken( + time("#d1-0830"), + Speaker::Player, + "I jump across the stream.", + ) .unwrap(); let sections = campaign.transcript_entries().unwrap(); @@ -201,12 +206,25 @@ fn player_input_appears_in_the_transcript_with_a_prefix() { assert_eq!(sections[0].body, "player> I jump across the stream."); } +#[test] +fn an_engine_directive_appears_in_the_transcript_under_its_own_prefix() { + let (campaign, _dir) = campaign(); + + campaign + .append_spoken(time("#d1-0830"), Speaker::Engine, "Recap the session.") + .unwrap(); + + let sections = campaign.transcript_entries().unwrap(); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].body, "storied> Recap the session."); +} + #[test] fn narrations_at_the_same_time_share_a_section() { let (campaign, _dir) = campaign(); campaign - .append_player(time("#d1-0830"), "I check the room.") + .append_spoken(time("#d1-0830"), Speaker::Player, "I check the room.") .unwrap(); campaign .append_narration( @@ -226,7 +244,7 @@ fn a_time_change_opens_a_new_section() { let (campaign, _dir) = campaign(); campaign - .append_player(time("#d1-0830"), "I wake up.") + .append_spoken(time("#d1-0830"), Speaker::Player, "I wake up.") .unwrap(); campaign .append_narration(time("#d1-1200"), "Noon arrives.") @@ -243,7 +261,7 @@ fn the_transcript_file_has_one_heading_per_section() { let (campaign, _dir) = campaign(); campaign - .append_player(time("#d1-0830"), "I wake up.") + .append_spoken(time("#d1-0830"), Speaker::Player, "I wake up.") .unwrap(); campaign .append_narration(time("#d1-1200"), "Noon arrives.") @@ -259,8 +277,9 @@ fn a_markdown_heading_line_inside_player_input_is_not_mistaken_for_a_new_section let (campaign, _dir) = campaign(); campaign - .append_player( + .append_spoken( time("#d1-0830"), + Speaker::Player, "I read the sign.\n## Danger\nWatch your step.", ) .unwrap(); @@ -323,7 +342,7 @@ fn recall_can_filter_to_the_log() { campaign.mark(time("#d1-0830"), "secret spy", None).unwrap(); campaign - .append_player(time("#d1-0900"), "I talk about spies.") + .append_spoken(time("#d1-0900"), Speaker::Player, "I talk about spies.") .unwrap(); let hits = search(&campaign, &["spy".to_string()], Some(Source::Log)).unwrap(); diff --git a/src/campaign/mod.rs b/src/campaign/mod.rs index 4a53d84..eb0304b 100644 --- a/src/campaign/mod.rs +++ b/src/campaign/mod.rs @@ -24,10 +24,12 @@ mod transcript; pub use clock::{GameTime, START}; pub use log::CampaignLog; pub use recall::{RecallHit, Source, search}; -pub use transcript::Transcript; +pub use transcript::{ENGINE_PREFIX, PLAYER_PREFIX, Transcript}; use std::path::{Path, PathBuf}; +use crate::bus::Speaker; + /// A world's record of time and events: the campaign log under /// `campaign-log/`, DM-only and forward-only, and the transcript under /// `transcript/`, shared with the player. @@ -95,10 +97,21 @@ impl Campaign { Ok(()) } - /// Records what the player typed at the start of the turn, stamped - /// with the current game time. - pub fn append_player(&self, time: GameTime, input: &str) -> Result<(), String> { - self.transcript.append(time, &format!("player> {input}")) + /// Records the line a turn started from, stamped with the current + /// game time and with the prefix `speaker` speaks under. + /// + /// The player's own lines and the engine's directives both land in + /// the record, under prefixes that tell them apart. A session that + /// reads the record back replays either one as something the DM was + /// told. + pub fn append_spoken( + &self, + time: GameTime, + speaker: Speaker, + input: &str, + ) -> Result<(), String> { + self.transcript + .append(time, &format!("{}{input}", transcript::prefix(speaker))) } /// Records the DM's narration at the given game time. diff --git a/src/campaign/transcript.rs b/src/campaign/transcript.rs index 19d218f..af265d5 100644 --- a/src/campaign/transcript.rs +++ b/src/campaign/transcript.rs @@ -2,18 +2,36 @@ //! //! One zero-padded file per game-day, with a `# Day N` title, then //! `## #dX-HHMM` headings wherever game time changes, and the content -//! beneath: `player>` lines for what the player typed, narration -//! paragraphs, and event lines from public or screened marks. Oldest -//! entry first. +//! beneath: `player>` lines for what the player typed, `storied>` lines +//! for what the engine told the DM, narration paragraphs, and event lines +//! from public or screened marks. Oldest entry first. use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +use crate::bus::Speaker; + use super::clock::GameTime; use super::day_file::{day_path, days_in, ensure_dir}; use super::entry::LogEntry; +/// What the transcript stamps on a line the player typed. +pub const PLAYER_PREFIX: &str = "player> "; + +/// What the transcript stamps on a line the engine handed the DM, such as +/// the trigger that asks a resumed session for its recap. The player +/// never typed it, so the record does not say they did. +pub const ENGINE_PREFIX: &str = "storied> "; + +/// The prefix `speaker`'s lines carry in the transcript. +pub fn prefix(speaker: Speaker) -> &'static str { + match speaker { + Speaker::Player => PLAYER_PREFIX, + Speaker::Engine => ENGINE_PREFIX, + } +} + /// The shared transcript at `dir`. #[derive(Debug, Clone)] pub struct Transcript { @@ -36,8 +54,8 @@ impl Transcript { /// /// Blocks at the same time are separated by a blank line; a time /// change inserts a blank line then the heading. The caller must - /// format the block (e.g. `player> …` for the player's lines, or the - /// narration as-is). + /// format the block (e.g. [`PLAYER_PREFIX`] on the player's lines, or + /// the narration as-is). /// /// This only ever adds bytes: the existing file is read to decide /// whether a heading is needed and how much of a gap closes it off diff --git a/src/dm/dm_clock_tests.rs b/src/dm/dm_clock_tests.rs index eaf6ab6..cadba98 100644 --- a/src/dm/dm_clock_tests.rs +++ b/src/dm/dm_clock_tests.rs @@ -2,6 +2,7 @@ //! the session brief in every turn's system prompt. use super::tests::{dm_for, dm_with_campaign, fake_server, sent_messages, sse_response}; +use crate::bus::Speaker; use serde_json::json; use tempfile::TempDir; @@ -42,8 +43,8 @@ fn a_second_turns_prompt_carries_the_campaign_clock() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I look around.").unwrap(); - dm.turn("I look again.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); + dm.turn(Speaker::Player, "I look again.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -65,8 +66,8 @@ fn a_mid_turn_mark_moves_the_clock_line_on_the_next_turn() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I rest until dawn.").unwrap(); - dm.turn("I look around.").unwrap(); + dm.turn(Speaker::Player, "I rest until dawn.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -84,8 +85,8 @@ fn a_dm_with_no_campaign_carries_no_clock_line() { ]); let mut dm = dm_for(url); - dm.turn("I look around.").unwrap(); - dm.turn("I look again.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); + dm.turn(Speaker::Player, "I look again.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); diff --git a/src/dm/dm_entity_notes_tests.rs b/src/dm/dm_entity_notes_tests.rs index b846b4f..fffab28 100644 --- a/src/dm/dm_entity_notes_tests.rs +++ b/src/dm/dm_entity_notes_tests.rs @@ -2,6 +2,7 @@ //! previous turn named, appended to the next turn's system prompt. use super::tests::{dm_for, dm_with_campaign, fake_server, sent_messages, sse_response}; +use crate::bus::Speaker; use tempfile::TempDir; @@ -30,8 +31,8 @@ fn a_second_turns_prompt_carries_the_note_of_an_entity_the_first_turn_named() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I enter the tavern.").unwrap(); - dm.turn("I greet her.").unwrap(); + dm.turn(Speaker::Player, "I enter the tavern.").unwrap(); + dm.turn(Speaker::Player, "I greet her.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -53,8 +54,8 @@ fn two_named_entities_notes_both_reach_the_next_prompt() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I enter the tavern.").unwrap(); - dm.turn("I watch them.").unwrap(); + dm.turn(Speaker::Player, "I enter the tavern.").unwrap(); + dm.turn(Speaker::Player, "I watch them.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -75,14 +76,14 @@ fn a_note_edited_on_disk_between_turns_reaches_the_next_prompt() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I enter the tavern.").unwrap(); + dm.turn(Speaker::Player, "I enter the tavern.").unwrap(); let stub = world.path().join("entities/Vera Blackwater.md"); std::fs::write( &stub, "---\nkind: person\naliases: []\n---\n\nVera runs the tavern now.\n", ) .unwrap(); - dm.turn("I greet her.").unwrap(); + dm.turn(Speaker::Player, "I greet her.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -103,9 +104,9 @@ fn an_entity_whose_note_file_is_gone_injects_nothing() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I enter the tavern.").unwrap(); + dm.turn(Speaker::Player, "I enter the tavern.").unwrap(); std::fs::remove_file(world.path().join("entities/Vera Blackwater.md")).unwrap(); - dm.turn("I greet her.").unwrap(); + dm.turn(Speaker::Player, "I greet her.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -125,8 +126,8 @@ fn a_dm_with_no_campaign_injects_nothing_and_still_turns() { ]); let mut dm = dm_for(url); - dm.turn("I enter the tavern.").unwrap(); - dm.turn("I greet her.").unwrap(); + dm.turn(Speaker::Player, "I enter the tavern.").unwrap(); + dm.turn(Speaker::Player, "I greet her.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -147,8 +148,8 @@ fn a_turn_that_names_nothing_injects_no_preamble() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I walk.").unwrap(); - dm.turn("I keep walking.").unwrap(); + dm.turn(Speaker::Player, "I walk.").unwrap(); + dm.turn(Speaker::Player, "I keep walking.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); diff --git a/src/dm/dm_session_start_tests.rs b/src/dm/dm_session_start_tests.rs index 094c289..ee0fe41 100644 --- a/src/dm/dm_session_start_tests.rs +++ b/src/dm/dm_session_start_tests.rs @@ -12,6 +12,7 @@ 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 @@ -64,7 +65,7 @@ fn a_written_transcript_replays_into_the_first_requests_messages() { 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(); + dm.turn(Speaker::Player, "I drink.").unwrap(); server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); @@ -85,7 +86,7 @@ fn a_transcript_longer_than_the_limit_replays_only_its_tail() { 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(); + dm.turn(Speaker::Player, "line 16").unwrap(); server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); @@ -117,7 +118,7 @@ fn the_first_prompt_carries_the_notes_of_the_last_replayed_narration() { 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(); + dm.turn(Speaker::Player, "I drink.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); @@ -144,8 +145,8 @@ fn a_session_brief_injects_its_body_and_its_linked_notes() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I look around.").unwrap(); - dm.turn("I keep looking.").unwrap(); + 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); @@ -167,9 +168,9 @@ fn a_brief_edited_between_turns_reaches_the_next_prompt() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I look around.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); fs::write(world.path().join("session.md"), "The party turned back.\n").unwrap(); - dm.turn("I keep looking.").unwrap(); + dm.turn(Speaker::Player, "I keep looking.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -185,7 +186,7 @@ fn an_empty_brief_injects_nothing() { 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(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); assert!(!next_system_prompt(&requests).contains("Where the story stands")); @@ -199,7 +200,7 @@ fn an_unresolvable_link_in_replayed_narration_establishes_nothing() { 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(); + dm.turn(Speaker::Player, "I stare back.").unwrap(); server.join().unwrap(); assert!(!world.path().join("entities/Nobody At All.md").exists()); @@ -230,7 +231,8 @@ fn a_restart_opens_with_the_last_turns_the_brief_and_the_note_they_share() { 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(); + dm.turn(Speaker::Player, "I ask her about the warehouse.") + .unwrap(); server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); @@ -248,7 +250,7 @@ fn an_empty_world_replays_nothing_and_still_turns() { 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(); + dm.turn(Speaker::Player, "I open the door.").unwrap(); server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); diff --git a/src/dm/dm_stage_tests.rs b/src/dm/dm_stage_tests.rs index 53977de..956e352 100644 --- a/src/dm/dm_stage_tests.rs +++ b/src/dm/dm_stage_tests.rs @@ -2,6 +2,7 @@ //! to, and the sheet every turn's prompt carries for them. use super::tests::{dm_for, dm_with_campaign, fake_server, sent_messages, sse_response}; +use crate::bus::Speaker; use std::fs; use std::path::Path; @@ -129,7 +130,7 @@ fn a_turn_carries_the_sheet_of_the_character_on_stage() { 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(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); @@ -152,13 +153,13 @@ fn a_sheet_edited_on_disk_between_turns_reaches_the_next_prompt() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("I look around.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); write_sheet( world.path(), "sister-maren", "---\nname: Sister Maren\nhp: 15\n---\n\nHer shoulder is torn open.\n", ); - dm.turn("I press on.").unwrap(); + dm.turn(Speaker::Player, "I press on.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -178,9 +179,9 @@ fn a_character_rolled_mid_session_reaches_the_next_prompt() { ]); let mut dm = dm_with_campaign(url, &world); - dm.turn("Roll me a cleric.").unwrap(); + dm.turn(Speaker::Player, "Roll me a cleric.").unwrap(); write_sheet(world.path(), "sister-maren", MAREN_SHEET); - dm.turn("I look around.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let first = next_system_prompt(&requests); @@ -202,7 +203,7 @@ fn a_world_with_nobody_on_stage_injects_no_sheet_section() { 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(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); @@ -215,7 +216,7 @@ fn a_dm_with_no_campaign_injects_no_sheet_section() { let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); let mut dm = dm_for(url); - dm.turn("I look around.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); @@ -236,7 +237,7 @@ fn a_stage_event_naming_a_character_with_no_sheet_injects_nothing() { 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(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); @@ -252,7 +253,7 @@ fn an_empty_sheet_injects_nothing() { 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(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); @@ -272,7 +273,7 @@ fn a_sheet_with_no_frontmatter_fence_carries_its_prose() { 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(); + dm.turn(Speaker::Player, "I look around.").unwrap(); server.join().unwrap(); let prompt = next_system_prompt(&requests); diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs index 7d4a997..21f5fc4 100644 --- a/src/dm/dm_tests.rs +++ b/src/dm/dm_tests.rs @@ -161,7 +161,7 @@ fn a_turn_streams_deltas_in_order_and_returns_the_assembled_reply() { let mut dm = dm_for(url); let collector = attach_collector(&mut dm); - dm.turn("I open the door.").unwrap(); + dm.turn(Speaker::Player, "I open the door.").unwrap(); assert_eq!( collector.deltas(), @@ -176,7 +176,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.").unwrap(); + dm.turn(Speaker::Player, "I open the door.").unwrap(); server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); @@ -194,7 +194,7 @@ fn a_turn_sends_the_configured_reasoning_effort() { let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); let mut dm = dm_with_reasoning_effort(url, "high"); - dm.turn("I open the door.").unwrap(); + dm.turn(Speaker::Player, "I open the door.").unwrap(); server.join().unwrap(); let request = requests.recv().unwrap(); @@ -210,7 +210,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); - dm.turn("I sleep.").unwrap(); + dm.turn(Speaker::Player, "I sleep.").unwrap(); server.join().unwrap(); // No mark has set the clock, so the narration carries the campaign's @@ -235,8 +235,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.").unwrap(); - dm.turn("I step inside.").unwrap(); + dm.turn(Speaker::Player, "I open the door.").unwrap(); + dm.turn(Speaker::Player, "I step inside.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -259,11 +259,11 @@ fn a_failed_turn_leaves_the_history_unchanged() { ]); let mut dm = dm_for(url); - let error = dm.turn("a doomed input"); + let error = dm.turn(Speaker::Player, "a doomed input"); assert!(error.is_err()); - dm.turn("a fresh input").unwrap(); + dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -290,11 +290,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"); + let error = dm.turn(Speaker::Player, "a doomed input"); assert!(error.is_err()); - dm.turn("a fresh input").unwrap(); + dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -313,10 +313,10 @@ fn an_empty_reply_appends_and_returns_the_empty_string() { let mut dm = dm_for(url); let collector = attach_collector(&mut dm); - dm.turn("silence").unwrap(); + dm.turn(Speaker::Player, "silence").unwrap(); assert_eq!(collector.get_done(), Some(String::new())); - dm.turn("again").unwrap(); + dm.turn(Speaker::Player, "again").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -340,7 +340,7 @@ fn breaking_on_the_first_delta_returns_cancelled_with_the_partial_text() { // Set break-on-delta so the first delta cancels the turn dm.bus.add(Box::new(TestCollector::new().break_on_delta())); - dm.turn("I open the door.").unwrap(); + dm.turn(Speaker::Player, "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. @@ -361,11 +361,11 @@ fn a_cancelled_turns_history_is_unchanged_for_the_next_request() { let collector = attach_collector(&mut dm); dm.bus.add(Box::new(TestCollector::new().break_on_delta())); - dm.turn("a doomed input").unwrap(); + dm.turn(Speaker::Player, "a doomed input").unwrap(); assert!(collector.get_cancelled().is_some()); - dm.turn("a fresh input").unwrap(); + dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -415,9 +415,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.").unwrap(); + dm.turn(Speaker::Player, "I open the door.").unwrap(); layer.write("system.md", "The second draft."); - dm.turn("I step inside.").unwrap(); + dm.turn(Speaker::Player, "I step inside.").unwrap(); server.join().unwrap(); let first = sent_messages(&requests.recv().unwrap()); @@ -443,7 +443,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.").unwrap_err(); + let error = dm.turn(Speaker::Player, "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 9232d84..fb078f4 100644 --- a/src/dm/dm_tool_round_tests.rs +++ b/src/dm/dm_tool_round_tests.rs @@ -139,7 +139,7 @@ fn a_tool_round_then_a_reply_the_second_request_carries_the_tool_result() { let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); - dm.turn("I search for traps.").unwrap(); + dm.turn(Speaker::Player, "I search for traps.").unwrap(); assert_eq!( collector.get_done(), @@ -209,7 +209,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. - dm.turn("I sleep.").unwrap(); + dm.turn(Speaker::Player, "I sleep.").unwrap(); server.join().unwrap(); // Turn succeeded without any warning in the reply. @@ -224,8 +224,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.").unwrap(); - dm.turn("I move on.").unwrap(); + dm.turn(Speaker::Player, "I search for traps.").unwrap(); + dm.turn(Speaker::Player, "I move on.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -262,7 +262,7 @@ fn a_secret_tool_call_emits_no_tool_event() { } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I check for danger.").unwrap(); + dm.turn(Speaker::Player, "I check for danger.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); @@ -291,7 +291,7 @@ fn bad_notation_retries_invisibly_with_no_tool_event() { } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I attack.").unwrap(); + dm.turn(Speaker::Player, "I attack.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); @@ -310,7 +310,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.").unwrap(); + dm.turn(Speaker::Player, "I roll.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -347,7 +347,7 @@ fn a_multi_call_round_dispatches_in_order() { } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I roll twice.").unwrap(); + dm.turn(Speaker::Player, "I roll twice.").unwrap(); } assert_eq!(*count.lock().unwrap(), 2); @@ -385,7 +385,7 @@ fn a_completed_calls_tool_event_arrives_before_the_stream_finishes_generating_th } } dm.bus.add(Box::new(EventTracker(Arc::clone(&event_kinds)))); - dm.turn("I search and act.").unwrap(); + dm.turn(Speaker::Player, "I search and act.").unwrap(); } // Call 1 completes, and dispatches, the moment call 2's fragment @@ -421,7 +421,7 @@ fn a_narrated_round_resets_the_tool_only_counter() { let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); - dm.turn("I keep watch.").unwrap(); + dm.turn(Speaker::Player, "I keep watch.").unwrap(); assert_eq!( collector.get_done(), @@ -450,7 +450,7 @@ fn the_request_after_max_tool_only_rounds_omits_tools_and_carries_the_note() { let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); - dm.turn("I keep searching.").unwrap(); + dm.turn(Speaker::Player, "I keep searching.").unwrap(); assert_eq!(collector.get_done(), Some("You come up empty.".to_string())); server.join().unwrap(); @@ -488,9 +488,9 @@ fn a_cancel_on_a_tool_event_leaves_history_untouched() { } } dm.bus.add(Box::new(BreakOnTool)); - dm.turn("I pull the lever.").unwrap(); + dm.turn(Speaker::Player, "I pull the lever.").unwrap(); - dm.turn("a fresh input").unwrap(); + dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); @@ -523,12 +523,12 @@ fn a_withheld_rounds_tool_calls_are_not_dispatched_and_the_turn_ends() { } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); - dm.turn("I keep rolling.").unwrap(); + dm.turn(Speaker::Player, "I keep rolling.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); - dm.turn("a fresh input").unwrap(); + dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); for _ in 0..(MAX_TOOL_ONLY_ROUNDS + 1) { @@ -569,7 +569,7 @@ fn a_turn_that_narrates_every_round_is_withheld_at_the_round_cap() { let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); - dm.turn("I keep watch all night.").unwrap(); + dm.turn(Speaker::Player, "I keep watch all night.").unwrap(); assert_eq!( collector.get_done(), diff --git a/src/dm/mod.rs b/src/dm/mod.rs index b87af8e..645de97 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -9,7 +9,7 @@ use rand::rngs::StdRng; use ratatui::text::Text; use serde_json::Value; -use crate::bus::{CampaignListener, TurnBus}; +use crate::bus::{CampaignListener, Speaker, TurnBus}; use crate::campaign::Campaign; use crate::chat::{ChatError, Client, Message, Role, StreamItem}; use crate::config::Config; @@ -37,10 +37,10 @@ pub mod tools; /// return next turn. const TOOLS_WITHHELD: &str = include_str!("tools-withheld.md"); -/// Submitted as the player's first line when the session opened on a -/// record, so the DM speaks first: it asks for a cold-open recap of the -/// turns the transcript replayed and the brief that follows them. The -/// player sees this line on screen, so it stays short. +/// Submitted as the session's first line when it opened on a record, so +/// the DM speaks first: it asks for a cold-open recap of the turns the +/// transcript replayed and the brief that follows them. The engine +/// speaks it, not the player, so it never reaches the screen. const RECAP: &str = include_str!("recap.md"); /// How many consecutive tool-only rounds, rounds that called tools and @@ -274,8 +274,8 @@ impl Dm { }) } - /// The line to submit on the player's behalf to open the session, or - /// `None` when the player opens it. + /// The line the engine speaks to open the session, or `None` when + /// the player opens it. /// /// A session that replayed a transcript starts with the DM speaking, /// and this is what asks it to: a recap of the turns the replay put @@ -311,6 +311,12 @@ impl Dm { /// Runs a turn from `input` as a loop of rounds, streaming narration /// and tool events through the session-scoped bus as they arrive. /// + /// `speaker` says who the line came from, the player or the engine. + /// The DM reads the same user message either way: a directive is + /// still something the DM was told. The speaker travels on + /// `bus.turn_start` for the listeners that record the turn, so the + /// transcript attributes the line to whoever spoke it. + /// /// Each round sends the history, `input`, and the rounds so far in /// this turn, then streams the response. A tool call dispatches /// through the toolbox the moment its fragments finish streaming, @@ -344,7 +350,7 @@ 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) -> Result<(), TurnError> { + pub fn turn(&mut self, speaker: Speaker, 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 @@ -352,7 +358,7 @@ impl Dm { // 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); + self.bus.turn_start(input, speaker); // Each layer's system.md is read from disk again here, so an // edit to a fragment reaches this turn. The context entries come diff --git a/src/dm/session_start.rs b/src/dm/session_start.rs index 2a2d99c..3f67db5 100644 --- a/src/dm/session_start.rs +++ b/src/dm/session_start.rs @@ -10,13 +10,10 @@ //! 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::campaign::{Campaign, ENGINE_PREFIX, PLAYER_PREFIX}; 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. /// @@ -61,18 +58,22 @@ pub(super) fn resume(campaign: &Campaign, scan: &mut ScanState) -> Result ` 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 +/// A line that starts with [`PLAYER_PREFIX`] is what the player typed, +/// and a line that starts with [`ENGINE_PREFIX`] is what the engine told +/// the DM. Both replay as user messages, because the DM heard both as +/// things it was told. 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. Consecutive lines of one voice +/// join into one message, and a change of voice always starts a new one, +/// so a player line and an engine line stay apart even though both are +/// user messages. A blank line between two lines of one voice 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 speaking: Option = None; let mut content = String::new(); let mut blank = false; @@ -81,31 +82,61 @@ fn reconstruct(text: &str) -> Vec { 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) { + let (voice, said) = voice_of(line); + if speaking == Some(voice) { content.push_str(if blank { "\n\n" } else { "\n" }); } else { if let Some(previous) = speaking { - messages.push(message(previous, std::mem::take(&mut content))); + messages.push(message(previous.role(), std::mem::take(&mut content))); } - speaking = Some(role); + speaking = Some(voice); } content.push_str(said); blank = false; } if let Some(previous) = speaking { - messages.push(message(previous, content)); + messages.push(message(previous.role(), content)); } messages } +/// Who a transcript line came from. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Voice { + /// The player typed it. + Player, + /// The engine told the DM. + Engine, + /// The DM said it. + Narration, +} + +impl Voice { + /// The role this voice replays as. The player and the engine both + /// spoke to the DM, so both come back as user messages. + fn role(self) -> Role { + match self { + Voice::Player | Voice::Engine => Role::User, + Voice::Narration => Role::Assistant, + } + } +} + +/// Who spoke `line`, and what is left of it once its prefix comes off. +fn voice_of(line: &str) -> (Voice, &str) { + if let Some(typed) = line.strip_prefix(PLAYER_PREFIX) { + return (Voice::Player, typed); + } + match line.strip_prefix(ENGINE_PREFIX) { + Some(directive) => (Voice::Engine, directive), + None => (Voice::Narration, line), + } +} + /// 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. +/// exchange from each line the DM was told, by the player or by the +/// engine. Narration older than the first replayed one goes with it, so +/// the tail starts on something the DM was told. fn tail(mut messages: Vec) -> Vec { let starts: Vec = messages .iter() diff --git a/src/dm/session_start_tests.rs b/src/dm/session_start_tests.rs index 445a945..02e2843 100644 --- a/src/dm/session_start_tests.rs +++ b/src/dm/session_start_tests.rs @@ -61,6 +61,46 @@ fn consecutive_player_lines_join_one_user_message() { ); } +#[test] +fn an_engine_line_replays_as_a_user_message() { + assert_eq!( + conversation("storied> Recap the session.\n\nLast time, the road bent north."), + vec![ + (Role::User, "Recap the session.".to_string()), + ( + Role::Assistant, + "Last time, the road bent north.".to_string() + ), + ] + ); +} + +#[test] +fn a_player_line_and_an_engine_line_stay_two_user_messages() { + assert_eq!( + conversation("player> I wait.\n\nstoried> Recap the session.\n\nDawn comes."), + vec![ + (Role::User, "I wait.".to_string()), + (Role::User, "Recap the session.".to_string()), + (Role::Assistant, "Dawn comes.".to_string()), + ] + ); +} + +#[test] +fn consecutive_engine_lines_join_one_user_message() { + assert_eq!( + conversation("storied> Recap the session.\n\nstoried> Keep it short.\n\nDawn comes."), + vec![ + ( + Role::User, + "Recap the session.\n\nKeep it short.".to_string() + ), + (Role::Assistant, "Dawn comes.".to_string()), + ] + ); +} + #[test] fn narration_before_any_player_line_opens_the_conversation() { assert_eq!( diff --git a/src/entities/entities_stub_tests.rs b/src/entities/entities_stub_tests.rs index 4c048cc..84657f4 100644 --- a/src/entities/entities_stub_tests.rs +++ b/src/entities/entities_stub_tests.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use tempfile::TempDir; use super::{SEED_SNIPPET_CHARS, ScanState, Scanner, snippet_around, stub_body}; -use crate::bus::Listener; +use crate::bus::{Listener, Speaker}; use crate::campaign::{CampaignLog, GameTime, START}; // --------------------------------------------------------------------------- @@ -58,7 +58,7 @@ fn a_new_stub_carries_the_turn_time_and_snippet() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]], the innkeeper."); scanner.done("You meet Vera Blackwater, the innkeeper."); @@ -79,7 +79,7 @@ fn a_fresh_world_with_no_anchor_falls_back_to_start_midnight() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("The [[Rusty Anchor]] looms ahead."); scanner.done("The Rusty Anchor looms ahead."); diff --git a/src/entities/entities_tests.rs b/src/entities/entities_tests.rs index 4b34361..ab5860b 100644 --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; use super::{Resolution, ScanState, Scanner, extract_wikilinks, note_body, stub_body}; -use crate::bus::{Flow, Listener}; +use crate::bus::{Flow, Listener, Speaker}; use crate::campaign::START; use tempfile::TempDir; @@ -195,7 +195,7 @@ fn a_new_name_is_auto_established_alongside_a_resolved_one() { state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); - scanner.turn_start("I sleep."); + scanner.turn_start("I sleep.", Speaker::Player); scanner.delta("[[Vera]] appears. [[Vera Blackwater]] is here."); scanner.done("[Vera] appears. Vera Blackwater is here."); @@ -214,13 +214,13 @@ fn a_turn_that_never_finishes_leaves_the_completed_set_alone() { let mut scanner = Scanner::new(Arc::clone(&state), None); // A clean first turn promotes Vera. - scanner.turn_start("I sleep."); + scanner.turn_start("I sleep.", Speaker::Player); 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.turn_start("I search.", Speaker::Player); scanner.delta("[[Vera Blackwater]]"); assert_eq!( @@ -235,12 +235,12 @@ fn a_turn_start_resets_the_accumulation_for_the_next_turn() { state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); - scanner.turn_start("I sleep."); + scanner.turn_start("I sleep.", Speaker::Player); 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.turn_start("I wake.", Speaker::Player); scanner.done("You wake."); assert_eq!(state.lock().unwrap().completed().count(), 0); @@ -252,7 +252,7 @@ fn a_wikilink_split_across_deltas_is_still_resolved() { state.lock().unwrap().register("Vera Blackwater"); let mut scanner = Scanner::new(Arc::clone(&state), None); - scanner.turn_start("I listen."); + scanner.turn_start("I listen.", Speaker::Player); scanner.delta("She is [[Vera "); scanner.delta("Blackwater]]."); scanner.done("She is Vera Blackwater."); @@ -275,7 +275,7 @@ fn an_unseen_bare_name_writes_a_stub_under_entities_and_lands_in_context() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]], the innkeeper."); scanner.done("You meet Vera Blackwater, the innkeeper."); @@ -297,7 +297,7 @@ fn an_unseen_scoped_name_writes_a_stub_under_its_own_scope() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("A guard steps forward: [[people/Joseph Black]]."); scanner.done("A guard steps forward: Joseph Black."); @@ -330,7 +330,7 @@ fn a_stub_already_on_disk_is_left_alone() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("[[Vera Blackwater]] is here."); scanner.done("Vera Blackwater is here."); @@ -349,7 +349,7 @@ fn a_directory_entity_gets_no_stub_beside_it() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I pray."); + scanner.turn_start("I pray.", Speaker::Player); scanner.delta("[[characters/sister-maren]] lifts her holy symbol."); scanner.done("Sister Maren lifts her holy symbol."); @@ -371,7 +371,7 @@ fn an_ambiguous_bare_name_injects_nothing_and_writes_no_stub() { } let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("[[Joseph Black]] walks in."); scanner.done("Joseph Black walks in."); @@ -385,7 +385,7 @@ fn a_new_name_with_no_world_holds_in_memory_only() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), None); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("[[the Rusty Anchor]] looms ahead."); scanner.done("the Rusty Anchor looms ahead."); @@ -401,7 +401,7 @@ fn a_stub_is_written_at_done_not_at_delta() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]]."); let stub = world.path().join("entities/Vera Blackwater.md"); @@ -418,7 +418,7 @@ fn a_cancelled_turn_writes_no_stub() { let state = Arc::new(Mutex::new(ScanState::default())); let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); - scanner.turn_start("I enter."); + scanner.turn_start("I enter.", Speaker::Player); scanner.delta("You meet [[Vera Blackwater]]."); scanner.cancelled("You meet Vera Blackwater."); diff --git a/src/entities/mod.rs b/src/entities/mod.rs index b8d274b..76ee56f 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -38,7 +38,7 @@ use std::sync::{Arc, Mutex}; use pulldown_cmark::{Event, LinkType, Options, Parser, Tag}; use serde_yaml_ng::Value; -use crate::bus::{Flow, Listener}; +use crate::bus::{Flow, Listener, Speaker}; use crate::campaign::{CampaignLog, GameTime, START}; use crate::knowledge::frontmatter::split_frontmatter; use crate::knowledge::{ENTRY_FILE, address_of, is_history_directory, read_frontmatter}; @@ -219,7 +219,7 @@ impl Scanner { } impl Listener for Scanner { - fn turn_start(&mut self, _input: &str) { + fn turn_start(&mut self, _input: &str, _speaker: Speaker) { let mut state = self.state.lock().unwrap(); state.accumulating.clear(); state.narration.clear(); diff --git a/src/play/screen.rs b/src/play/screen.rs index 7784ff6..0f4c0b5 100644 --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -33,7 +33,8 @@ use super::prompt; use super::sync::SyncGuard; use super::transcript::{Kind, Transcript, aside, failure, player}; use super::viewport::ViewportRows; -use super::worker::{TurnEvent, Worker}; +use super::worker::{TurnEvent, TurnInput, Worker}; +use crate::bus::Speaker; /// The rows the inline viewport takes with a one-row prompt and a one-row /// tail area: that tail row, the rule under it, and that one prompt row. @@ -118,20 +119,21 @@ pub trait Stage { fn take_stage(&self, slug: &str) -> Result<(), String>; } -/// A line that submits itself before the first key, as if the player -/// typed it. A canned scenario opens a fresh session this way, and a -/// resumed session opens the same way with a recap instead. -pub struct Opening { - /// The line itself, submitted exactly as if the player had typed it. - pub text: String, - /// Whether the turn this line starts draws a horizontal rule into the - /// transcript once it ends with [`TurnEvent::Done`]. A canned - /// scenario's opening leaves this false, since there is no earlier - /// play to set apart from what follows. A resumed session's recap - /// sets it true, so the rule marks where the recap stops and live - /// play begins. A turn that ends with [`TurnEvent::Failed`] draws no - /// rule either way. - pub divider: bool, +/// A line that submits itself before the first key, and how the screen +/// treats it. +pub enum Opening { + /// A canned scenario's first line. The player chose it, so it echoes + /// on screen as their line and lands in the prompt history, and its + /// turn draws no divider: there is no earlier play to set apart. + Scenario(String), + /// A resumed session's recap trigger. The engine wrote it, so it + /// never shows on screen and never enters the prompt history; the + /// worker still records it in the transcript file, where the record + /// says why the DM recapped. Its turn ends with a horizontal rule + /// into the transcript once it ends with [`TurnEvent::Done`], to + /// mark where the recap stops and live play begins. A turn that + /// ends with [`TurnEvent::Failed`] draws no rule. + Recap(String), } /// What a session brings to the loop besides the terminal, the player's @@ -149,9 +151,8 @@ pub struct Session<'a> { /// Where the screen learns about the world's characters and puts one /// on stage. pub stage: &'a dyn Stage, - /// A line that submits itself before the first key, as if the player - /// typed it. A canned scenario or a resumed session's recap opens the - /// session through this. + /// A line that starts a turn before the first key. A canned scenario + /// or a resumed session's recap opens the session through this. pub opening: Option, } @@ -169,8 +170,8 @@ pub struct Session<'a> { /// terminal paints each pass in one go instead of painting whatever has /// landed on the wire so far. The opening banner queues before the loop /// and paints with the first pass, inside that pass's update. An opening -/// line in `session` submits right after the banner, before the first -/// key, the same as a line the player typed. +/// line in `session` starts its turn right after the banner, before the +/// first key. /// /// `viewport` changes the viewport's height when the prompt needs more or /// fewer rows. Each resize sits inside the same guard as the repaint that @@ -218,9 +219,16 @@ pub fn play>( .transcript .insert(terminal, ¬ice, aside(), Kind::Aside)?; } - if let Some(opening) = session.opening.take() { - screen.input.set_text(opening.text); - screen.submit(terminal, worker, session, guard, viewport, opening.divider)?; + match session.opening.take() { + Some(Opening::Scenario(text)) => { + screen.input.set_text(text); + screen.submit(terminal, worker, session, guard, viewport)?; + } + // The recap trigger goes straight to the worker: the screen + // shows nothing, the prompt history learns nothing, and the + // sparkle on the next pass is what says a turn is running. + Some(Opening::Recap(text)) => screen.send(worker, Speaker::Engine, text, true), + None => {} } loop { screen.tick = screen.tick.wrapping_add(1); @@ -256,7 +264,7 @@ pub fn play>( Some(Key::Down) => down(&mut screen, terminal, session.history)?, Some(Key::Newline) => screen.input.insert('\n'), Some(Key::Paste(text)) => screen.input.insert_str(&text), - Some(Key::Enter) => screen.submit(terminal, worker, session, guard, viewport, false)?, + Some(Key::Enter) => screen.submit(terminal, worker, session, guard, viewport)?, Some(Key::Cancel | Key::Interrupt) if screen.busy => { worker.cancel.store(true, Ordering::Relaxed); } @@ -435,9 +443,8 @@ impl Screen { /// Sends the input line to the worker and puts it in the transcript. /// /// A blank line is nothing to say. A line typed while a turn runs stays on - /// the prompt until the turn ends. A send that fails means the worker is - /// gone, which leaves nothing to do but keep drawing until the player - /// quits. + /// the prompt until the turn ends. The turn itself starts in + /// [`Self::send`], which every line the DM hears goes through. /// /// The line goes out under the prefix it was typed under, so it keeps /// the shape it had on the prompt. The clock the next turn ends on is @@ -454,12 +461,6 @@ impl Screen { /// stays inside this same guard rather than waiting for the next pass: /// otherwise the screen would sit part-cleared, outside any synchronized /// update, until that next pass came around. - /// - /// `divider` marks the turn this line starts as the one turn that draws - /// a horizontal rule into the transcript when it ends with - /// [`TurnEvent::Done`]. Every key-driven submit passes `false`; only the - /// session's own opening line, when it is a resumed session's recap, - /// passes `true`. fn submit>( &mut self, terminal: &mut Terminal, @@ -467,7 +468,6 @@ impl Screen { session: &mut Session<'_>, guard: &mut G, viewport: &mut V, - divider: bool, ) -> Result<(), B::Error> { // One trim answers every question the rest of this asks: whether // the line says anything, whether it names a slash command, and @@ -516,13 +516,28 @@ impl Screen { self.transcript.place(terminal)?; terminal.draw(|frame| render(self, frame))?; guard.end(); - self.busy = true; - self.divider = divider; - let _ = worker.inputs.send(input.clone()); + self.send(worker, Speaker::Player, input.clone(), false); session.history.record(&input); Ok(()) } + /// Starts a turn on `text`, spoken by `speaker`: the prompt goes + /// busy, the turn's `divider` is set, and the line goes to the + /// worker. + /// + /// Nothing else happens here. The transcript, the prompt, and the + /// prompt history hold what the player said, so a line the engine + /// spoke reaches none of them, and [`Self::submit`] does that work + /// itself for the lines the player types. + /// + /// A send that fails means the worker is gone, which leaves nothing + /// to do but keep drawing until the player quits. + fn send(&mut self, worker: &Worker, speaker: Speaker, text: String, divider: bool) { + self.busy = true; + self.divider = divider; + let _ = worker.inputs.send(TurnInput { speaker, text }); + } + /// Handles `/play`, screen-local like `/context`: never reaches the /// worker, and never touches `busy`, since [`Self::submit`] has /// already refused to run this while a turn is in flight. @@ -735,7 +750,7 @@ fn events( /// that narrates and then calls a secret tool emits no tool event, so the /// stream is the only place that has seen every delta of the turn. /// -/// A turn `submit` marked with `divider` draws a horizontal rule into the +/// A turn `send` marked with `divider` draws a horizontal rule into the /// transcript here, after whatever the DM said, so a resumed session's /// recap ends on a line that marks where it stops and live play begins. fn finish( @@ -775,7 +790,7 @@ fn insert_divider( /// prompt can show a new day or hour. /// /// `divider` resets here too, however the turn ended, so it never carries -/// over to the next one: only `submit` sets it, and only for the one line +/// over to the next one: only `send` sets it, and only for the one line /// it was given for. fn settle(screen: &mut Screen, clock: Clock<'_>) { screen.busy = false; diff --git a/src/play/screen_sync_tests.rs b/src/play/screen_sync_tests.rs index 2c7f6cd..93446ed 100644 --- a/src/play/screen_sync_tests.rs +++ b/src/play/screen_sync_tests.rs @@ -241,7 +241,6 @@ fn submitting_repaints_the_shrunk_viewport_inside_its_own_guard() { &mut session, &mut guard, &mut viewport, - false, ) .unwrap(); diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs index 56abc8e..6e3a3a8 100644 --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -113,7 +113,7 @@ impl Keys for Script { /// Everything a played-back script leaves behind. pub(in crate::play) struct Played { pub(super) terminal: Terminal, - inputs: Receiver, + inputs: Receiver, pub(super) cancel: Arc, /// Holds the worker stub's channels open, so an unsubmitted input on /// `inputs` reads as empty, not disconnected. @@ -205,12 +205,19 @@ impl Played { self.requested.borrow().clone() } - /// The one input the player submitted. + /// The one turn the loop sent the worker: who spoke it, and what it + /// said. + pub(super) fn sent(&self) -> (Speaker, String) { + let input = self.inputs.try_recv().unwrap(); + (input.speaker, input.text) + } + + /// What the one turn the loop sent the worker said. pub(super) fn submitted(&self) -> String { - self.inputs.try_recv().unwrap() + self.sent().1 } - /// Whether the player submitted nothing to the worker. + /// Whether the loop sent the worker nothing. pub(super) fn nothing_submitted(&self) -> bool { self.inputs.try_recv() == Err(TryRecvError::Empty) } @@ -286,19 +293,28 @@ pub(in crate::play) fn play_clocked_on(height: u16, clock: Clock<'_>, steps: Vec play_session_on(height, clock, &EmptyStage, None, steps) } -/// Runs the loop the way [`play_script`] does, with `opening` as the line -/// that submits itself before the first key. `divider` sets whether the -/// turn that answers it draws a horizontal rule into the transcript once -/// it ends with [`TurnEvent::Done`]. -pub(in crate::play) fn play_opening(opening: &str, divider: bool, steps: Vec) -> Played { +/// Runs the loop the way [`play_script`] does, opening on a canned +/// scenario's first line: the player's own line, echoed on screen. +pub(in crate::play) fn play_opening(opening: &str, steps: Vec) -> Played { play_session_on( VIEWPORT_HEIGHT, &mut || None, &EmptyStage, - Some(Opening { - text: opening.to_string(), - divider, - }), + Some(Opening::Scenario(opening.to_string())), + steps, + ) +} + +/// Runs the loop the way [`play_script`] does, opening on a resumed +/// session's recap trigger: the engine's line, which never reaches the +/// screen and whose turn draws a divider when it ends with +/// [`TurnEvent::Done`]. +pub(in crate::play) fn play_recap(trigger: &str, steps: Vec) -> Played { + play_session_on( + VIEWPORT_HEIGHT, + &mut || None, + &EmptyStage, + Some(Opening::Recap(trigger.to_string())), steps, ) } @@ -556,10 +572,13 @@ pub(in crate::play) fn done() -> Step { } #[test] -fn an_opening_line_submits_before_the_first_key() { - let played = play_opening("You wake in a ditch", false, vec![]); +fn a_scenario_opening_submits_before_the_first_key() { + let played = play_opening("You wake in a ditch", vec![]); - assert_eq!(played.submitted(), "You wake in a ditch"); + assert_eq!( + played.sent(), + (Speaker::Player, "You wake in a ditch".to_string()) + ); assert!(played.transcript().contains("> You wake in a ditch")); } @@ -568,17 +587,55 @@ fn the_opening_turn_holds_the_prompt_until_it_ends() { let mut steps = typing("hi"); steps.push(press(Key::Enter)); - let played = play_opening("You wake in a ditch", false, steps); + let played = play_opening("You wake in a ditch", steps); assert_eq!(played.submitted(), "You wake in a ditch"); assert!(played.nothing_submitted()); } #[test] -fn the_player_recalls_the_opening_line_with_up() { +fn the_player_recalls_a_scenario_opening_with_up() { let steps = vec![done(), press(Key::Up)]; - let played = play_opening("You wake in a ditch", false, steps); + let played = play_opening("You wake in a ditch", steps); assert_eq!(played.prompt(), "> You wake in a ditch"); } + +#[test] +fn a_recap_trigger_reaches_the_worker_as_the_engines_line() { + let played = play_recap("Where were we?", vec![]); + + assert_eq!( + played.sent(), + (Speaker::Engine, "Where were we?".to_string()) + ); +} + +#[test] +fn a_recap_trigger_never_shows_on_screen() { + let played = play_recap("Where were we?", vec![done()]); + + assert!(!played.transcript().contains("Where were we?")); + assert!(!played.viewport().contains("Where were we?")); +} + +#[test] +fn the_player_cannot_recall_a_recap_trigger_with_up() { + let steps = vec![done(), press(Key::Up)]; + + let played = play_recap("Where were we?", steps); + + assert_eq!(played.prompt(), ">"); +} + +#[test] +fn a_recap_trigger_holds_the_prompt_until_its_turn_ends() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_recap("Where were we?", steps); + + assert_eq!(played.submitted(), "Where were we?"); + assert!(played.nothing_submitted()); +} diff --git a/src/play/screen_turn_tests.rs b/src/play/screen_turn_tests.rs index 794c801..d989cfc 100644 --- a/src/play/screen_turn_tests.rs +++ b/src/play/screen_turn_tests.rs @@ -8,7 +8,7 @@ use std::sync::atomic::Ordering; use ratatui::text::Text; -use super::tests::{Step, done, play_opening, play_script, press, typing}; +use super::tests::{Step, done, play_opening, play_recap, play_script, press, typing}; use super::*; #[test] @@ -105,24 +105,23 @@ fn a_turn_of_only_a_tool_call_says_nothing_of_the_kind() { } #[test] -fn a_divider_flagged_opening_draws_a_rule_once_its_turn_completes() { - let played = play_opening("Where were we?", true, vec![done()]); +fn a_recap_opening_draws_a_rule_once_its_turn_completes() { + let played = play_recap("Where were we?", vec![done()]); assert!(played.transcript().contains(&RULE.repeat(40))); } #[test] fn a_scenario_opening_draws_no_divider() { - let played = play_opening("You wake in a ditch", false, vec![done()]); + let played = play_opening("You wake in a ditch", vec![done()]); assert!(!played.transcript().contains(&RULE.repeat(40))); } #[test] -fn a_divider_flagged_opening_whose_turn_fails_draws_no_divider() { - let played = play_opening( +fn a_recap_opening_whose_turn_fails_draws_no_divider() { + let played = play_recap( "Where were we?", - true, vec![Step::Turn(TurnEvent::Failed("no".to_string()))], ); @@ -136,7 +135,7 @@ fn a_later_turn_after_the_divider_draws_no_second_rule() { steps.push(press(Key::Enter)); steps.push(done()); - let played = play_opening("Where were we?", true, steps); + let played = play_recap("Where were we?", steps); assert_eq!(played.transcript().matches(&RULE.repeat(40)).count(), 1); } diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 8a0cbb8..eacaa54 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -41,11 +41,12 @@ const POLL_INTERVAL: Duration = Duration::from_millis(50); /// first, starts the DM, and plays until the player quits. /// /// `opening` is a canned scenario's first line and, when given, always -/// submits as the player's first turn before any key, with no divider. -/// When `opening` is `None`, a session the DM resumed from a prior -/// transcript submits its recap trigger instead, with a divider marking -/// where the recap ends and live play begins. A fresh world with no -/// scenario opens with neither, and the player speaks first. +/// runs as the player's first turn before any key, with no divider. When +/// `opening` is `None`, a session the DM resumed from a prior transcript +/// runs its recap trigger instead, which the engine speaks: the player +/// never sees it, and a divider marks where the recap ends and live play +/// begins. A fresh world with no scenario opens with neither, and the +/// player speaks first. /// /// The terminal goes back to how it was before the game, with the /// transcript still on the screen. An init that fails part way through has @@ -85,16 +86,8 @@ pub fn run( // session with a divider, and a fresh world with nothing to recap opens // with neither. let opening = opening - .map(|text| Opening { - text, - divider: false, - }) - .or_else(|| { - dm.recap_trigger().map(|text| Opening { - text, - divider: true, - }) - }); + .map(Opening::Scenario) + .or_else(|| dm.recap_trigger().map(Opening::Recap)); pin_to_bottom(VIEWPORT_HEIGHT).map_err(|error| error.to_string())?; let options = TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT), diff --git a/src/play/worker.rs b/src/play/worker.rs index 44e2194..44c979e 100644 --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -1,5 +1,6 @@ -//! The worker thread. It owns the `Dm` and turns player input into a -//! stream of `TurnEvent`s, so the render loop never waits on the network. +//! The worker thread. It owns the `Dm` and turns each line the DM is +//! told, by the player or by the engine, into a stream of `TurnEvent`s, +//! so the render loop never waits on the network. use std::any::Any; use std::panic::{self, AssertUnwindSafe}; @@ -9,7 +10,7 @@ use std::sync::mpsc::{self, Receiver, Sender}; use ratatui::text::Text; -use crate::bus::ScreenListener; +use crate::bus::{ScreenListener, Speaker}; use crate::dm::{Dm, TurnError}; /// The name the worker thread runs under, which the play loop's panic @@ -40,13 +41,22 @@ pub enum TurnEvent { Failed(String), } +/// One turn's input on its way to the worker: the line, and who spoke +/// it. The player types most of them; the engine speaks the rest, such +/// as the trigger that asks a resumed session for its recap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnInput { + pub speaker: Speaker, + pub text: String, +} + /// A running worker thread, with the channels that reach it. /// /// The thread stops once `inputs` closes. Nothing waits for it: leaving the /// game should not wait on a turn that is still streaming. pub struct Worker { - /// Player input, one line per turn. - pub inputs: Sender, + /// The lines the turns run from, one per turn. + pub inputs: Sender, /// What the turns report back. pub events: Receiver, /// Set this to interrupt the turn in progress. @@ -88,7 +98,7 @@ impl Worker { /// false at the start of each turn. fn run( mut dm: Dm, - requests: &Receiver, + requests: &Receiver, replies: &Sender, cancel: &Arc, ) { @@ -103,7 +113,7 @@ fn run( for input in requests { cancel.store(false, Ordering::Relaxed); - if let Some(event) = run_turn(&mut || dm.turn(&input)) { + if let Some(event) = run_turn(&mut || dm.turn(input.speaker, &input.text)) { // 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); @@ -164,6 +174,22 @@ mod tests { use std::sync::atomic::Ordering; use std::thread::JoinHandle; + /// A line the player typed, on its way to the worker. + fn typed(text: &str) -> TurnInput { + TurnInput { + speaker: Speaker::Player, + text: text.to_string(), + } + } + + /// A line the engine spoke, on its way to the worker. + fn directed(text: &str) -> TurnInput { + TurnInput { + speaker: Speaker::Engine, + text: text.to_string(), + } + } + /// Serves each of `bodies` in order, one per accepted connection, as a /// complete HTTP/1.1 event-stream response, and returns the URL to /// reach it. @@ -308,7 +334,7 @@ mod tests { let (url, server) = fake_server(vec![body]); let worker = Worker::spawn(dm_for(url)); - worker.inputs.send("I sleep.".to_string()).unwrap(); + worker.inputs.send(typed("I sleep.")).unwrap(); assert_eq!( worker.events.recv().unwrap(), @@ -337,7 +363,7 @@ mod tests { let (url, server) = fake_server(vec![tool_round, reply]); let worker = Worker::spawn(dm_for(url)); - worker.inputs.send("I roll.".to_string()).unwrap(); + worker.inputs.send(typed("I roll.")).unwrap(); assert!( matches!(worker.events.recv().unwrap(), TurnEvent::Tool(_)), @@ -425,7 +451,7 @@ mod tests { fn a_failed_turn_sends_the_error_text() { let worker = Worker::spawn(dm_for(dead_address())); - worker.inputs.send("I sleep.".to_string()).unwrap(); + worker.inputs.send(typed("I sleep.")).unwrap(); let event = worker.events.recv().unwrap(); @@ -444,7 +470,7 @@ mod tests { let (url, ready, resume, server) = staggered_server(cancelled_reply, vec![reply]); let worker = Worker::spawn(dm_for(url)); - worker.inputs.send("I sleep.".to_string()).unwrap(); + worker.inputs.send(typed("I sleep.")).unwrap(); // The worker's request lands only after it resets the cancel flag // for this turn, so setting it here cannot be undone by the reset. @@ -461,7 +487,7 @@ mod tests { TurnEvent::Cancelled("You wake.".to_string()) ); - worker.inputs.send("I wake.".to_string()).unwrap(); + worker.inputs.send(typed("I wake.")).unwrap(); assert_eq!( worker.events.recv().unwrap(), TurnEvent::Delta("Hi.".to_string()) @@ -483,7 +509,7 @@ mod tests { let (url, ready, resume, server) = staggered_server(tool_round, vec![]); let worker = Worker::spawn(dm_for(url)); - worker.inputs.send("I roll.".to_string()).unwrap(); + worker.inputs.send(typed("I roll.")).unwrap(); // The worker's request lands only after it resets the cancel flag // for this turn, so setting it here cannot be undone by the reset. @@ -516,7 +542,7 @@ mod tests { // is over, so the transcript is complete when it does. let (inputs, requests) = mpsc::channel(); let (replies, events) = mpsc::channel(); - inputs.send("I sleep.".to_string()).unwrap(); + inputs.send(typed("I sleep.")).unwrap(); drop(inputs); run( @@ -540,4 +566,33 @@ mod tests { let bodies: Vec = entries.into_iter().map(|entry| entry.body).collect(); assert_eq!(bodies, vec!["player> I sleep.\n\nYou wake.".to_string()]); } + + #[test] + fn a_turn_the_engine_spoke_records_under_the_engines_prefix() { + let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Last time...\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, server) = fake_server(vec![body]); + let world = tempfile::tempdir().unwrap(); + let campaign = Campaign::open(world.path()).unwrap(); + + let (inputs, requests) = mpsc::channel(); + let (replies, _events) = mpsc::channel(); + inputs.send(directed("Recap the session.")).unwrap(); + drop(inputs); + + run( + dm_recording_to(url, world.path()), + &requests, + &replies, + &Arc::new(AtomicBool::new(false)), + ); + server.join().unwrap(); + + let entries = campaign.transcript_entries().unwrap(); + let bodies: Vec = entries.into_iter().map(|entry| entry.body).collect(); + assert_eq!( + bodies, + vec!["storied> Recap the session.\n\nLast time...".to_string()] + ); + } }