diff --git a/plans/0000-roadmap.md b/plans/0000-roadmap.md index e4c23fc..dc5c669 100644 --- a/plans/0000-roadmap.md +++ b/plans/0000-roadmap.md @@ -155,8 +155,10 @@ no plan yet is a stub and keeps its number until its plan is written. off.* ([0012](0012-the-archivist.md)) - [ ] **The DM's storytelling voice.** Instructions that teach the DM craft, not just rules, drawn from real storytelling theory: - cadence and rhythm (short beats when danger is close, longer lines - when the world is calm), scene shape (arrive late, leave early, open + cadence and rhythm (bias toward short and punchy; zoom in and speed + up on action, intrigue, investigation, and danger, with an occasional + longer passage only to carry a transition like an overland day of + travel), scene shape (arrive late, leave early, open on something moving), sensory economy (one concrete detail beats three adjectives), tension that rises and rests across a session, and a reply whose length follows the weight of the moment. The @@ -195,3 +197,27 @@ no plan yet is a stub and keeps its number until its plan is written. creation, background world motion, advancement, name generation, colors and theming (the styles all live in `markdown::style` waiting for it). Each one starts as a conversation, not a commitment. + +## Future notes + +Things we want to figure out eventually, parked here before we forget. +None of these are designed; each one opens as a conversation, not a +commitment. + +- [ ] **System-agnostic character operations.** How much of the sheet + tool can be written once, with the game system as data instead of + code. A character having inventory, a level, and conditions is common + across systems; 5e's specific fields (spell slots, prepared spells) + are not. Figure out the seam before building sheet operations twice. + Brushes against the real character sheet row above. +- [ ] **DM modes.** The DM already has a storytelling register. Figure + out whether character creation, leveling up, and combat initiative + should each be their own mode with a specialized tool subset, prompt, + and context, the way encounter mode imagines for rounds. Brushes + against encounter mode above. +- [ ] **New-game onboarding.** A flow for starting a fresh game: what a + player needs to be told or asked before the first session, and how + much of that the DM can run itself versus how much needs setup. +- [ ] **Character death and continuity.** What happens to the record, + the world, and the story when a character dies, and how a game stays + continuous across that. diff --git a/rules/srd-5.2/system.md b/rules/srd-5.2/system.md index 00269a7..55bbdd9 100644 --- a/rules/srd-5.2/system.md +++ b/rules/srd-5.2/system.md @@ -4,6 +4,10 @@ These are the core rules of Dungeons & Dragons 5th edition, hypercondensed. When Six ability scores: Strength, Dexterity, Constitution, Intelligence, Wisdom, Charisma. Each has a modifier: subtract 10, divide by 2, round down. A check is d20 + ability modifier + proficiency bonus (if proficient). A saving throw is the same but always uses the ability modifier. +## When to call for a check + +Call for a check only when success and failure are both possible and the outcome matters. If a task cannot realistically fail, or failing changes nothing, do not roll; let the fiction carry it. The player's stated approach decides which ability the check uses. + ## Proficiency A character's proficiency bonus starts at +2 and grows with level. Proficiency applies to weapon attacks, trained skills, proficient saves, and spellcasting. A tool or skill a character is not proficient in still rolls with the ability modifier alone. diff --git a/src/dm/base-system.md b/src/dm/base-system.md index d299e16..8409ed4 100644 --- a/src/dm/base-system.md +++ b/src/dm/base-system.md @@ -47,6 +47,14 @@ Entities you refer to stay in the next turn's context. Stop naming something and 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. +## Player agency + +The player owns their character's decisions: what the character wants, chooses, and does. A direction in shorthand, like `tell them I'm going`, is yours to bring to life: say it for them, with whatever flourish fits the scene. When the player writes the words themselves in quotes, like `"Bite me, you scoundrel!"`, keep them exactly as written; quote marks are how they claim a line as their own. + +Flesh out only the smaller details a real player would not bother with: the way the character moves, a physical tick, how they hold the door. Leave the character's intent, choices, and voice to the player. + +Do not offer an opinion about what the player should do unless they ask for one; if they ask what you think, say what you think without giving away any secrets. + ## Out of character A player line that opens with `(out of character)` is table talk, not the character speaking or acting. Answer it out of character too, briefly and plainly. The story does not move, nobody in the world hears it, and the clock does not mark. Return to the story when the player does. diff --git a/src/dm/dm_clock_tests.rs b/src/dm/dm_clock_tests.rs index cadba98..79f9fd0 100644 --- a/src/dm/dm_clock_tests.rs +++ b/src/dm/dm_clock_tests.rs @@ -1,5 +1,6 @@ -//! Tests for the clock line: the current campaign clock, stated ahead of -//! the session brief in every turn's system prompt. +//! Tests for the clock section: the current campaign clock and the +//! turns-since-last-`mark` nudge, 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 crate::bus::Speaker; @@ -94,3 +95,57 @@ fn a_dm_with_no_campaign_carries_no_clock_line() { assert!(!prompt.contains("The campaign clock reads")); } + +#[test] +fn turns_without_a_mark_nudge_the_dm_to_mark() { + let world = TempDir::new().unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn(Speaker::Player, "I look around.").unwrap(); + dm.turn(Speaker::Player, "I look again.").unwrap(); + dm.turn(Speaker::Player, "I keep looking.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + requests.recv().unwrap(); + let prompt = next_system_prompt(&requests); + + assert!(prompt.contains("It's been 2 turns since you last marked time.")); +} + +#[test] +fn a_mark_resets_the_turns_since_mark_nudge() { + 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"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn(Speaker::Player, "I rest until dawn.").unwrap(); + dm.turn(Speaker::Player, "I look around.").unwrap(); + dm.turn(Speaker::Player, "I look again.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + requests.recv().unwrap(); + + // The turn that follows the mark carries no nudge yet. + let fresh = next_system_prompt(&requests); + assert!(fresh.contains("The campaign clock reads #d2-0830.")); + assert!(!fresh.contains("since you last marked time")); + + // One completed turn without a mark starts the count again. + let nudged = next_system_prompt(&requests); + assert!(nudged.contains("It's been 1 turn since you last marked time.")); +} diff --git a/src/dm/mod.rs b/src/dm/mod.rs index 645de97..1de479b 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -151,6 +151,13 @@ pub struct Dm { /// replayed at least one message into the history. Read by /// [`Self::recap_trigger`]. resumed: bool, + /// How many completed turns have passed since the DM last called + /// `mark`, shown in the prompt's clock section when it climbs past + /// zero. A turn that marked time does not count against it. + turns_since_mark: usize, + /// Whether a successful `mark` landed this turn, so the turn that + /// moved the clock is not the first to count against [`Self::turns_since_mark`]. + marked_this_turn: bool, } impl Dm { @@ -271,6 +278,8 @@ impl Dm { entity_root, campaign, resumed, + turns_since_mark: 0, + marked_this_turn: false, }) } @@ -359,6 +368,7 @@ impl Dm { // records on `done`, so an unfinished turn leaves both as they // were. self.bus.turn_start(input, speaker); + self.marked_this_turn = false; // Each layer's system.md is read from disk again here, so an // edit to a fragment reaches this turn. The context entries come @@ -373,6 +383,7 @@ impl Dm { self.campaign.as_ref(), self.entity_root.as_deref(), &scan, + self.turns_since_mark, ); drop(scan); self.history[0] = system_message(&prompt); @@ -442,6 +453,10 @@ impl Dm { }; let outcome = self.toolbox.call(&call.function.name, &args); + if outcome.succeeded && call.function.name == "mark" { + self.marked_this_turn = true; + self.turns_since_mark = 0; + } if let Some(display) = &outcome.display && self.bus.tool(Some(display)).is_break() { @@ -457,6 +472,9 @@ impl Dm { self.history.push(user_message); self.history.extend(round_messages); self.history.push(assistant_message(narration.clone())); + if !self.marked_this_turn { + self.turns_since_mark += 1; + } self.bus.done(&narration); return Ok(()); } diff --git a/src/dm/prompt.rs b/src/dm/prompt.rs index 47182bc..9acb029 100644 --- a/src/dm/prompt.rs +++ b/src/dm/prompt.rs @@ -42,8 +42,9 @@ pub(super) fn compose( campaign: Option<&Campaign>, root: Option<&Path>, scan: &ScanState, + turns_since_mark: usize, ) -> String { - let prompt = append_clock(prompt, campaign); + let prompt = append_clock(prompt, campaign, turns_since_mark); let prompt = append_character_sheet(prompt, campaign); let Some(root) = root else { return prompt; @@ -57,11 +58,26 @@ pub(super) fn compose( /// 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 { +/// +/// 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; }; - format!("{prompt}\n\n---\n\nThe campaign clock reads {time}.") + 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 diff --git a/src/dm/tools/mod.rs b/src/dm/tools/mod.rs index 752cf94..e157318 100644 --- a/src/dm/tools/mod.rs +++ b/src/dm/tools/mod.rs @@ -143,6 +143,10 @@ pub struct ToolOutcome { /// What the transcript shows, or `None` when the call was secret or /// the call itself failed. pub display: Option>, + /// Whether the call ran to a normal reply. False when the call + /// itself failed, so a caller can tell a successful call from a + /// fumble that merely returns an error message. + pub succeeded: bool, } /// The DM's set of callable tools. @@ -275,6 +279,7 @@ fn outcome(reply: ToolReply, visibility: Visibility) -> ToolOutcome { ToolOutcome { for_model: reply.for_model, display, + succeeded: true, } } @@ -285,6 +290,7 @@ fn error(message: String) -> ToolOutcome { ToolOutcome { for_model: message, display: None, + succeeded: false, } }