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")); +}