use std::io::{self, Write};
use clap::Parser;
use rand::rngs::StdRng;
use rand::SeedableRng;
use crate::app::{evaluate, Outcome};
use crate::parse;
#[derive(Parser, Debug)]
#[command(name = "tinhorn", version, about, long_about = None)]
pub struct Cli {
#[arg(value_name = "EXPRESSION", trailing_var_arg = true)]
pub expr: Vec<String>,
#[arg(short, long)]
pub print: bool,
#[arg(short, long)]
pub verbose: bool,
#[arg(long)]
pub json: bool,
#[arg(long, value_name = "N")]
pub seed: Option<u64>,
#[arg(long)]
pub mute: bool,
}
impl Cli {
pub fn expression(&self) -> String {
self.expr.join(" ").trim().to_string()
}
}
pub fn run_one_shot(cli: &Cli, expr: &str) -> io::Result<()> {
let roll = match parse::parse(expr) {
Ok(r) => r,
Err(e) => {
eprintln!("tinhorn: {e}");
std::process::exit(2);
}
};
let mut rng = match cli.seed {
Some(seed) => StdRng::seed_from_u64(seed),
None => StdRng::from_entropy(),
};
let outcome = evaluate(expr, &roll, &mut rng);
let stdout = io::stdout();
let mut out = stdout.lock();
if cli.json {
let json = serde_json::to_string_pretty(&outcome).expect("Outcome serializes");
writeln!(out, "{json}")?;
} else if cli.verbose {
write!(out, "{}", format_verbose(&outcome))?;
} else {
writeln!(out, "{}", outcome.total)?;
}
if (cli.print || cli.verbose) && outcome.success == Some(false) {
out.flush()?;
std::process::exit(1);
}
Ok(())
}
pub fn format_verbose(o: &Outcome) -> String {
use std::fmt::Write;
let mut s = String::new();
for term in &o.terms {
let faces: Vec<String> = term
.dice
.iter()
.map(|d| {
let mut f = d.value.to_string();
if d.exploded {
f.push('!'); }
if !d.kept {
f = format!("[{f}]"); }
f
})
.collect();
let _ = write!(s, " {:<10} {}", term.notation, faces.join(" "));
if term.multiplier != 1 {
let _ = write!(s, " ×{}", term.multiplier);
}
let _ = writeln!(s, " = {}", term.subtotal);
}
if o.modifier != 0 {
let sign = if o.modifier > 0 { "+" } else { "−" };
let _ = writeln!(s, " {:<10} {sign}{}", "modifier", o.modifier.abs());
}
let _ = writeln!(s, " total {}", o.total);
if let (Some(target), Some(success), Some(margin)) = (o.target, o.success, o.margin) {
let verdict = crate::app::verdict_text(success, margin).to_lowercase();
let _ = writeln!(s, " vs {target:<7} {verdict}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn outcome(expr: &str, seed: u64) -> Outcome {
let roll = parse::parse(expr).unwrap();
let mut rng = StdRng::seed_from_u64(seed);
evaluate(expr, &roll, &mut rng)
}
#[test]
fn seeded_evaluation_is_deterministic() {
let a = outcome("4d6dl1+2", 42);
let b = outcome("4d6dl1+2", 42);
assert_eq!(a.total, b.total);
let av: Vec<u32> = a.terms[0].dice.iter().map(|d| d.value).collect();
let bv: Vec<u32> = b.terms[0].dice.iter().map(|d| d.value).collect();
assert_eq!(av, bv, "same seed must reproduce the same dice");
}
#[test]
fn total_matches_kept_dice_and_modifier() {
let o = outcome("4d6dl1+2", 7);
assert_eq!(o.terms[0].dice.len(), 4);
assert_eq!(o.terms[0].dice.iter().filter(|d| !d.kept).count(), 1);
let kept: i32 = o.terms[0]
.dice
.iter()
.filter(|d| d.kept)
.map(|d| d.value as i32)
.sum();
assert_eq!(o.total, kept + 2);
assert_eq!(o.modifier, 2);
}
#[test]
fn multiply_is_reflected_in_subtotal() {
let o = outcome("3d6*2+d8", 3);
let t0 = &o.terms[0];
let raw: i32 = t0
.dice
.iter()
.filter(|d| d.kept)
.map(|d| d.value as i32)
.sum();
assert_eq!(t0.multiplier, 2);
assert_eq!(t0.subtotal, raw * 2);
}
#[test]
fn exploded_dice_are_flagged() {
for seed in 0..200 {
let o = outcome("6d6!", seed);
if o.terms[0].dice.iter().any(|d| d.exploded) {
assert!(o.terms[0]
.dice
.iter()
.filter(|d| d.exploded)
.all(|d| d.kept));
return;
}
}
panic!("no seed produced an explosion");
}
#[test]
fn json_has_the_expected_shape() {
let o = outcome("2d20kh1", 1);
let v: serde_json::Value = serde_json::to_value(&o).unwrap();
assert!(v["total"].is_number());
assert_eq!(v["terms"][0]["notation"], "2d20kh1");
assert_eq!(v["terms"][0]["dice"].as_array().unwrap().len(), 2);
let die = &v["terms"][0]["dice"][0];
assert!(die["value"].is_number());
assert!(die["kept"].is_boolean());
assert!(die["exploded"].is_boolean());
assert!(die["crit"].is_boolean());
assert!(die["fumble"].is_boolean());
}
#[test]
fn json_crits_match_the_arena_rule() {
for seed in 0..300 {
let o = outcome("4d6", seed);
for d in &o.terms[0].dice {
assert_eq!(d.crit, d.kept && d.value == 6, "seed {seed}");
assert_eq!(d.fumble, d.kept && d.value == 1, "seed {seed}");
}
}
for seed in 0..500 {
let o = outcome("2d20kl1", seed);
if let Some(d) = o.terms[0].dice.iter().find(|d| !d.kept && d.value == 20) {
assert!(!d.crit, "seed {seed}: a dropped 20 must not be a crit");
return;
}
}
panic!("no seed dropped a 20 under disadvantage");
}
#[test]
fn verbose_marks_dropped_and_shows_total() {
let o = outcome("4d6dl1", 7);
let text = format_verbose(&o);
assert!(text.contains("4d6dl1"), "term notation missing");
assert!(text.contains('['), "dropped die not bracketed");
assert!(text.contains("total"), "total line missing");
}
#[test]
fn expression_joins_args() {
let cli = Cli::parse_from(["tinhorn", "d6", "+", "d8"]);
assert_eq!(cli.expression(), "d6 + d8");
let cli = Cli::parse_from(["tinhorn", "d20", "vs", "15"]);
assert_eq!(cli.expression(), "d20 vs 15");
}
#[test]
fn staked_outcome_carries_the_verdict() {
for seed in 0..50 {
let o = outcome("d20+5 vs 15", seed);
assert_eq!(o.target, Some(15));
assert_eq!(o.margin, Some(o.total - 15));
assert_eq!(o.success, Some(o.total >= 15), "seed {seed}");
}
let o = outcome("3d6", 1);
assert_eq!((o.target, o.success, o.margin), (None, None, None));
}
#[test]
fn staked_json_and_verbose_show_the_verdict() {
let o = outcome("d20 vs 10", 3);
let v: serde_json::Value = serde_json::to_value(&o).unwrap();
assert_eq!(v["target"], 10);
assert!(v["success"].is_boolean());
assert!(v["margin"].is_number());
let text = format_verbose(&o);
assert!(
text.contains("vs 10"),
"verbose verdict line missing:\n{text}"
);
assert!(
text.contains("success") || text.contains("fail"),
"no verdict wording:\n{text}"
);
let v: serde_json::Value = serde_json::to_value(outcome("3d6", 1)).unwrap();
assert!(v.get("target").is_none());
}
}