use rand::{Rng, RngExt};
use regex::Regex;
use std::fmt;
use std::sync::LazyLock;
static TERM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\s*(?P<sign>[+-]?)\s*(?:(?P<count>\d*)[dD](?P<sides>\d+)|(?P<modval>\d+))\s*")
.unwrap()
});
pub const MAX_DICE: u32 = 1_000_000;
pub const MAX_SIDES: u32 = 1_000_000;
pub const MAX_MODIFIER: u32 = 1_000_000;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum D20Error {
#[error("invalid die roll expression: no die roll terms found")]
EmptyExpression,
#[error("invalid term: '{0}'")]
InvalidTerm(String),
#[error("missing '+' or '-' operator before '{0}'")]
MissingOperator(String),
#[error("invalid die: a die must have at least one side")]
ZeroSidedDie,
#[error("dice count {count} exceeds the maximum of {max}")]
DiceCountTooLarge {
count: u64,
max: u32,
},
#[error("die with {sides} sides exceeds the maximum of {max}")]
SidesTooLarge {
sides: u64,
max: u32,
},
#[error("modifier {modifier} exceeds the maximum magnitude of {max}")]
ModifierTooLarge {
modifier: i64,
max: u32,
},
#[error("invalid range: min ({min}) must be less than or equal to max ({max})")]
InvalidRange {
min: i32,
max: i32,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Roll {
pub drex: String,
pub terms: Vec<TermResult>,
pub total: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TermResult {
Dice {
multiplier: i32,
sides: u32,
rolls: Vec<u32>,
},
Modifier(i32),
}
impl TermResult {
pub fn subtotal(&self) -> i64 {
match self {
TermResult::Modifier(n) => *n as i64,
TermResult::Dice {
multiplier, rolls, ..
} => {
let sum: i64 = rolls.iter().map(|&r| r as i64).sum();
if *multiplier < 0 { -sum } else { sum }
}
}
}
pub fn rolls(&self) -> &[u32] {
match self {
TermResult::Dice { rolls, .. } => rolls,
TermResult::Modifier(_) => &[],
}
}
fn term(&self) -> DieRollTerm {
match *self {
TermResult::Dice {
multiplier, sides, ..
} => DieRollTerm::DieRoll { multiplier, sides },
TermResult::Modifier(n) => DieRollTerm::Modifier(n),
}
}
}
impl fmt::Display for TermResult {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
TermResult::Modifier(n) => write!(f, "{n:+}"),
TermResult::Dice {
multiplier,
sides,
rolls,
} => {
write!(f, "{multiplier}d{sides}{rolls:?}")
}
}
}
}
impl fmt::Display for Roll {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
for term in &self.terms {
write!(f, "{term}")?;
}
write!(f, " (Total: {})", self.total)
}
}
impl Roll {
pub fn rolls(&self) -> RollIterator {
RollIterator {
drex: self.drex.clone(),
terms: self.terms.iter().map(TermResult::term).collect(),
}
}
}
pub struct RollIterator {
drex: String,
terms: Vec<DieRollTerm>,
}
impl Iterator for RollIterator {
type Item = Roll;
fn next(&mut self) -> Option<Roll> {
let mut rng = rand::rng();
let terms: Vec<TermResult> = self
.terms
.iter()
.cloned()
.map(|term| term.evaluate(&mut rng))
.collect();
let total = terms.iter().map(TermResult::subtotal).sum();
Some(Roll {
drex: self.drex.clone(),
terms,
total,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DieRollTerm {
DieRoll {
multiplier: i32,
sides: u32,
},
Modifier(i32),
}
impl DieRollTerm {
fn parse(drt: &str) -> Result<DieRollTerm, D20Error> {
let lower = drt.to_lowercase();
if lower.contains('d') {
let mut parts = lower.splitn(2, 'd');
let mult_str = parts.next().unwrap_or("");
let sides_str = parts.next().unwrap_or("");
let multiplier = match mult_str {
"" | "+" => 1,
"-" => -1,
other => other
.parse::<i64>()
.map_err(|_| D20Error::InvalidTerm(drt.to_string()))?,
};
let sides = sides_str
.parse::<u64>()
.map_err(|_| D20Error::InvalidTerm(drt.to_string()))?;
if multiplier.unsigned_abs() > MAX_DICE as u64 {
return Err(D20Error::DiceCountTooLarge {
count: multiplier.unsigned_abs(),
max: MAX_DICE,
});
}
if sides == 0 {
return Err(D20Error::ZeroSidedDie);
}
if sides > MAX_SIDES as u64 {
return Err(D20Error::SidesTooLarge {
sides,
max: MAX_SIDES,
});
}
Ok(DieRollTerm::DieRoll {
multiplier: multiplier as i32,
sides: sides as u32,
})
} else {
let modifier = drt
.parse::<i64>()
.map_err(|_| D20Error::InvalidTerm(drt.to_string()))?;
if modifier.unsigned_abs() > MAX_MODIFIER as u64 {
return Err(D20Error::ModifierTooLarge {
modifier,
max: MAX_MODIFIER,
});
}
Ok(DieRollTerm::Modifier(modifier as i32))
}
}
fn evaluate<R: Rng + ?Sized>(self, rng: &mut R) -> TermResult {
match self {
DieRollTerm::Modifier(n) => TermResult::Modifier(n),
DieRollTerm::DieRoll { multiplier, sides } => {
let rolls = (0..multiplier.unsigned_abs())
.map(|_| rng.random_range(1..=sides))
.collect();
TermResult::Dice {
multiplier,
sides,
rolls,
}
}
}
}
}
pub fn roll_dice(s: &str) -> Result<Roll, D20Error> {
roll_dice_with_rng(s, &mut rand::rng())
}
pub fn roll_dice_with_rng<R: Rng + ?Sized>(s: &str, rng: &mut R) -> Result<Roll, D20Error> {
let drex = s.trim().to_string();
let terms = parse_die_roll_terms(&drex)?;
if terms.is_empty() {
return Err(D20Error::EmptyExpression);
}
let terms: Vec<TermResult> = terms.into_iter().map(|t| t.evaluate(rng)).collect();
let total = terms.iter().map(TermResult::subtotal).sum();
Ok(Roll { drex, terms, total })
}
fn parse_die_roll_terms(drex: &str) -> Result<Vec<DieRollTerm>, D20Error> {
let mut terms = Vec::new();
let mut pos = 0;
while pos < drex.len() {
let rest = &drex[pos..];
let caps = TERM_RE
.captures(rest)
.ok_or_else(|| D20Error::InvalidTerm(rest.to_string()))?;
let sign = caps.name("sign").map_or("", |m| m.as_str());
if !terms.is_empty() && sign.is_empty() {
return Err(D20Error::MissingOperator(rest.to_string()));
}
let token = match caps.name("sides") {
Some(sides) => {
let count = caps.name("count").map_or("", |m| m.as_str());
format!("{sign}{count}d{}", sides.as_str())
}
None => format!("{sign}{}", caps.name("modval").unwrap().as_str()),
};
terms.push(DieRollTerm::parse(&token)?);
pos += caps.get(0).unwrap().end();
}
Ok(terms)
}
pub fn roll_range(min: i32, max: i32) -> Result<i32, D20Error> {
roll_range_with_rng(min, max, &mut rand::rng())
}
pub fn roll_range_with_rng<R: Rng + ?Sized>(
min: i32,
max: i32,
rng: &mut R,
) -> Result<i32, D20Error> {
if min > max {
Err(D20Error::InvalidRange { min, max })
} else {
Ok(rng.random_range(min..=max))
}
}
#[cfg(test)]
mod tests;