From 5c45dfaed7bd1e4a642a9602071d60457a3fc116 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 4 Aug 2026 15:28:28 -0400 Subject: [PATCH] Route engine directives to the DM unseen and unattributed Live play showed the recap trigger rendered as the player's own line, three paragraphs of instruction under their prompt prefix. The engine now has a directive channel: input carries a Speaker, player or engine, from the screen through the worker and Dm::turn to the bus and the campaign. An engine directive never shows in the terminal, never enters the prompt history, and the transcript records it as storied> rather than player>, with both prefixes defined once in the campaign. Replay reads both back as user messages, a voice change always starts a new message, and the recap's divider still draws when its turn completes. A scenario opening keeps its echo: the player chose that line, and the record says so. Asides (plan 0013) and the Archivist's briefs get the same channel for free. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/bus.rs | 41 +++++++++---- src/campaign/campaign_tests.rs | 31 ++++++++-- src/campaign/mod.rs | 23 +++++-- src/campaign/transcript.rs | 28 +++++++-- src/dm/dm_clock_tests.rs | 13 ++-- src/dm/dm_entity_notes_tests.rs | 25 ++++---- src/dm/dm_session_start_tests.rs | 24 ++++---- src/dm/dm_stage_tests.rs | 21 ++++--- src/dm/dm_tests.rs | 36 +++++------ src/dm/dm_tool_round_tests.rs | 32 +++++----- src/dm/mod.rs | 24 +++++--- src/dm/session_start.rs | 73 +++++++++++++++------- src/dm/session_start_tests.rs | 40 ++++++++++++ src/entities/entities_stub_tests.rs | 6 +- src/entities/entities_tests.rs | 30 ++++----- src/entities/mod.rs | 4 +- src/play/screen.rs | 93 ++++++++++++++++------------ src/play/screen_sync_tests.rs | 1 - src/play/screen_tests.rs | 95 +++++++++++++++++++++++------ src/play/screen_turn_tests.rs | 15 +++-- src/play/terminal.rs | 23 +++---- src/play/worker.rs | 83 ++++++++++++++++++++----- 22 files changed, 515 insertions(+), 246 deletions(-) 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()] + ); + } } -- 2.51.2