#![expect(clippy::min_ident_chars, reason = "Exception as dN is a tabletop convention")]
use std::sync::Mutex;
use crate::rng::{Random, UniformThreadRandom};
pub trait Dice {
fn roll(&self) -> u8;
fn roll_pool(&self, pool: usize) -> Vec<u8>;
fn sides(&self) -> u8;
}
pub type D10<R> = D<10, R>;
pub type D100<R> = D<100, R>;
pub type D12<R> = D<12, R>;
pub type D20<R> = D<20, R>;
pub type D4<R> = D<4, R>;
pub type D6<R> = D<6, R>;
pub type D8<R> = D<8, R>;
pub struct D<const SIDES: u8, R: Random<u8>> {
rng: Mutex<R>,
}
impl<const SIDES: u8, R: Random<u8>> D<SIDES, R> {
#[inline]
pub fn new(rng: R) -> Self {
Self { rng: Mutex::new(rng) }
}
}
impl<const SIDES: u8> Default for D<SIDES, UniformThreadRandom<u8>> {
#[inline]
fn default() -> Self {
Self {
#[expect(clippy::unwrap_used, reason = "Exception as this call failing would be a programming error")]
rng: Mutex::new(UniformThreadRandom::new(1, SIDES).unwrap()),
}
}
}
impl<const SIDES: u8, R: Random<u8>> Dice for D<SIDES, R> {
#[inline]
fn roll(&self) -> u8 {
*Option::unwrap(self.roll_pool(1).first())
}
#[inline]
fn roll_pool(&self, pool: usize) -> Vec<u8> {
#[expect(clippy::unwrap_used, reason = "Mutex being poisoned is a programming error")]
self.rng.lock().unwrap().take(pool)
}
#[inline]
fn sides(&self) -> u8 {
SIDES
}
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "Expect is allowed is tests")]
mod tests {
use rstest::rstest;
use super::*;
use crate::assert_approx;
const SAMPLES: u32 = 1_000_000;
const ERROR_TOLERANCE_PCT: f64 = 5.0 / 100.0;
type D42 = D<42, UniformThreadRandom<u8>>;
#[rstest]
#[case::d4(D4::default())]
#[case::d6(D6::default())]
#[case::d8(D8::default())]
#[case::d10(D10::default())]
#[case::d12(D12::default())]
#[case::d20(D20::default())]
#[case::d100(D100::default())]
#[case::d42(D42::default())]
fn should_distribute_values_evenly_when_sampling_single_values(#[case] d: impl Dice) {
let mut buckets = vec![0u32; d.sides().into()];
for _ in 0..SAMPLES {
buckets[(d.roll() - 1) as usize] += 1;
}
let approx_expect: f64 = SAMPLES.checked_div(d.sides().into()).expect("should not overflow").into();
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation, reason = "Exception as this is a test")]
let error_tolerance = (approx_expect * ERROR_TOLERANCE_PCT) as u64;
assert_eq!(buckets.iter().sum::<u32>(), SAMPLES, "should have recorded {SAMPLES} samples");
#[expect(clippy::cast_possible_truncation, reason = "Exception as this is a test")]
for bucket in buckets {
assert_approx!(approx_expect as i64, i64::from(bucket), error_tolerance);
}
}
#[rstest]
#[case::d4(D4::default())]
#[case::d6(D6::default())]
#[case::d8(D8::default())]
#[case::d10(D10::default())]
#[case::d12(D12::default())]
#[case::d20(D20::default())]
#[case::d100(D100::default())]
#[case::d42(D42::default())]
fn should_distribute_values_evenly_when_sampling_many_values(#[case] d: impl Dice) {
let mut buckets = vec![0u32; d.sides() as usize];
for n in d.roll_pool(SAMPLES as usize) {
buckets[(n - 1) as usize] += 1;
}
let approx_expect = f64::from(SAMPLES) / f64::from(d.sides());
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation, reason = "Exception as this is a test")]
let error_tolerance = (approx_expect * ERROR_TOLERANCE_PCT) as u64;
assert_eq!(buckets.iter().sum::<u32>(), SAMPLES, "should have recorded {SAMPLES} samples");
#[expect(clippy::cast_possible_truncation, reason = "Exception as this is a test")]
for bucket in buckets {
assert_approx!(approx_expect as i64, i64::from(bucket), error_tolerance);
}
}
}