use anyhow::{Context, anyhow};
use rand::RngExt;
use std::cmp::Ordering;
use std::fmt::{self, Display, Formatter};
pub type Roll = i64;
pub type Sides = u8;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Die {
D4,
D6,
D8,
D10,
D12,
D20,
D100,
}
impl Die {
pub fn sides(&self) -> Sides {
match self {
Die::D4 => 4,
Die::D6 => 6,
Die::D8 => 8,
Die::D10 => 10,
Die::D12 => 12,
Die::D20 => 20,
Die::D100 => 100,
}
}
pub fn roll(&self) -> Roll {
let mut rng = rand::rng();
rng.random_range(1..=self.sides()) as Roll
}
}
impl Display for Die {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let string = match self {
Die::D4 => "d4",
Die::D6 => "d6",
Die::D8 => "d8",
Die::D10 => "d10",
Die::D12 => "d12",
Die::D20 => "d20",
Die::D100 => "d100",
};
write!(f, "{string}")
}
}
impl TryFrom<String> for Die {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for Die {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_ref() {
"d4" => Ok(Self::D4),
"d6" => Ok(Self::D6),
"d8" => Ok(Self::D8),
"d10" => Ok(Self::D10),
"d12" => Ok(Self::D12),
"d20" => Ok(Self::D20),
"d100" => Ok(Self::D100),
_ => Err(anyhow!("Invalid die description: {value}")),
}
}
}
impl TryFrom<u8> for Die {
type Error = anyhow::Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
Self::try_from(&value)
}
}
impl TryFrom<&u8> for Die {
type Error = anyhow::Error;
fn try_from(value: &u8) -> Result<Self, Self::Error> {
match value {
4 => Ok(Die::D4),
6 => Ok(Die::D6),
8 => Ok(Die::D8),
10 => Ok(Die::D10),
12 => Ok(Die::D12),
20 => Ok(Die::D20),
100 => Ok(Die::D100),
_ => Err(anyhow!("Cannot create dice with {value} sides")),
}
}
}
impl PartialOrd for Die {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Die {
fn cmp(&self, other: &Self) -> Ordering {
self.sides().cmp(&other.sides())
}
}
#[derive(Clone, Copy, Debug, Eq)]
pub enum Rollable {
Dice(Die, u64),
Fixed(Roll),
}
impl Rollable {
pub fn roll(&self) -> Roll {
match self {
Rollable::Dice(d, n) => (0..*n).map(|_| d.roll()).sum(),
Rollable::Fixed(n) => *n,
}
}
}
impl Display for Rollable {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Rollable::Dice(d, n) => write!(f, "{n}{d}"),
Rollable::Fixed(d) => write!(f, "{d}"),
}
}
}
impl TryFrom<String> for Rollable {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for Rollable {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse().map(Rollable::Fixed).or_else(|_| {
let low_value = value.to_lowercase();
let mut values = low_value.splitn(2, 'd');
let n = values
.next()
.ok_or(anyhow!("Not enough values in {value}"))?;
let d = values
.next()
.ok_or(anyhow!("Not enough values in {value}"))?;
let n = if n.is_empty() {
1
} else {
n.parse::<u64>()
.with_context(|| format!("Could not parse {n} as u64"))?
};
let d = Die::try_from(format!("d{d}"))?;
Ok(Rollable::Dice(d, n))
})
}
}
impl PartialEq for Rollable {
fn eq(&self, other: &Self) -> bool {
match self {
Rollable::Dice(ld, _) => match other {
Rollable::Dice(rd, _) => ld == rd,
Rollable::Fixed(_) => false,
},
Rollable::Fixed(ln) => match other {
Rollable::Dice(_, _) => false,
Rollable::Fixed(rn) => *ln == *rn,
},
}
}
}
impl PartialOrd for Rollable {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Rollable {
fn cmp(&self, other: &Self) -> Ordering {
match self {
Rollable::Dice(ld, _) => match other {
Rollable::Dice(rd, _) => ld.cmp(rd),
Rollable::Fixed(_) => Ordering::Greater,
},
Rollable::Fixed(ln) => match other {
Rollable::Dice(_, _) => Ordering::Less,
Rollable::Fixed(rn) => ln.cmp(rn),
},
}
}
}
macro_rules! impl_From {
( $( $int_type:ident ),*) => {
$(
impl From<$int_type> for Rollable {
fn from(value: $int_type) -> Self {
Rollable::Fixed(value as Roll)
}
}
)*
};
}
impl_From!(i8, u8, i16, u16, i32, u32, i64, u64);
#[cfg(test)]
mod tests {
macro_rules! for_all_dice {
($macro:ident) => {
$macro!(d4, d6, d8, d10, d12, d20, d100);
};
}
mod die {
use super::super::{Die, Roll, Sides};
use paste::paste;
macro_rules! gen_create_from_string {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<create_ $die _from_string>]() {
let s = String::from(stringify!($die));
let die = Die::try_from(s).expect(
concat!("could not create ", stringify!($die))
);
assert!(matches!(die, Die::[<$die:upper>]));
}
)*
}
};
}
macro_rules! gen_turn_string {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<turn_string_into_$die>]() {
let die = Die::try_from(stringify!($die)).expect(
concat!("could not create ", stringify!($die))
);
assert!(matches!(die, Die::[<$die:upper>]));
}
)*
}
};
}
macro_rules! gen_create_from_str {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<create_ $die _from_str>]() {
let s = stringify!($die);
let die = s.try_into().expect(
concat!("could not create ", stringify!(d4), " from string")
);
assert!(matches!(die, Die::[<$die:upper>]));
}
)*
}
};
}
macro_rules! gen_display {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<display_$die>]() {
let die = Die::[<$die:upper>];
let s = format!("{die}");
assert_eq!(s, stringify!($die));
}
)*
}
};
}
macro_rules! gen_turn_str {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<turn_str_into_$die>] () {
let die = stringify!($die).try_into().expect(
concat!("could not create ", stringify!($die), " from string")
);
assert!(matches!(die, Die::[<$die:upper>]));
}
)*
}
};
}
macro_rules! gen_return_number_of_sides {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<a_ $die _returns_the_number_of_sides>]() {
let n: Sides = stringify!($die)
.replace("d", "")
.parse()
.expect(
concat!("could not parse ", stringify!($die), " as an int")
);
let die = Die::try_from(stringify!($die)).expect(
concat!("could not create die from ", stringify!(d4))
);
assert_eq!(die.sides(), n);
}
)*
}
};
}
macro_rules! gen_roll {
( $( $die:ident ),* ) => {
paste! {
$(
#[test]
fn [<a_ $die _can_be_rolled>]() {
let n: Roll = stringify!($die)
.replace("d", "")
.parse()
.expect(
concat!("could not parse ", stringify!($die), " as an int")
);
let die = Die::try_from(stringify!($die)).expect(
concat!("could not create die from ", stringify!(d4))
);
let result = die.roll();
assert!(result >= 1);
assert!(result <= n);
}
)*
}
};
}
for_all_dice!(gen_create_from_string);
for_all_dice!(gen_turn_string);
for_all_dice!(gen_create_from_str);
for_all_dice!(gen_turn_str);
for_all_dice!(gen_display);
for_all_dice!(gen_return_number_of_sides);
for_all_dice!(gen_roll);
#[test]
fn sort_die() {
let mut unsorted = vec![
Die::D20,
Die::D4,
Die::D100,
Die::D12,
Die::D10,
Die::D8,
Die::D6,
];
let sorted = vec![
Die::D4,
Die::D6,
Die::D8,
Die::D10,
Die::D12,
Die::D20,
Die::D100,
];
unsorted.sort();
assert_eq!(unsorted, sorted);
}
}
mod rollable {
use super::super::{Roll, Rollable};
use anyhow::Result;
use paste::paste;
use proptest::prelude::*;
proptest! {
#[test]
fn create_dice_from_str(die in "[2-9][0-9]{0,9}[dD](4|6|8|10|12|20|100)") {
let roll = Rollable::try_from(die.as_str()).expect(&format!("could not create rollable from {die}"));
assert!(matches!(roll, Rollable::Dice(_, _)));
}
#[test]
fn turn_str_into_dice(die in "[2-9][0-9]{0,9}[dD](4|6|8|10|12|20|100)") {
let roll = die.as_str().try_into().expect(&format!("could not create rollable from {die}"));
assert!(matches!(roll, Rollable::Dice(_, _)));
}
#[test]
fn fail_if_str_has_multiple_ds(die in "[1-9][0-9]{0,9}[dD](4|6|8|10|12|20|100)[dD](4|6|8|10|12|20|100)") {
let roll: Result<Rollable> = die.as_str().try_into();
assert!(roll.is_err());
}
}
proptest! {
#[test]
fn create_dice_from_string(die in "[2-9][0-9]{0,9}[dD](4|6|8|10|12|20|100)") {
let roll = Rollable::try_from(die).expect("could not create rollable");
assert!(matches!(roll, Rollable::Dice(_, _)));
}
#[test]
fn create_string_into_dice(die in "[2-9][0-9]{0,9}[dD](4|6|8|10|12|20|100)") {
let roll = die.try_into().expect("could not create rollable");
assert!(matches!(roll, Rollable::Dice(_, _)));
}
#[test]
fn fail_if_string_has_multiple_ds(die in "[1-9][0-9]{0,9}[dD](4|6|8|10|12|20|100)[dD](4|6|8|10|12|20|100)") {
let roll: Result<Rollable> = die.try_into();
assert!(roll.is_err());
}
}
proptest! {
#[test]
fn create_positive_fixed_from_str(s in "[0-9]{1,9}") {
let roll = Rollable::try_from(s.as_str()).expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
#[test]
fn turn_str_into_positive_fixed(s in "[0-9]{1,9}") {
let roll = s.as_str().try_into().expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
}
proptest! {
#[test]
fn create_positive_fixed_from_string(s in "[0-9]{1,9}") {
let roll = Rollable::try_from(s).expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
#[test]
fn turn_string_into_positive_fixed(s in "[0-9]{1,9}") {
let roll = s.try_into().expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
}
proptest! {
#[test]
fn create_negative_fixed_from_str(s in "-[0-9]{1,9}") {
let roll = Rollable::try_from(s.as_str()).expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
#[test]
fn turn_str_into_negative_fixed(s in "-[0-9]{1,9}") {
let roll = s.as_str().try_into().expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
}
proptest! {
#[test]
fn create_negative_fixed_from_string(s in "-[0-9]{1,9}") {
let roll = Rollable::try_from(s).expect("could not create rollable");
assert!(matches!(roll, Rollable::Fixed(_)));
}
#[test]
fn turn_string_into_negative_fixed(s in "-[0-9]{1,9}") {
let roll = s.try_into().expect("could not create rollable from {s}");
assert!(matches!(roll, Rollable::Fixed(_)));
}
}
proptest! {
#[test]
fn return_error_for_invalid_die_str(s in "[dD][^12468][0-9]+") {
let roll = Rollable::try_from(s.as_str());
assert!(roll.is_err());
}
#[test]
fn return_error_for_invalid_die_str_into(s in "[dD][^12468][0-9]+") {
let roll: Result<Rollable> = s.as_str().try_into();
assert!(roll.is_err());
}
}
proptest! {
#[test]
fn return_error_for_invalid_die_string(s in "[dD][^12468][0-9]+") {
let roll = Rollable::try_from(s);
assert!(roll.is_err());
}
#[test]
fn return_error_for_invalid_die_string_into(s in "d[^12468][0-9]+") {
let roll: Result<Rollable> = s.try_into();
assert!(roll.is_err());
}
}
proptest! {
#[test]
fn return_error_for_invalid_fixed_str(s in "[^0-9-+dD][0-9]+") {
let roll = Rollable::try_from(s.as_str());
assert!(roll.is_err());
}
#[test]
fn return_error_for_invalid_fixed_str_into(s in "[^0-9-+dD][0-9]+") {
let roll: Result<Rollable> = s.as_str().try_into();
assert!(roll.is_err());
}
}
proptest! {
#[test]
fn return_error_for_invalid_fixed_string(s in "[^0-9-+dD][0-9]+") {
let roll = Rollable::try_from(s);
assert!(roll.is_err());
}
#[test]
fn return_error_for_invalid_fixed_string_into(s in "[^0-9-+dD][0-9]+") {
let roll: Result<Rollable> = s.try_into();
assert!(roll.is_err());
}
}
macro_rules! gen_create_fixed_int {
( $( $int_type:ident ),*) => {
paste! {
$(
proptest! {
#[test]
fn [<create_fixed_from_ $int_type>](n: $int_type) {
let roll = Rollable::from(n);
assert!(matches!(roll, Rollable::Fixed(_)));
}
#[test]
fn [<turn_ $int_type _into_rollable>](n: $int_type) {
let roll = n.into();
assert!(matches!(roll, Rollable::Fixed(_)));
}
}
)*
}
};
}
gen_create_fixed_int!(i8, u8, i16, u16, i32, u32, i64, u64);
macro_rules! gen_roll_for_die {
( $( $die:ident ),*) => {
paste! {
$(
#[test]
fn [<can_roll_for_$die>]() {
let n: Roll = stringify!($die)
.replace("d", "")
.parse().expect(concat!(
"could not parse ",
stringify!($die),
" as an int"
));
let r = Rollable::try_from(stringify!($die)).expect(
concat!("cannot create Rollable for ", stringify!($die))
);
let result = r.roll();
assert!(result >= 1);
assert!(result <= n);
}
)*
}
};
}
macro_rules! gen_roll_for_dice {
( $( $die:ident ),*) => {
paste! {
$(
proptest! {
#[test]
fn [<can_roll_for_multiple_$die>](num in 1u64..1000u64) {
let sides: u64 = stringify!($die)
.replace("d", "")
.parse().expect(concat!(
"could not parse ",
stringify!($die),
" as an int"
));
let s = format!("{num}{}", stringify!($die));
let r = Rollable::try_from(s).expect(
concat!("cannot create Rollable for ", stringify!($die))
);
let result = r.roll();
assert!(result >= num as i64);
assert!(result <= (sides * num) as i64);
}
}
)*
}
};
}
macro_rules! gen_roll_for_fixed_int {
( $( $int_type:ident ),*) => {
paste! {
$(
proptest! {
#[test]
fn [<can_roll_for_ $int_type>](n: $int_type) {
let n_min = $int_type::MIN;
let n_max = $int_type::MAX;
let r = Rollable::from(n);
let result = r.roll() as $int_type;
assert!(
result >= n_min,
"{n} >= {n_min} ({})", stringify!($int_type),
);
assert!(
result <= n_max,
"{n} <= {n_max} ({})", stringify!($int_type),
);
}
}
)*
}
};
}
for_all_dice!(gen_roll_for_die);
for_all_dice!(gen_roll_for_dice);
gen_roll_for_fixed_int!(i8, u8, i16, u16, i32, u32, i64, u64);
mod comparison {
use super::super::super::{Die, Rollable};
macro_rules! assert_gt {
($lhs:expr, $rhs:expr) => {
assert!($lhs > $rhs, "expected {} to be greater than {}", $lhs, $rhs)
};
}
#[test]
fn dice_equality() {
assert_eq!(Rollable::Dice(Die::D10, 2), Rollable::Dice(Die::D10, 2));
}
#[test]
fn dice_equality_irrespective_of_quantity() {
assert_eq!(Rollable::Dice(Die::D10, 2), Rollable::Dice(Die::D10, 3));
}
#[test]
fn dice_inequality() {
assert_ne!(Rollable::Dice(Die::D10, 2), Rollable::Dice(Die::D8, 2));
}
#[test]
fn fixed_equality() {
assert_eq!(Rollable::Fixed(4), Rollable::Fixed(4));
}
#[test]
fn fixed_inequality() {
assert_ne!(Rollable::Fixed(4), Rollable::Fixed(-4));
}
#[test]
fn dice_with_more_sides_is_greater() {
assert_gt!(Rollable::Dice(Die::D10, 2), Rollable::Dice(Die::D8, 3));
}
#[test]
fn fixed_with_larger_value_is_greater() {
assert_gt!(Rollable::Fixed(10), Rollable::Fixed(1));
}
#[test]
fn dice_is_greater_than_fixed() {
assert_gt!(Rollable::Dice(Die::D10, 2), Rollable::Fixed(100));
}
#[test]
fn sort_rollables() {
let mut unsorted = [
Rollable::Dice(Die::D6, 1),
Rollable::Dice(Die::D10, 2),
Rollable::Fixed(2),
Rollable::Dice(Die::D8, 3),
Rollable::Dice(Die::D4, 1),
];
let sorted = [
Rollable::Dice(Die::D10, 2),
Rollable::Dice(Die::D8, 3),
Rollable::Dice(Die::D6, 1),
Rollable::Dice(Die::D4, 1),
Rollable::Fixed(2),
];
unsorted.sort();
unsorted.reverse();
assert_eq!(unsorted, sorted);
}
}
}
}