diff --git a/src/dm/base-system.md b/src/dm/base-system.md index 57c92f2..d28cadb 100644 --- a/src/dm/base-system.md +++ b/src/dm/base-system.md @@ -28,15 +28,34 @@ The first time you introduce a named person, place, or thing, write that first m An address with a scope is exact and unambiguous. [[people/Joseph Black|the guard]] names the guard precisely. A bare name is a reference by name: [[Joseph Black]] matches a known entity named Joseph Black, whatever scope it lives under. +Four shapes cover every wikilink you write, each with its own benefit: + +- `[[Old Marga]]`: bare. Establishes or references by name; the stub lands under `entities/` until something classifies it. +- `[[Old Marga|the miller's widow]]`: the player reads "the miller's widow"; the record still knows exactly who. +- `[[people/Jane Smith]]`: born classified. The player sees only "Jane Smith"; the path never shows. +- `[[places/Sorrowbend|the drowned village]]`: classified for the record, natural in the prose. + +A scoped stub is born sorted instead of waiting for a later classification pass, and one consistent address makes the whole record searchable by a single name. The player never sees the scope or the brackets, only the display text, so there is no cost at the table to scoping a link. `people/` and `places/` are examples, not a fixed list; scope a stub under whatever directory fits it. + If your reference matches nothing, the engine creates a stub there and adds it to your context. A scoped reference like [[people/Joseph Black]] creates the stub under people/. A bare name like [[Joseph Black]] creates it under entities/, the home for things whose kind is not yet known; a later pass can move it to a scope. If a bare name matches more than one entity, the engine says so and injects nothing rather than guess. The part after the pipe is what the player sees in the terminal. Use a pipe only when the display text differs from the address. -For example: "You push through the door of [[The Rusty Anchor|the tavern]] and find [[Old Marga]] hunched by the fire, muttering to a mug that stopped steaming an hour ago." Two names, two wikilinks: one scoped with a pipe for the display form, one bare because the address and the display form are the same. +For example: "You push through the door of [[places/The Rusty Anchor|the tavern]] and find [[Old Marga]] hunched by the fire, muttering to a mug that stopped steaming an hour ago." Two names, two wikilinks: one scoped with a pipe for the display form, one bare because the address and the display form are the same. Entities you refer to stay in the next turn's context. Stop naming something and it drops out of that context. Link what still matters and stop linking what has passed. Name the Rusty Anchor and Vera Blackwater to keep the tavern and the NPC in your head; stop when the party moves on. Every entity you link comes back as a note in your next prompt; that is the payoff for linking it now. -The campaign already has a clock and tools for the timeline. Use `mark` and `recall` for history. Wikilinks are for what is true right now. Keep the two roles distinct. +See Timekeeping for the clock and the history tools, `mark` and `recall`. Wikilinks are for what is true right now; keep the two roles distinct. + +## Timekeeping + +The campaign clock is entirely yours. It moves only when you call `mark`. Time never passes on its own. + +Your prompt tells you the current clock each turn. + +When your narration spends time, mark it: travel, rests, waiting, a scene cut. Choose the size of the jump to fit the fiction, minutes for a scene, hours for a journey, days for downtime. + +Narrated time that you never mark did not happen on the record: the log, the transcript anchors, and every note's "first seen" moment key off the clock, so an unmarked hour leaves the record telling a different story than your narration. ## Visibility @@ -52,6 +71,6 @@ You always get the full text of every entry, screened and secret alike. A screen Tools handle the game's mechanical parts. Roll real dice through a tool instead of inventing a result. -The campaign has a clock. When game time passes, use `mark` to advance it and log the event. To remember what has already happened, use `recall`. +To remember what has already happened, use `recall`. Narrate between rolls. Do not run a long silent stretch of tool calls with nothing said in between. diff --git a/src/dm/dm_clock_tests.rs b/src/dm/dm_clock_tests.rs new file mode 100644 index 0000000..eaf6ab6 --- /dev/null +++ b/src/dm/dm_clock_tests.rs @@ -0,0 +1,95 @@ +//! Tests for the clock line: the current campaign clock, stated ahead of +//! the session brief in every turn's system prompt. + +use super::tests::{dm_for, dm_with_campaign, fake_server, sent_messages, sse_response}; + +use serde_json::json; +use tempfile::TempDir; + +/// The system prompt of the next request the fake server captured. +fn next_system_prompt( + requests: &std::sync::mpsc::Receiver, +) -> String { + sent_messages(&requests.recv().unwrap())[0]["content"] + .as_str() + .unwrap() + .to_string() +} + +/// An SSE response for one round that calls `mark` with `time`, and +/// narrates nothing, so the turn goes another round. +fn mark_call_response(time: &str) -> Vec { + let arguments = json!({ + "time": time, + "event": "Time passes.", + "visibility": "secret", + }) + .to_string(); + let body = format!( + "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"call_1\",\"function\":{{\"name\":\"mark\",\"arguments\":{}}}}}]}},\"finish_reason\":null}}]}}\n\n\ + data: [DONE]\n\n", + json!(arguments) + ); + sse_response(&body) +} + +#[test] +fn a_second_turns_prompt_carries_the_campaign_clock() { + let world = TempDir::new().unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + dm.turn("I look again.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("The campaign clock reads #d1-0000.")); +} + +#[test] +fn a_mid_turn_mark_moves_the_clock_line_on_the_next_turn() { + let world = TempDir::new().unwrap(); + let (url, requests, server) = fake_server(vec![ + mark_call_response("#d2-0830"), + sse_response( + "data: {\"choices\":[{\"delta\":{\"content\":\"Morning comes.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n", + ), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I rest until dawn.").unwrap(); + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("The campaign clock reads #d2-0830.")); +} + +#[test] +fn a_dm_with_no_campaign_carries_no_clock_line() { + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_for(url); + + dm.turn("I look around.").unwrap(); + dm.turn("I look again.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(!prompt.contains("The campaign clock reads")); +} diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 8ba8f8f..8dbf8f1 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -114,7 +114,8 @@ pub enum TurnDelta { /// mid-turn, the context stack that builds the system prompt each turn, /// and the history of the conversation so far, which opens with the tail /// of the last session's transcript when the world has one. The campaign -/// it records to lives on the session-scoped bus as a listener, not here. +/// it records to also rides the session-scoped bus as a listener; `turn` +/// reads its own clone here to read the clock fresh each turn. pub struct Dm { client: Client, toolbox: Toolbox, @@ -134,6 +135,10 @@ pub struct Dm { /// there is no campaign, in which case a turn's prompt carries /// neither a brief nor any entity notes. entity_root: Option, + /// The campaign this session records to, kept so `turn` can read the + /// clock fresh at the start of every turn. `None` when there is no + /// campaign, in which case a turn's prompt carries no clock line. + campaign: Option, } impl Dm { @@ -247,6 +252,7 @@ impl Dm { bus, scan, entity_root, + campaign, }) } @@ -311,11 +317,16 @@ impl Dm { // edit to a fragment reaches this turn. The context entries come // from the mount, which stays as it was scanned at startup. let prompt = self.context.prompt().map_err(TurnError::Context)?; - // The session brief and the notes of the entities the previous - // turn referenced ride on the end of the prompt; the scan lock is - // held only long enough to read the completed set. + // The clock, the session brief, and the notes of the entities the + // previous turn referenced ride on the end of the prompt; the scan + // lock is held only long enough to read the completed set. let scan = self.scan.lock().unwrap(); - let prompt = prompt::compose(prompt, self.entity_root.as_deref(), &scan); + let prompt = prompt::compose( + prompt, + self.campaign.as_ref(), + self.entity_root.as_deref(), + &scan, + ); drop(scan); self.history[0] = system_message(&prompt); @@ -466,3 +477,7 @@ mod entity_notes_tests; #[cfg(test)] #[path = "dm_session_start_tests.rs"] mod session_start_tests; + +#[cfg(test)] +#[path = "dm_clock_tests.rs"] +mod clock_tests; diff --git a/src/dm/prompt.rs b/src/dm/prompt.rs index 9b2039a..1c35438 100644 --- a/src/dm/prompt.rs +++ b/src/dm/prompt.rs @@ -1,14 +1,17 @@ //! The turn's system prompt: what the context stack composes, plus the //! session state that rides on the end of it. //! -//! Two sections follow the context stack's own prompt. The session brief -//! says where the story stood when the last session ended. The entity -//! notes say what the world knows about whatever the DM named last turn. -//! Both are read fresh from disk every turn, so an edit between two turns -//! reaches the second one. +//! Three things follow the context stack's own prompt, in order. A clock +//! line states the current game time, read fresh from the campaign every +//! turn. The session brief says where the story stood when the last +//! session ended. The entity notes say what the world knows about +//! whatever the DM named last turn. The brief and the notes are read +//! fresh from disk every turn too, so an edit between two turns reaches +//! the second one. use std::path::Path; +use crate::campaign::Campaign; use crate::entities::{ScanState, brief_body, note_body}; /// The preamble ahead of the session brief: this is where the story @@ -20,13 +23,20 @@ const SESSION_BRIEF_PREAMBLE: &str = include_str!("session-brief.md"); /// conversation, and the conversation wins. const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); -/// `prompt` with the session's own state appended: the session brief -/// first, then the notes of `scan`'s completed entities. +/// `prompt` with the session's own state appended: the clock first, then +/// the session brief, then the notes of `scan`'s completed entities. /// -/// Both come from the world at `root`. A `None` root is a session with -/// no world, which has neither a brief nor notes to read, so its prompt -/// is the context stack's alone. -pub(super) fn compose(prompt: String, root: Option<&Path>, scan: &ScanState) -> String { +/// The clock comes from `campaign`; the brief and the notes come from the +/// world at `root`. A `None` campaign, or one whose clock cannot be read, +/// contributes no clock line. A `None` root is a session with no world, +/// which has neither a brief nor notes to read. +pub(super) fn compose( + prompt: String, + campaign: Option<&Campaign>, + root: Option<&Path>, + scan: &ScanState, +) -> String { + let prompt = append_clock(prompt, campaign); let Some(root) = root else { return prompt; }; @@ -34,6 +44,18 @@ pub(super) fn compose(prompt: String, root: Option<&Path>, scan: &ScanState) -> append_entity_notes(prompt, root, scan) } +/// Appends a line stating the current campaign clock in `mark`'s own +/// `#dX-HHMM` notation, ahead of the session brief. A `None` campaign, or +/// one whose clock cannot be read, contributes nothing: a turn never +/// fails over the clock, and a bad reading is left out rather than +/// guessed at. +fn append_clock(prompt: String, campaign: Option<&Campaign>) -> String { + let Some(time) = campaign.and_then(|campaign| campaign.current_time().ok()) else { + return prompt; + }; + format!("{prompt}\n\n---\n\nThe campaign clock reads {time}.") +} + /// Appends the world's `session.md` to `prompt`, so the DM knows where /// the story stands however far back the replayed history reaches. A /// world with no brief, or an empty one, contributes nothing, not even