//! The turn's system prompt: what the context stack composes, plus the //! session state that rides on the end of it. //! //! Four 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 character sheet says who the player is playing and what //! shape they are in. 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 sheet, 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 super::stage; use crate::campaign::Campaign; use crate::entities::{ScanState, brief_body, note_body}; /// The preamble ahead of the session brief: this is where the story /// stood after the last session, and the conversation wins once it moves /// past that. const SESSION_BRIEF_PREAMBLE: &str = include_str!("session-brief.md"); /// The preamble ahead of the entity notes: the notes may trail the /// conversation, and the conversation wins. const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); /// The preamble ahead of the character sheet: the file is the truth about /// the numbers, and the engine reread it this turn. const CHARACTER_SHEET_PREAMBLE: &str = include_str!("character-sheet.md"); /// `prompt` with the session's own state appended: the clock first, then /// the character on stage, then the session brief, then the notes of /// `scan`'s completed entities. /// /// The clock and the sheet come 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, turns_since_mark: usize, ) -> String { let prompt = append_clock(prompt, campaign, turns_since_mark); let prompt = append_character_sheet(prompt, campaign); let Some(root) = root else { return prompt; }; let prompt = append_session_brief(prompt, root); 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. /// /// When the clock has not moved for `turns_since_mark` turns, a nudge /// rides the same section naming how long it has been, so timekeeping /// stays on the DM's mind without anything else changing. fn append_clock(prompt: String, campaign: Option<&Campaign>, turns_since_mark: usize) -> String { let Some(time) = campaign.and_then(|campaign| campaign.current_time().ok()) else { return prompt; }; let mut body = format!("The campaign clock reads {time}."); if turns_since_mark > 0 { let turn = if turns_since_mark == 1 { "turn" } else { "turns" }; body.push_str(&format!( "\n\nIt's been {turns_since_mark} {turn} since you last marked time." )); } format!("{prompt}\n\n---\n\n{body}") } /// Appends the sheet of the character on stage to `prompt`, under the /// character's own address, so every turn carries the person the player /// is playing. A session with nobody on stage, and one whose sheet is /// missing or empty, contribute nothing, not even /// [`CHARACTER_SHEET_PREAMBLE`]. fn append_character_sheet(prompt: String, campaign: Option<&Campaign>) -> String { let Some(campaign) = campaign else { return prompt; }; let Some(slug) = stage::active_character(campaign) else { return prompt; }; let Some(sheet) = stage::sheet(campaign, &slug) else { return prompt; }; section( prompt, CHARACTER_SHEET_PREAMBLE, &format!("### characters/{slug}\n\n{sheet}"), ) } /// 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 /// [`SESSION_BRIEF_PREAMBLE`]. fn append_session_brief(prompt: String, root: &Path) -> String { match brief_body(root) { Some(body) => section(prompt, SESSION_BRIEF_PREAMBLE, &body), None => prompt, } } /// Appends the notes of `scan`'s completed entities to `prompt`, so the /// next turn keeps straight whatever the DM named last turn. Each note is /// read fresh from `root`, and an entity with no note, or an empty one, /// contributes nothing: it was established with no world, or it has none /// to contribute. Nothing is appended, not even /// [`ENTITY_NOTES_PREAMBLE`], when no completed entity has a note. fn append_entity_notes(prompt: String, root: &Path, scan: &ScanState) -> String { let mut notes = String::new(); for entity in scan.completed() { let Some(body) = note_body(root, &entity.address) else { continue; }; if !notes.is_empty() { notes.push_str("\n\n"); } notes.push_str("### "); notes.push_str(&entity.address); notes.push('\n'); notes.push_str(&body); } if notes.is_empty() { return prompt; } section(prompt, ENTITY_NOTES_PREAMBLE, ¬es) } /// `prompt` with one more section on the end: a rule, then `preamble`, /// then `body`. fn section(prompt: String, preamble: &str, body: &str) -> String { format!("{prompt}\n\n---\n\n{}\n\n{body}", preamble.trim()) }