From 6e3070d364cf839da41c52ff30d8ebb8af426b2d Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Thu, 30 Jul 2026 21:29:52 -0400 Subject: [PATCH] Add storied roll, the CLI face for the dice engine Task 1 landed dice::parse and dice::roll as a pure module with no way to run it from the command line. This wires storied roll NOTATION through cli.rs the way storied srd is wired: parse the notation, roll it once with a real RNG at the CLI boundary, and print the result as a short story, one line per die showing every value it showed and whether it counted, the modifier if the notation carried one, and the total. An unparseable notation prints the parse error and exits nonzero. The story renderer lives in src/dice_story.rs, wired into the dice module rather than inline in cli.rs, since it belongs to the dice domain. Plain stdout only; the fancier formatting waits for the transcript work in a later phase. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CUXjWo1zGFhdJUig1hcQGf --- src/cli.rs | 50 ++++++++++++++++ src/dice.rs | 3 + src/dice_story.rs | 38 +++++++++++++ src/dice_story_tests.rs | 123 ++++++++++++++++++++++++++++++++++++++++ tests/roll.rs | 28 +++++++++ 5 files changed, 242 insertions(+) create mode 100644 src/dice_story.rs create mode 100644 src/dice_story_tests.rs create mode 100644 tests/roll.rs diff --git a/src/cli.rs b/src/cli.rs index 6d6c5c4..7a2799d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,7 @@ use std::path::Path; use clap::{Parser, Subcommand}; +use crate::dice; use crate::srd::fetch::{self, FetchOutcome, SrdSources}; use crate::srd::verify; @@ -22,6 +23,11 @@ enum Command { #[command(subcommand)] command: SrdCommand, }, + /// Roll dice notation once and print the result. + Roll { + /// Dice notation to roll, like `d20` or `2d6+3`. + notation: String, + }, /// Talk to the dungeon master in the terminal. // Coverage builds leave this command out, along with the terminal it // needs. See `src/play/mod.rs`. @@ -58,6 +64,7 @@ pub fn run(cli: Cli, srd_sources: &SrdSources, layer_root: &Path) -> Result<(), Some(Command::Srd { command: SrdCommand::Verify, }) => run_srd_verify(layer_root), + Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] Some(Command::Play { api_base, model }) => { crate::play::run(&crate::config::Overrides { api_base, model }) @@ -93,6 +100,15 @@ fn render_verify_report(report: &verify::Report) -> Result<(), String> { } } +/// Parses `notation`, rolls it once with a real random source, and prints +/// its story. +fn run_roll(notation: &str) -> Result<(), String> { + let parsed = dice::parse(notation).map_err(|error| error.to_string())?; + let result = dice::roll(&parsed, &mut rand::rng()); + println!("{}", dice::story::tell(&result)); + Ok(()) +} + fn report_fetch(destination: &Path, outcome: FetchOutcome) { match outcome { FetchOutcome::AlreadyPresent => { @@ -157,6 +173,26 @@ mod tests { } } + fn roll_cli(notation: &str) -> Cli { + Cli { + command: Some(Command::Roll { + notation: notation.to_string(), + }), + } + } + + fn unused_sources() -> SrdSources { + SrdSources { + pdf: FetchConfig { + url: "http://127.0.0.1:0".to_string(), + destination: PathBuf::from("unused"), + expected_sha256: "0".repeat(64), + }, + text: never_dialed_text_config(), + meta: fixture_meta(), + } + } + fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); @@ -367,6 +403,20 @@ mod tests { assert!(result.is_err()); } + #[test] + fn run_roll_prints_the_story_for_valid_notation() { + let result = run(roll_cli("4d6kh3"), &unused_sources(), &unique_temp_dir()); + + assert_eq!(result, Ok(())); + } + + #[test] + fn run_roll_reports_a_parse_error_for_invalid_notation() { + let result = run(roll_cli("banana"), &unused_sources(), &unique_temp_dir()); + + assert!(result.is_err()); + } + #[test] fn render_verify_report_succeeds_with_a_summary_when_there_are_no_failures() { let report = verify::Report { diff --git a/src/dice.rs b/src/dice.rs index 5c99a7a..6de1aab 100644 --- a/src/dice.rs +++ b/src/dice.rs @@ -566,6 +566,9 @@ fn apply_keep_drop(dice: &mut [Die], keep_drop: Option) { } } +#[path = "dice_story.rs"] +pub mod story; + #[cfg(test)] #[path = "dice_tests.rs"] mod tests; diff --git a/src/dice_story.rs b/src/dice_story.rs new file mode 100644 index 0000000..7661308 --- /dev/null +++ b/src/dice_story.rs @@ -0,0 +1,38 @@ +//! Renders a completed roll as a short story: the notation, every die as +//! it fell, the modifier, and the total. + +use super::{Die, RollResult}; + +/// Renders `result` as a story: the notation on its own line, one line +/// per die showing every value it showed and whether it counted, the +/// modifier when the notation carried one, and the total on the last line. +pub fn tell(result: &RollResult) -> String { + let mut lines = Vec::with_capacity(result.dice.len() + 3); + lines.push(result.notation.clone()); + lines.extend(result.dice.iter().map(describe)); + if result.modifier != 0 { + lines.push(format!("modifier: {:+}", result.modifier)); + } + lines.push(format!("total: {}", result.total)); + lines.join("\n") +} + +/// Describes one die: every value it showed, in the order it showed +/// them, and whether it was kept or dropped. +fn describe(die: &Die) -> String { + let rolls = die + .rolls + .iter() + .map(u32::to_string) + .collect::>() + .join(", "); + if die.kept { + format!(" d{}: {rolls} kept ({:+})", die.sides, die.contribution()) + } else { + format!(" d{}: {rolls} dropped", die.sides) + } +} + +#[cfg(test)] +#[path = "dice_story_tests.rs"] +mod tests; diff --git a/src/dice_story_tests.rs b/src/dice_story_tests.rs new file mode 100644 index 0000000..b1b7895 --- /dev/null +++ b/src/dice_story_tests.rs @@ -0,0 +1,123 @@ +//! Tests for `dice_story.rs`, split out to keep the production file +//! under the project's file-length guideline. + +use super::*; + +fn die(sides: u32, rolls: Vec, value: u32, kept: bool, negative: bool) -> Die { + Die { + sides, + rolls, + value, + kept, + negative, + } +} + +fn roll_result(notation: &str, dice: Vec, modifier: i64, total: i64) -> RollResult { + RollResult { + notation: notation.to_string(), + dice, + modifier, + total, + } +} + +#[test] +fn the_first_line_is_the_notation() { + let result = roll_result("d6", vec![die(6, vec![4], 4, true, false)], 0, 4); + + let story = tell(&result); + + assert_eq!(story.lines().next(), Some("d6")); +} + +#[test] +fn a_kept_die_shows_its_value_and_contribution() { + let result = roll_result("d6", vec![die(6, vec![4], 4, true, false)], 0, 4); + + let story = tell(&result); + + assert!(story.contains("d6: 4 kept (+4)")); +} + +#[test] +fn a_dropped_die_shows_its_value_without_a_contribution() { + let result = roll_result("4d6kh3", vec![die(6, vec![2], 2, false, false)], 0, 0); + + let story = tell(&result); + + assert!(story.contains("d6: 2 dropped")); +} + +#[test] +fn a_rerolled_die_shows_every_value_it_showed() { + let result = roll_result("d20r1", vec![die(20, vec![1, 15], 15, true, false)], 0, 15); + + let story = tell(&result); + + assert!(story.contains("d20: 1, 15 kept")); +} + +#[test] +fn a_negative_group_shows_a_negative_contribution() { + let result = roll_result("d20-d4", vec![die(4, vec![3], 3, true, true)], 0, -3); + + let story = tell(&result); + + assert!(story.contains("d4: 3 kept (-3)")); +} + +#[test] +fn every_die_appears_in_roll_order() { + let result = roll_result( + "2d6", + vec![ + die(6, vec![5], 5, true, false), + die(6, vec![2], 2, true, false), + ], + 0, + 7, + ); + + let story = tell(&result); + let first = story.find("d6: 5").unwrap(); + let second = story.find("d6: 2").unwrap(); + + assert!(first < second); +} + +#[test] +fn the_modifier_line_is_omitted_when_there_is_none() { + let result = roll_result("d6", vec![die(6, vec![4], 4, true, false)], 0, 4); + + let story = tell(&result); + + assert!(!story.contains("modifier:")); +} + +#[test] +fn a_positive_modifier_is_shown_with_its_sign() { + let result = roll_result("d8+1", vec![die(8, vec![4], 4, true, false)], 1, 5); + + let story = tell(&result); + + assert!(story.contains("modifier: +1")); +} + +#[test] +fn a_negative_modifier_is_shown_with_its_sign() { + let result = roll_result("2d10-2", vec![die(10, vec![4], 4, true, false)], -2, 2); + + let story = tell(&result); + + assert!(story.contains("modifier: -2")); +} + +#[test] +fn the_story_ends_with_the_total() { + let result = roll_result("d6", vec![die(6, vec![4], 4, true, false)], 0, 4); + + let story = tell(&result); + + assert_eq!(story.lines().last(), Some("total: 4")); +} diff --git a/tests/roll.rs b/tests/roll.rs new file mode 100644 index 0000000..19839d7 --- /dev/null +++ b/tests/roll.rs @@ -0,0 +1,28 @@ +use assert_cmd::Command; + +#[test] +fn rolling_valid_notation_echoes_it_and_prints_a_total() { + let output = Command::cargo_bin("storied") + .unwrap() + .args(["roll", "4d6kh3"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("4d6kh3")); + assert!(stdout.contains("total:")); +} + +#[test] +fn rolling_unparseable_notation_prints_the_error_and_fails() { + let output = Command::cargo_bin("storied") + .unwrap() + .args(["roll", "banana"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("banana")); +} -- 2.51.2