use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::parse::{self, DiceTerm, Goal, Roll, Stake, TermMod};
use crate::physics::{self, Physics};
use glam::{Quat, Vec3};
use rapier3d::prelude::RigidBodyHandle;
const GRAVITY: f32 = 60.0; const MAX_AIRBORNE: f32 = 8.0; const MAX_EXPLOSIONS: usize = 40;
const SHAKE_POWER_RATE: f32 = 3.9; pub(crate) const CUP_SWAY_RATE: f32 = 7.0;
const MAX_HISTORY: usize = 200; const STAT_SAMPLES: usize = 20_000; const MAX_SOUNDS: usize = 64; const KNOCK_BUDGET: usize = 8; const CRIT_PARTICLES: usize = 16; const RELEASE_ECHO_SECS: f32 = 1.6;
pub struct Die {
pub sides: u32,
pub final_value: u32,
pub shown: u32,
pub pos: Vec3,
pub rot: Quat,
pub settled: bool,
pub color_idx: usize,
pub term_idx: usize,
pub mult: i32,
pub kept: bool,
explode: Option<parse::Compare>,
exploded: bool,
age: f32, body: RigidBodyHandle,
}
pub fn check(total: i32, stake: Stake) -> (bool, i32) {
let raw = match stake.goal {
Goal::Over => total as i64 - stake.target as i64,
Goal::Under => stake.target as i64 - total as i64,
};
let margin = raw.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
(margin >= 0, margin)
}
pub fn verdict_text(success: bool, margin: i32) -> String {
match (success, margin) {
(true, 0) => "SUCCESS — exactly".to_string(),
(true, m) => format!("SUCCESS by {m}"),
(false, m) => format!("FAIL by {}", -m),
}
}
pub fn crit_face(sides: u32, value: u32) -> bool {
sides >= 2 && value == sides
}
pub fn fumble_face(sides: u32, value: u32) -> bool {
sides >= 2 && value == 1
}
fn tumbling_face(rot: Quat, sides: u32) -> u32 {
if sides <= 1 {
return 1;
}
let bits = rot.x.to_bits() ^ rot.y.to_bits().rotate_left(11) ^ rot.z.to_bits().rotate_left(22);
1 + (bits % sides)
}
fn mesh_points(sides: u32) -> Vec<Vec3> {
crate::render3d::dice::mesh_for(sides)
.vertices
.iter()
.map(|v| v.position)
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SoundEvent {
Impact {
sides: u32,
speed: f32,
},
Knock {
sides: u32,
speed: f32,
},
Settle {
sides: u32,
},
Rattle {
power: f32,
},
Throw {
power: f32,
},
Crit,
Fumble,
Success,
Failure,
}
pub struct Particle {
pub x: f32,
pub y: f32,
vx: f32,
vy: f32,
age: f32,
life: f32,
pub glyph: char,
pub bright: bool,
}
impl Particle {
pub fn fade(&self) -> f32 {
(self.age / self.life).clamp(0.0, 1.0)
}
}
struct Shake {
t: f32,
expr: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Throw {
pub power: f32,
pub age: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
None,
Help,
History,
Stats,
}
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub expr: String,
pub values: Vec<u32>,
pub total: i32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RollMode {
Shake,
Roll,
Insta,
}
impl RollMode {
pub fn next(self) -> RollMode {
match self {
RollMode::Shake => RollMode::Roll,
RollMode::Roll => RollMode::Insta,
RollMode::Insta => RollMode::Shake,
}
}
pub fn label(self) -> &'static str {
match self {
RollMode::Shake => "shake",
RollMode::Roll => "roll",
RollMode::Insta => "insta",
}
}
}
pub struct App {
pub input: String,
pub cursor: usize,
pub pane_scroll: u16,
pub dice: Vec<Die>,
pub modifier: i32,
pub stake: Option<Stake>,
pub error: Option<String>,
pub history: Vec<HistoryEntry>,
pub pane: Pane,
pub mode: RollMode,
recorded: bool,
pub arena_w: f32,
pub arena_h: f32,
pub spawned: bool,
shake: Option<Shake>,
pub last_throw: Option<Throw>,
pub particles: Vec<Particle>,
pub sounds: Vec<SoundEvent>,
pub muted: bool,
stats_cache: Option<(String, usize, Stats)>,
explosions: Vec<usize>,
rng: StdRng,
physics: Physics,
phys_accum: f32,
clock: f32,
flash: f32,
impact_energy: f32,
focus: f32,
}
impl App {
pub fn new(initial: String) -> Self {
Self::with_rng(initial, StdRng::from_entropy())
}
pub fn with_seed(initial: String, seed: u64) -> Self {
Self::with_rng(initial, StdRng::seed_from_u64(seed))
}
fn with_rng(initial: String, rng: StdRng) -> Self {
let mut app = App {
input: String::new(),
cursor: 0,
pane_scroll: 0,
dice: Vec::new(),
modifier: 0,
stake: None,
error: None,
history: Vec::new(),
pane: Pane::None,
mode: RollMode::Shake,
recorded: false,
arena_w: 0.0,
arena_h: 0.0,
spawned: false,
shake: None,
last_throw: None,
particles: Vec::new(),
sounds: Vec::new(),
muted: false,
stats_cache: None,
explosions: Vec::new(),
rng,
physics: Physics::new(),
phys_accum: 0.0,
clock: 0.0,
flash: 0.0,
impact_energy: 0.0,
focus: 0.0,
};
if !initial.trim().is_empty() {
app.input = initial.trim().to_string();
app.cursor = app.input.len();
app.roll();
}
app
}
pub fn cursor_byte(&self) -> usize {
let mut c = self.cursor.min(self.input.len());
while !self.input.is_char_boundary(c) {
c -= 1;
}
c
}
pub fn input_insert(&mut self, c: char) {
let at = self.cursor_byte();
self.input.insert(at, c);
self.cursor = at + c.len_utf8();
}
pub fn input_backspace(&mut self) {
if self.cursor_byte() == 0 {
return;
}
self.cursor_left();
self.input_delete();
}
pub fn input_delete(&mut self) {
let at = self.cursor_byte();
if at < self.input.len() {
self.input.remove(at);
}
self.cursor = at;
}
pub fn cursor_left(&mut self) {
let at = self.cursor_byte();
self.cursor = self.input[..at]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
pub fn cursor_right(&mut self) {
let at = self.cursor_byte();
self.cursor = self.input[at..]
.chars()
.next()
.map(|c| at + c.len_utf8())
.unwrap_or(at);
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
self.cursor = self.input.len();
}
pub fn set_pane(&mut self, pane: Pane) {
self.pane = pane;
self.pane_scroll = 0;
}
pub fn roll(&mut self) {
if self.build_pool() {
self.launch_pool(None); }
}
fn build_pool(&mut self) -> bool {
match parse::parse(&self.input) {
Ok(Roll {
terms,
modifier,
stake,
}) => {
self.error = None;
self.modifier = modifier;
self.stake = stake;
self.explosions = vec![0; terms.len()];
let mut dice: Vec<Die> = Vec::new();
for (ti, term) in terms.iter().enumerate() {
self.roll_term(ti, term, &mut dice);
}
self.dice = dice;
self.spawned = false;
self.recorded = false;
self.particles.clear();
self.last_throw = None;
true
}
Err(e) => {
self.error = Some(e);
false
}
}
}
pub fn insta_roll(&mut self) {
self.roll();
if self.error.is_some() {
return;
}
for _ in 0..40_000 {
self.update(1.0 / 60.0);
if self.all_settled() {
break;
}
}
self.particles.clear();
self.sounds.retain(|s| {
matches!(
s,
SoundEvent::Crit | SoundEvent::Fumble | SoundEvent::Success | SoundEvent::Failure
)
});
if let Some(d) = self.dice.first() {
self.sounds.insert(0, SoundEvent::Settle { sides: d.sides });
}
}
fn roll_term(&mut self, term_idx: usize, term: &DiceTerm, out: &mut Vec<Die>) {
let start = out.len();
let base_color = start;
let explode = explode_condition(term);
for _ in 0..term.count {
let color = base_color + (out.len() - start);
out.push(self.new_die(term.sides, term_idx, color, explode));
}
apply_keep_drop(&mut out[start..], term);
let mult = term_multiplier(term);
for die in &mut out[start..] {
die.mult = mult;
}
}
fn new_die(
&mut self,
sides: u32,
term_idx: usize,
color_idx: usize,
explode: Option<parse::Compare>,
) -> Die {
let final_value = self.rng.gen_range(1..=sides);
Die {
sides,
final_value,
shown: 1 + (color_idx as u32 * 7 + 2) % sides,
pos: Vec3::ZERO,
rot: Quat::IDENTITY,
settled: false,
color_idx,
term_idx,
mult: 1,
kept: true,
explode,
exploded: false,
age: 0.0,
body: RigidBodyHandle::invalid(),
}
}
pub fn start_shake(&mut self) {
if self.shake.is_some() {
return;
}
match parse::parse(&self.input) {
Ok(_) => {
self.error = None;
self.shake = Some(Shake {
t: 0.0,
expr: self.input.clone(),
});
}
Err(e) => self.error = Some(e),
}
}
pub fn shaking(&self) -> bool {
self.shake.is_some()
}
pub fn shake_t(&self) -> f32 {
self.shake.as_ref().map(|s| s.t).unwrap_or(0.0)
}
pub fn cancel_shake(&mut self) {
self.shake = None;
}
pub fn power(&self) -> f32 {
if self.shake.is_some() {
0.5 - 0.5 * (self.shake_t() * SHAKE_POWER_RATE).cos()
} else {
0.0
}
}
pub fn cup_x(&self) -> f32 {
let centre = self.arena_w / 2.0;
let amp = (self.arena_w * 0.18) * (0.35 + 0.65 * self.power());
centre + (self.shake_t() * CUP_SWAY_RATE).sin() * amp
}
pub fn cup_offset(&self) -> f32 {
(self.cup_x() / self.arena_w.max(1.0)) * 2.0 - 1.0
}
pub fn throw(&mut self) {
let power = self.power();
let cup = self.cup_offset();
let Some(shake) = self.shake.take() else {
return;
};
self.input = shake.expr;
if self.build_pool() {
self.launch_pool(Some((power, cup)));
self.last_throw = Some(Throw { power, age: 0.0 });
self.emit(SoundEvent::Throw { power });
}
}
fn emit(&mut self, ev: SoundEvent) {
if self.sounds.len() < MAX_SOUNDS {
self.sounds.push(ev);
}
}
fn emit_priority(&mut self, ev: SoundEvent) {
if self.sounds.len() >= MAX_SOUNDS {
self.sounds.remove(0);
}
self.sounds.push(ev);
}
pub fn take_sounds(&mut self) -> Vec<SoundEvent> {
let events = std::mem::take(&mut self.sounds);
if self.muted {
Vec::new()
} else {
events
}
}
pub fn release_echo(&self) -> Option<Throw> {
self.last_throw.filter(|t| t.age < RELEASE_ECHO_SECS)
}
pub fn tremor(&self) -> f32 {
match self.last_throw {
Some(t) if t.power > 0.6 && t.age < 0.35 => (t.power - 0.6) / 0.4,
_ => 0.0,
}
}
pub fn verdict(&self) -> Option<(bool, i32)> {
let stake = self.stake?;
if !self.all_settled() {
return None;
}
Some(check(self.total(), stake))
}
pub fn crit_dice(&self) -> impl Iterator<Item = &Die> {
self.dice
.iter()
.filter(|d| d.kept && d.settled && crit_face(d.sides, d.final_value))
}
pub fn fumble_dice(&self) -> impl Iterator<Item = &Die> {
self.dice
.iter()
.filter(|d| d.kept && d.settled && fumble_face(d.sides, d.final_value))
}
fn launch_pool(&mut self, power: Option<(f32, f32)>) {
self.physics.clear();
self.phys_accum = 0.0;
let count = self.dice.len();
let cols = count.clamp(1, 6);
for i in 0..count {
let sides = self.dice[i].sides;
let pts = mesh_points(sides);
let col = i % cols;
let zi = (i / cols) % 3;
let layer = i / (cols * 3);
let fx = if cols > 1 {
col as f32 / (cols - 1) as f32
} else {
0.5
};
let x = (fx * 2.0 - 1.0) * physics::HX * 0.7 + self.rng.gen_range(-0.05..=0.05);
let zs = physics::HZ - physics::DIE_R - 0.02;
let z = [0.0, -zs, zs][zi] + self.rng.gen_range(-0.02..=0.02);
let y =
(physics::HY * 0.6 - layer as f32 * 0.8).max(-physics::HY + physics::DIE_R + 0.25); let pos = Vec3::new(x, y, z);
let body = self.physics.spawn(&pts, pos);
self.dice[i].body = body;
self.dice[i].pos = pos;
let (linvel, angvel) = match power {
Some((p, cx)) => {
let speed = 4.0 + 9.0 * p;
let aim = (-cx + self.rng.gen_range(-0.6..=0.6)).clamp(-1.0, 1.0);
let lin = Vec3::new(
aim * speed * self.rng.gen_range(0.45..=0.65),
speed * self.rng.gen_range(0.4..=0.7),
self.rng.gen_range(-1.0..=1.0),
);
let ang = Vec3::new(
self.rng.gen_range(-22.0..=22.0),
self.rng.gen_range(-22.0..=22.0),
self.rng.gen_range(-22.0..=22.0),
);
(lin, ang)
}
None => {
let lin = Vec3::new(
self.rng.gen_range(-2.0..=2.0),
self.rng.gen_range(-7.0..=-3.0),
self.rng.gen_range(-1.5..=1.5),
);
let ang = Vec3::new(
self.rng.gen_range(-18.0..=18.0),
self.rng.gen_range(-18.0..=18.0),
self.rng.gen_range(-18.0..=18.0),
);
(lin, ang)
}
};
self.physics.launch(body, linvel, angvel);
}
self.spawned = true;
}
pub fn clock(&self) -> f32 {
self.clock
}
pub fn flash(&self) -> f32 {
self.flash
}
pub fn impact_energy(&self) -> f32 {
self.impact_energy
}
pub fn focus(&self) -> f32 {
self.focus
}
pub fn camera_shake(&self) -> Vec3 {
let tr = self.tremor();
match self.last_throw {
Some(t) if tr > 0.0 => {
let (amp, phase) = (tr * 0.055, t.age * 38.0);
Vec3::new(phase.sin() * amp, phase.cos() * amp, 0.0)
}
_ => Vec3::ZERO,
}
}
pub fn update(&mut self, dt: f32) {
let dt = dt.min(0.05); self.clock += dt; self.flash = (self.flash - dt * 2.2).max(0.0); self.impact_energy = (self.impact_energy - dt * 4.0).max(0.0);
let want_focus = if self.shaking() || self.dice.is_empty() {
0.0
} else {
1.0
};
self.focus += (want_focus - self.focus) * (dt * 2.2).min(1.0);
if let Some(shake) = &mut self.shake {
let before = (shake.t * CUP_SWAY_RATE / std::f32::consts::PI) as i32;
shake.t += dt;
let after = (shake.t * CUP_SWAY_RATE / std::f32::consts::PI) as i32;
if after != before {
let power = self.power();
self.emit(SoundEvent::Rattle { power });
}
}
if let Some(throw) = &mut self.last_throw {
throw.age += dt;
}
self.update_particles(dt);
if !self.spawned {
return;
}
if self.all_settled() {
self.phys_accum = 0.0;
} else {
self.phys_accum += dt;
let mut budget = 8; while self.phys_accum >= physics::STEP && budget > 0 {
self.phys_accum -= physics::STEP;
budget -= 1;
self.physics_step();
}
}
if !self.recorded && self.all_settled() {
self.record_roll();
self.recorded = true;
}
}
fn physics_step(&mut self) {
let pre: Vec<(RigidBodyHandle, u32, f32)> = self
.dice
.iter()
.map(|d| (d.body, d.sides, self.physics.speed(d.body)))
.collect();
let mut voiced = 0;
for imp in self.physics.step(&pre) {
if imp.speed > 1.5 {
self.impact_energy = (self.impact_energy + imp.speed * 0.12).min(1.5);
if voiced >= KNOCK_BUDGET {
continue;
}
voiced += 1;
let (sides, speed) = (imp.sides, imp.speed * 12.0);
if imp.die_die {
self.emit(SoundEvent::Knock { sides, speed });
} else {
self.emit(SoundEvent::Impact { sides, speed });
}
}
}
self.sync_and_settle(physics::STEP);
}
fn sync_and_settle(&mut self, dt: f32) {
let mut to_explode: Vec<(u32, usize, i32, parse::Compare)> = Vec::new();
for i in 0..self.dice.len() {
if self.dice[i].settled {
continue;
}
let (pos, rot) = self.physics.pose(self.dice[i].body);
self.dice[i].pos = pos;
self.dice[i].rot = rot;
self.dice[i].age += dt;
self.dice[i].shown = tumbling_face(rot, self.dice[i].sides);
let overdue = self.dice[i].age >= MAX_AIRBORNE;
if overdue {
self.physics.freeze(self.dice[i].body);
}
if overdue || self.physics.sleeping(self.dice[i].body) {
self.dice[i].settled = true;
self.dice[i].shown = self.dice[i].final_value;
let sides = self.dice[i].sides;
self.emit(SoundEvent::Settle { sides });
let (exploded, explode, final_value, term, mult) = {
let d = &self.dice[i];
(d.exploded, d.explode, d.final_value, d.term_idx, d.mult)
};
if !exploded {
self.dice[i].exploded = true;
if let Some(cmp) = explode {
if cmp.matches(final_value) && self.explosions[term] < MAX_EXPLOSIONS {
self.explosions[term] += 1;
to_explode.push((sides, term, mult, cmp));
}
}
}
}
}
for (sides, term_idx, mult, cmp) in to_explode {
let color = self.dice.len();
let mut die = self.new_die(sides, term_idx, color, Some(cmp));
die.mult = mult;
let x = self.rng.gen_range(-physics::HX * 0.6..=physics::HX * 0.6);
let z = self.rng.gen_range(-physics::HZ * 0.5..=physics::HZ * 0.5);
let pos = Vec3::new(x, physics::HY * 0.6, z);
let pts = mesh_points(sides);
die.body = self.physics.spawn(&pts, pos);
die.pos = pos;
self.physics.launch(
die.body,
Vec3::new(
self.rng.gen_range(-2.0..=2.0),
self.rng.gen_range(-6.0..=-3.0),
self.rng.gen_range(-1.0..=1.0),
),
Vec3::new(
self.rng.gen_range(-18.0..=18.0),
self.rng.gen_range(-18.0..=18.0),
self.rng.gen_range(-18.0..=18.0),
),
);
self.dice.push(die);
}
}
fn record_roll(&mut self) {
let values: Vec<u32> = self
.dice
.iter()
.filter(|d| d.kept)
.map(|d| d.final_value)
.collect();
self.history.push(HistoryEntry {
expr: self.input.trim().to_string(),
values,
total: self.total(),
});
if self.history.len() > MAX_HISTORY {
let overflow = self.history.len() - MAX_HISTORY;
self.history.drain(0..overflow);
}
let crit_pos: Vec<Vec3> = self.crit_dice().map(|d| d.pos).collect();
let fumble_pos: Vec<Vec3> = self.fumble_dice().map(|d| d.pos).collect();
if !crit_pos.is_empty() {
self.flash = 1.0; }
let crits: Vec<(f32, f32)> = crit_pos.iter().map(|&p| self.world_to_cell(p)).collect();
let fumbles: Vec<(f32, f32)> = fumble_pos.iter().map(|&p| self.world_to_cell(p)).collect();
for &(x, y) in &crits {
self.burst(x, y, true);
}
for &(x, y) in &fumbles {
self.burst(x, y, false);
}
if !crits.is_empty() {
self.emit_priority(SoundEvent::Crit);
}
if !fumbles.is_empty() {
self.emit_priority(SoundEvent::Fumble);
}
match self.verdict() {
Some((true, _)) => self.emit_priority(SoundEvent::Success),
Some((false, _)) => self.emit_priority(SoundEvent::Failure),
None => {}
}
}
fn burst(&mut self, x: f32, y: f32, bright: bool) {
let (cx, cy) = (x, y);
let n = if bright { CRIT_PARTICLES } else { 6 };
for i in 0..n {
let angle =
(i as f32 / n as f32) * std::f32::consts::TAU + self.rng.gen_range(-0.2..=0.2);
let speed = if bright {
self.rng.gen_range(9.0..=20.0)
} else {
self.rng.gen_range(2.0..=5.0)
};
let glyph = if bright {
['✦', '*', '·'][i % 3]
} else {
'·'
};
self.particles.push(Particle {
x: cx,
y: cy,
vx: angle.cos() * speed * 1.8, vy: angle.sin() * speed - if bright { 6.0 } else { 0.0 },
age: 0.0,
life: if bright {
self.rng.gen_range(0.7..=1.2)
} else {
self.rng.gen_range(0.4..=0.8)
},
glyph,
bright,
});
}
}
fn update_particles(&mut self, dt: f32) {
let (maxx, maxy) = (self.arena_w.max(1.0), self.arena_h.max(1.0));
for p in &mut self.particles {
p.age += dt;
p.vy += GRAVITY * 0.25 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
if p.x < -1.0 || p.x > maxx || p.y > maxy {
p.age = p.life;
}
}
self.particles.retain(|p| p.age < p.life);
}
pub fn all_settled(&self) -> bool {
self.spawned && !self.dice.is_empty() && self.dice.iter().all(|d| d.settled)
}
fn world_to_cell(&self, pos: Vec3) -> (f32, f32) {
if self.arena_w < 1.0 || self.arena_h < 1.0 {
return (self.arena_w / 2.0, self.arena_h / 2.0);
}
let aspect = crate::render3d_view::arena_aspect(self.arena_w, self.arena_h);
let cam = crate::render3d_view::live_camera(
self.camera_shake(),
aspect,
self.focus,
self.clock,
self.flash,
);
crate::render3d_view::project_to_cell(&cam, pos, self.arena_w, self.arena_h)
.unwrap_or((self.arena_w / 2.0, self.arena_h / 2.0))
}
pub fn total(&self) -> i32 {
self.summed(|d| d.final_value as i32)
}
fn summed(&self, value: impl Fn(&Die) -> i32) -> i32 {
let mut sum = 0i32;
let mut i = 0;
while i < self.dice.len() {
let term = self.dice[i].term_idx;
let mult = self.dice[i].mult;
let mut term_sum = 0i32;
while i < self.dice.len() && self.dice[i].term_idx == term {
if self.dice[i].kept {
term_sum += value(&self.dice[i]);
}
i += 1;
}
sum += term_sum * mult;
}
sum + self.modifier
}
pub fn stats(&mut self) -> Result<Stats, String> {
let expr = self.input.trim().to_string();
if let Some((k_expr, k_rolls, stats)) = &self.stats_cache {
if *k_expr == expr && *k_rolls == self.history.len() {
return Ok(stats.clone());
}
}
let roll = parse::parse(&expr)?;
let mut sample_rng = StdRng::seed_from_u64(self.history.len() as u64 ^ 0x5715_d1ce);
let mut totals = Vec::with_capacity(STAT_SAMPLES);
let mut sum = 0i64;
let (mut lo, mut hi) = (i32::MAX, i32::MIN);
for _ in 0..STAT_SAMPLES {
let t = sample_total(&roll, &mut sample_rng);
totals.push(t);
sum += t as i64;
lo = lo.min(t);
hi = hi.max(t);
}
let mean = sum as f64 / STAT_SAMPLES as f64;
let success_odds = roll.stake.map(|s| {
totals.iter().filter(|&&v| check(v, s).0).count() as f64 / STAT_SAMPLES as f64
});
let dist = histogram(&totals, lo, hi);
let here: Vec<&HistoryEntry> = self.history.iter().filter(|e| e.expr == expr).collect();
let session = SessionStats::from(&here);
let stats = Stats {
expr: expr.clone(),
samples: STAT_SAMPLES,
min: lo,
max: hi,
mean,
stake: roll.stake,
success_odds,
dist,
session,
total_rolls: self.history.len(),
};
self.stats_cache = Some((expr, self.history.len(), stats.clone()));
Ok(stats)
}
}
#[derive(Debug, Clone, Copy)]
pub struct Bucket {
pub total: i32,
pub fraction: f64, }
#[derive(Debug, Clone)]
pub struct Stats {
pub expr: String,
pub samples: usize,
pub min: i32,
pub max: i32,
pub mean: f64,
pub stake: Option<Stake>,
pub success_odds: Option<f64>,
pub dist: Vec<Bucket>,
pub session: SessionStats,
pub total_rolls: usize,
}
#[derive(Debug, Clone, Default)]
pub struct SessionStats {
pub count: usize,
pub min: i32,
pub max: i32,
pub mean: f64,
}
impl SessionStats {
fn from(entries: &[&HistoryEntry]) -> Self {
if entries.is_empty() {
return SessionStats::default();
}
let mut lo = i32::MAX;
let mut hi = i32::MIN;
let mut sum = 0i64;
for e in entries {
lo = lo.min(e.total);
hi = hi.max(e.total);
sum += e.total as i64;
}
SessionStats {
count: entries.len(),
min: lo,
max: hi,
mean: sum as f64 / entries.len() as f64,
}
}
}
fn histogram(totals: &[i32], lo: i32, hi: i32) -> Vec<Bucket> {
const MAX_BUCKETS: usize = 11;
if totals.is_empty() {
return Vec::new();
}
let span = (hi - lo).max(0) as usize + 1;
let bins = span.min(MAX_BUCKETS);
let width = (span as f64 / bins as f64).max(1.0);
let mut counts = vec![0usize; bins];
for &t in totals {
let b = (((t - lo) as f64) / width) as usize;
counts[b.min(bins - 1)] += 1;
}
let n = totals.len() as f64;
counts
.iter()
.enumerate()
.map(|(b, &c)| Bucket {
total: lo + (b as f64 * width + width / 2.0).floor() as i32,
fraction: c as f64 / n,
})
.collect()
}
fn sample_total(roll: &Roll, rng: &mut StdRng) -> i32 {
let mut total = roll.modifier;
for term in &roll.terms {
let explode = explode_condition(term);
let mult = term_multiplier(term);
let mut pool: Vec<(u32, bool)> = (0..term.count)
.map(|_| (rng.gen_range(1..=term.sides), true))
.collect();
if let Some(cmp) = explode {
let mut spawned = 0usize;
let mut i = 0;
while i < pool.len() {
if cmp.matches(pool[i].0) && spawned < MAX_EXPLOSIONS {
pool.push((rng.gen_range(1..=term.sides), true));
spawned += 1;
}
i += 1;
}
}
let base = term.count as usize;
for m in &term.mods {
let (high, n) = match *m {
TermMod::KeepHigh(n) => (true, n as usize),
TermMod::DropLow(n) => (true, base.saturating_sub(n as usize)),
TermMod::KeepLow(n) => (false, n as usize),
TermMod::DropHigh(n) => (false, base.saturating_sub(n as usize)),
_ => continue,
};
keep_n_values(&mut pool[..base], n, high);
}
let term_sum: i32 = pool
.iter()
.filter(|&&(_, k)| k)
.map(|&(v, _)| v as i32)
.sum();
total += term_sum * mult;
}
total
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct OutcomeDie {
pub value: u32,
pub kept: bool,
pub exploded: bool,
pub crit: bool,
pub fumble: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct OutcomeTerm {
pub notation: String,
pub sides: u32,
pub dice: Vec<OutcomeDie>,
pub multiplier: i32,
pub subtotal: i32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Outcome {
pub expression: String,
pub terms: Vec<OutcomeTerm>,
pub modifier: i32,
pub total: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub goal: Option<Goal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub success: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub margin: Option<i32>,
}
pub fn evaluate(expression: &str, roll: &Roll, rng: &mut StdRng) -> Outcome {
let mut terms = Vec::with_capacity(roll.terms.len());
let mut total = roll.modifier;
for term in &roll.terms {
let explode = explode_condition(term);
let mult = term_multiplier(term);
let base = term.count as usize;
let mut dice: Vec<OutcomeDie> = (0..base)
.map(|_| OutcomeDie {
value: rng.gen_range(1..=term.sides),
kept: true,
exploded: false,
crit: false,
fumble: false,
})
.collect();
if let Some(cmp) = explode {
let mut spawned = 0usize;
let mut i = 0;
while i < dice.len() {
if cmp.matches(dice[i].value) && spawned < MAX_EXPLOSIONS {
dice.push(OutcomeDie {
value: rng.gen_range(1..=term.sides),
kept: true,
exploded: true,
crit: false,
fumble: false,
});
spawned += 1;
}
i += 1;
}
}
for m in &term.mods {
let (high, n) = match *m {
TermMod::KeepHigh(n) => (true, n as usize),
TermMod::DropLow(n) => (true, base.saturating_sub(n as usize)),
TermMod::KeepLow(n) => (false, n as usize),
TermMod::DropHigh(n) => (false, base.saturating_sub(n as usize)),
_ => continue,
};
keep_n_outcome(&mut dice[..base], n, high);
}
for d in &mut dice {
d.crit = d.kept && crit_face(term.sides, d.value);
d.fumble = d.kept && fumble_face(term.sides, d.value);
}
let kept_sum: i32 = dice.iter().filter(|d| d.kept).map(|d| d.value as i32).sum();
let subtotal = kept_sum * mult;
total += subtotal;
terms.push(OutcomeTerm {
notation: term_notation(term),
sides: term.sides,
dice,
multiplier: mult,
subtotal,
});
}
let checked = roll.stake.map(|s| check(total, s));
Outcome {
expression: expression.to_string(),
terms,
modifier: roll.modifier,
total,
target: roll.stake.map(|s| s.target),
goal: roll.stake.map(|s| s.goal),
success: checked.map(|(s, _)| s),
margin: checked.map(|(_, m)| m),
}
}
fn keep_n_outcome(dice: &mut [OutcomeDie], n: usize, high: bool) {
let mut live: Vec<usize> = (0..dice.len()).filter(|&i| dice[i].kept).collect();
live.sort_by_key(|&i| dice[i].value);
if high {
live.reverse();
}
for &i in live.iter().skip(n) {
dice[i].kept = false;
}
}
fn term_notation(term: &DiceTerm) -> String {
use std::fmt::Write;
let mut s = String::new();
if term.count != 1 {
let _ = write!(s, "{}", term.count);
}
let _ = write!(s, "d{}", term.sides);
for m in &term.mods {
match *m {
TermMod::KeepHigh(n) => {
let _ = write!(s, "kh{n}");
}
TermMod::KeepLow(n) => {
let _ = write!(s, "kl{n}");
}
TermMod::DropHigh(n) => {
let _ = write!(s, "dh{n}");
}
TermMod::DropLow(n) => {
let _ = write!(s, "dl{n}");
}
TermMod::Explode(None) => s.push('!'),
TermMod::Explode(Some(parse::Compare::Eq(n))) => {
let _ = write!(s, "!={n}");
}
TermMod::Explode(Some(parse::Compare::Gt(n))) => {
let _ = write!(s, "!>{n}");
}
TermMod::Explode(Some(parse::Compare::Lt(n))) => {
let _ = write!(s, "!<{n}");
}
TermMod::Mul(n) => {
let _ = write!(s, "*{n}");
}
}
}
s
}
fn keep_n_values(pool: &mut [(u32, bool)], n: usize, high: bool) {
let mut live: Vec<usize> = (0..pool.len()).filter(|&i| pool[i].1).collect();
live.sort_by_key(|&i| pool[i].0);
if high {
live.reverse();
}
for &i in live.iter().skip(n) {
pool[i].1 = false;
}
}
fn explode_condition(term: &DiceTerm) -> Option<parse::Compare> {
term.mods.iter().find_map(|m| match m {
TermMod::Explode(Some(c)) => Some(*c),
TermMod::Explode(None) => Some(parse::Compare::Eq(term.sides)),
_ => None,
})
}
fn term_multiplier(term: &DiceTerm) -> i32 {
term.mods
.iter()
.filter_map(|m| match m {
TermMod::Mul(n) => Some(*n),
_ => None,
})
.product()
}
fn apply_keep_drop(dice: &mut [Die], term: &DiceTerm) {
for m in &term.mods {
let (keep_high, n) = match *m {
TermMod::KeepHigh(n) => (true, n as usize),
TermMod::DropLow(n) => (true, dice.len().saturating_sub(n as usize)),
TermMod::KeepLow(n) => (false, n as usize),
TermMod::DropHigh(n) => (false, dice.len().saturating_sub(n as usize)),
_ => continue,
};
keep_n(dice, n, keep_high);
}
}
fn keep_n(dice: &mut [Die], n: usize, high: bool) {
let mut live: Vec<usize> = (0..dice.len()).filter(|&i| dice[i].kept).collect();
live.sort_by_key(|&i| dice[i].final_value);
if high {
live.reverse();
}
for &i in live.iter().skip(n) {
dice[i].kept = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settle(app: &mut App, max_frames: usize) -> Option<usize> {
for f in 0..max_frames {
app.update(1.0 / 60.0);
if app.all_settled() {
return Some(f);
}
}
None
}
fn in_box(p: Vec3) -> bool {
p.x.abs() <= physics::HX + 0.25
&& p.y.abs() <= physics::HY + 0.25
&& p.z.abs() <= physics::HZ + 0.25
}
fn seeded(input: &str, seed: u64) -> App {
let mut app = App {
input: input.to_string(),
cursor: input.len(),
pane_scroll: 0,
dice: Vec::new(),
modifier: 0,
stake: None,
error: None,
arena_w: 60.0,
arena_h: 20.0,
spawned: false,
shake: None,
last_throw: None,
particles: Vec::new(),
sounds: Vec::new(),
muted: false,
stats_cache: None,
explosions: Vec::new(),
history: Vec::new(),
pane: Pane::None,
mode: RollMode::Shake,
recorded: false,
rng: StdRng::seed_from_u64(seed),
physics: Physics::new(),
phys_accum: 0.0,
clock: 0.0,
flash: 0.0,
impact_energy: 0.0,
focus: 0.0,
};
app.roll();
app
}
fn tick(app: &mut App, secs: f32) {
let frames = (secs * 60.0) as usize;
for _ in 0..frames {
app.update(1.0 / 60.0);
}
}
#[test]
fn insta_rolls_the_same_dice_as_the_animation_under_the_same_seed() {
let expr = "4d6!kh3+2";
let mut animated = seeded(expr, 9);
for _ in 0..40_000 {
animated.update(1.0 / 60.0);
if animated.all_settled() {
break;
}
}
assert!(animated.all_settled());
let mut insta = seeded("", 9);
insta.input = expr.to_string();
insta.insta_roll();
assert!(insta.all_settled(), "insta must land settled");
let a: Vec<u32> = animated.dice.iter().map(|d| d.final_value).collect();
let b: Vec<u32> = insta.dice.iter().map(|d| d.final_value).collect();
assert_eq!(a, b, "insta must roll exactly the animation's dice");
}
#[test]
fn power_starts_at_zero_and_stays_in_unit_range() {
let mut app = seeded("", 1);
app.input = "3d6".into();
app.start_shake();
assert!(app.shaking());
assert_eq!(app.power(), 0.0, "power starts from a standstill");
let mut peak = 0.0f32;
for _ in 0..300 {
app.update(1.0 / 60.0);
let p = app.power();
assert!((0.0..=1.0).contains(&p), "power {p} escaped 0..=1");
peak = peak.max(p);
}
assert!(peak > 0.95, "power never came near its peak (max {peak})");
}
#[test]
fn shaking_rolls_nothing_until_released() {
let mut app = seeded("", 2);
app.input = "3d6".into();
app.start_shake();
tick(&mut app, 1.0);
assert!(app.dice.is_empty(), "dice appeared before the throw");
assert!(app.history.is_empty());
app.cancel_shake();
assert!(!app.shaking());
assert!(app.dice.is_empty(), "cancelling a shake must not roll");
}
#[test]
fn a_bad_expression_errors_at_pickup_not_at_release() {
let mut app = seeded("", 3);
app.input = "nonsense".into();
app.start_shake();
assert!(!app.shaking(), "a bad expression must not start a shake");
assert!(app.error.is_some(), "the typo surfaces immediately");
}
#[test]
fn a_throw_launches_from_the_cup_and_settles_in_bounds() {
for seed in 0..20 {
let mut app = seeded("", seed);
app.input = "4d6".into();
app.start_shake();
tick(&mut app, 0.4); app.throw();
assert!(!app.shaking());
assert_eq!(app.dice.len(), 4);
settle(&mut app, 20000).expect("thrown dice never settled");
for d in &app.dice {
assert!(
in_box(d.pos),
"seed {seed}: die escaped the tray at {:?}",
d.pos
);
}
assert_eq!(
app.history.len(),
1,
"a thrown roll is recorded like any other"
);
}
}
#[test]
fn throw_power_shapes_the_launch_but_not_the_values() {
let faces = |shake_secs: f32| -> (Vec<u32>, f32) {
let mut app = seeded("", 42);
app.input = "5d20".into();
app.start_shake();
tick(&mut app, shake_secs);
let power = app.power();
app.throw();
let mut vals: Vec<u32> = app.dice.iter().map(|d| d.final_value).collect();
vals.sort_unstable();
(vals, power)
};
let (hard_vals, hard_power) = faces(0.8);
let (soft_vals, soft_power) = faces(1.55);
assert!(
hard_power > 0.9,
"expected a near-peak release, got {hard_power}"
);
assert!(
soft_power < 0.2,
"expected a near-trough release, got {soft_power}"
);
assert_eq!(
hard_vals, soft_vals,
"throw power leaked into the roll values"
);
}
#[test]
fn a_full_power_throw_leaves_the_cup_faster_than_a_lob() {
let launch_speed = |shake_secs: f32, seed: u64| -> f32 {
let mut app = seeded("", seed);
app.input = "3d6".into();
app.start_shake();
tick(&mut app, shake_secs);
app.throw();
app.dice
.iter()
.map(|d| app.physics.speed(d.body))
.fold(0.0, f32::max)
};
for seed in 0..10 {
let rocket = launch_speed(0.8, seed); let lob = launch_speed(1.55, seed); assert!(
rocket > lob * 1.5,
"seed {seed}: rocket {rocket:.1} not clearly faster than lob {lob:.1}"
);
}
}
#[test]
fn same_power_throws_spray_both_directions() {
let (mut left, mut right) = (0, 0);
for seed in 0..30 {
let mut app = seeded("", seed);
app.input = "3d6".into();
app.start_shake();
tick(&mut app, 0.8); app.throw();
for d in &app.dice {
match app.physics.velocity_x(d.body) {
vx if vx < 0.0 => left += 1,
_ => right += 1,
}
}
}
assert!(
left > 0 && right > 0,
"full-power throws all broke one way ({left} left / {right} right)"
);
}
#[test]
fn staked_roll_reaches_a_verdict_only_once_settled() {
for seed in 0..30 {
let mut app = seeded("d20+2 vs 12", seed);
assert_eq!(app.stake.map(|s| s.target), Some(12));
assert_eq!(app.verdict(), None, "no verdict while dice are falling");
settle(&mut app, 6000).expect("never settled");
let (success, margin) = app.verdict().expect("settled roll must have a verdict");
assert_eq!(margin, app.total() - 12);
assert_eq!(success, app.total() >= 12, "seed {seed}");
}
}
#[test]
fn a_roll_under_verdict_flips_the_comparison() {
for seed in 0..30 {
let mut app = seeded("d20 < 10", seed);
assert_eq!(
app.stake,
Some(Stake {
target: 10,
goal: Goal::Under
})
);
settle(&mut app, 6000).expect("never settled");
let (success, margin) = app.verdict().expect("settled roll must have a verdict");
assert_eq!(success, app.total() <= 10, "seed {seed}");
assert_eq!(margin, 10 - app.total(), "seed {seed}");
}
}
#[test]
fn a_natural_20_bursts_gold_and_rings() {
let mut found = (false, false);
for seed in 0..300 {
let mut app = seeded("d20", seed);
let v = app.dice[0].final_value;
if v != 20 && v != 1 {
continue;
}
settle(&mut app, 6000).expect("never settled");
if v == 20 {
assert!(app.crit_dice().count() == 1);
assert!(
app.particles.iter().any(|p| p.bright),
"seed {seed}: no gold burst for a natural 20"
);
let (cx, cy) = app.world_to_cell(app.dice[0].pos);
assert!(
cx.is_finite() && cy.is_finite(),
"burst position not finite"
);
assert!(app.sounds.contains(&SoundEvent::Crit), "no crit ring");
found.0 = true;
} else {
assert!(app.fumble_dice().count() == 1);
assert!(
app.particles.iter().any(|p| !p.bright),
"seed {seed}: no dust for a natural 1"
);
assert!(app.sounds.contains(&SoundEvent::Fumble), "no fumble thud");
found.1 = true;
}
if found == (true, true) {
return;
}
}
panic!("seed range produced neither a 20 nor a 1: {found:?}");
}
#[test]
fn any_die_type_crits_on_its_max_and_fumbles_on_one() {
let mut found = (false, false);
for seed in 0..200 {
let mut app = seeded("d6", seed);
let v = app.dice[0].final_value;
if v != 6 && v != 1 {
continue;
}
settle(&mut app, 6000).expect("never settled");
if v == 6 {
assert_eq!(
app.crit_dice().count(),
1,
"seed {seed}: a maxed d6 is a crit"
);
assert!(
app.particles.iter().any(|p| p.bright),
"no gold for a maxed d6"
);
assert!(app.sounds.contains(&SoundEvent::Crit));
found.0 = true;
} else {
assert_eq!(
app.fumble_dice().count(),
1,
"seed {seed}: a d6 on 1 fumbles"
);
assert!(app.sounds.contains(&SoundEvent::Fumble));
found.1 = true;
}
if found == (true, true) {
return;
}
}
panic!("seed range produced neither a 6 nor a 1 on a d6: {found:?}");
}
#[test]
fn dropped_dice_never_crit() {
for seed in 0..500 {
let mut app = seeded("2d20kl1", seed);
let dropped_max = app.dice.iter().any(|d| !d.kept && d.final_value == 20);
let kept_max = app.dice.iter().any(|d| d.kept && d.final_value == 20);
if !dropped_max || kept_max {
continue;
}
settle(&mut app, 6000).expect("never settled");
assert_eq!(
app.crit_dice().count(),
0,
"seed {seed}: a dropped 20 must not crit"
);
assert!(
!app.sounds.contains(&SoundEvent::Crit),
"seed {seed}: dropped 20 rang"
);
return;
}
panic!("no seed dropped a 20 under disadvantage");
}
#[test]
fn many_crits_burst_per_die_but_ring_once() {
for seed in 0..300 {
let mut app = seeded("8d4", seed);
let maxes = app.dice.iter().filter(|d| d.final_value == 4).count();
if maxes < 2 {
continue;
}
settle(&mut app, 12000).expect("never settled");
assert_eq!(app.crit_dice().count(), maxes);
let bursts = app.particles.iter().filter(|p| p.bright).count();
assert!(
bursts >= maxes * 6,
"seed {seed}: {maxes} crits but only {bursts} spark glyphs"
);
let rings = app
.sounds
.iter()
.filter(|s| **s == SoundEvent::Crit)
.count();
assert_eq!(rings, 1, "seed {seed}: the ring plays once per roll");
return;
}
panic!("no seed rolled two maxed d4s");
}
#[test]
fn particles_expire_rather_than_accumulate() {
for seed in 0..300 {
let mut app = seeded("d20", seed);
if app.dice[0].final_value != 20 {
continue;
}
settle(&mut app, 6000).expect("never settled");
assert!(!app.particles.is_empty());
tick(&mut app, 3.0); assert!(app.particles.is_empty(), "particles never died");
return;
}
panic!("no natural 20 in seed range");
}
#[test]
fn a_staked_settle_emits_the_matching_verdict_sound() {
for seed in 0..30 {
let mut app = seeded("d20 vs 10", seed);
settle(&mut app, 6000).expect("never settled");
let (success, _) = app.verdict().unwrap();
let expect = if success {
SoundEvent::Success
} else {
SoundEvent::Failure
};
let opposite = if success {
SoundEvent::Failure
} else {
SoundEvent::Success
};
assert!(
app.sounds.contains(&expect),
"seed {seed}: verdict sound missing"
);
assert!(
!app.sounds.contains(&opposite),
"seed {seed}: wrong verdict sound"
);
}
}
#[test]
fn physics_makes_noise_and_the_queue_self_caps() {
let mut app = seeded("20d6", 4);
settle(&mut app, 20000).expect("never settled");
assert!(!app.sounds.is_empty(), "a full roll should make some noise");
assert!(app.sounds.len() <= MAX_SOUNDS, "queue exceeded its cap");
assert!(
app.sounds
.iter()
.any(|s| matches!(s, SoundEvent::Settle { .. })),
"no settle knock recorded"
);
}
#[test]
fn a_fresh_shake_does_not_inherit_a_stale_rattle() {
let mut app = seeded("", 6);
app.input = "3d6".into();
app.start_shake();
tick(&mut app, 1.0);
app.cancel_shake();
app.sounds.clear();
app.start_shake();
app.update(1.0 / 60.0);
assert!(
!app.sounds
.iter()
.any(|s| matches!(s, SoundEvent::Rattle { .. })),
"a fresh shake must not open with a leftover rattle tick"
);
}
#[test]
fn muting_empties_the_sound_queue_at_the_source() {
let mut app = seeded("3d6", 1);
settle(&mut app, 6000).expect("never settled");
assert!(!app.sounds.is_empty(), "a roll should queue some noise");
app.muted = true;
assert!(
app.take_sounds().is_empty(),
"muted take_sounds must return nothing"
);
assert!(
app.sounds.is_empty(),
"muted take_sounds must still clear the queue"
);
app.muted = false;
app.input = "3d6".into();
app.roll();
settle(&mut app, 6000).expect("never settled");
assert!(
!app.take_sounds().is_empty(),
"unmuted take_sounds must hand events over"
);
}
#[test]
fn the_throw_rolls_the_expression_locked_at_pickup() {
let mut app = seeded("", 8);
app.input = "2d6".into();
app.start_shake();
app.input = "9d9nonsense".into(); app.throw();
assert!(app.error.is_none(), "the locked expression was valid");
assert_eq!(
app.dice.len(),
2,
"the throw must roll the snapshot, not the mutation"
);
assert_eq!(
app.input, "2d6",
"the input reverts to what was actually thrown"
);
}
#[test]
fn shaking_rattles_and_release_echo_fades() {
let mut app = seeded("", 5);
app.input = "3d6".into();
app.start_shake();
tick(&mut app, 0.8);
assert!(
app.sounds
.iter()
.any(|s| matches!(s, SoundEvent::Rattle { .. })),
"a shaken cup must rattle"
);
app.throw();
assert!(app.sounds.contains(&SoundEvent::Throw {
power: app.last_throw.unwrap().power
}));
assert!(
app.release_echo().is_some(),
"no release echo after a throw"
);
assert!(
app.tremor() > 0.0,
"a near-peak throw should shake the arena"
);
tick(&mut app, 2.0);
assert_eq!(app.release_echo(), None, "the echo must fade");
assert_eq!(app.tremor(), 0.0, "the tremor must stop");
}
#[test]
fn advantage_keeps_the_higher_die() {
for seed in 0..50 {
let app = seeded("2d20kh1", seed);
assert_eq!(app.dice.len(), 2, "both dice are still thrown");
let kept: Vec<&Die> = app.dice.iter().filter(|d| d.kept).collect();
assert_eq!(kept.len(), 1, "advantage keeps exactly one");
let dropped = app.dice.iter().find(|d| !d.kept).unwrap();
assert!(
kept[0].final_value >= dropped.final_value,
"kept {} < dropped {}",
kept[0].final_value,
dropped.final_value
);
assert_eq!(app.total(), kept[0].final_value as i32);
}
}
#[test]
fn disadvantage_keeps_the_lower_die() {
for seed in 0..50 {
let app = seeded("2d20kl1", seed);
let kept = app.dice.iter().find(|d| d.kept).unwrap();
let dropped = app.dice.iter().find(|d| !d.kept).unwrap();
assert!(kept.final_value <= dropped.final_value);
}
}
#[test]
fn drop_lowest_sums_the_top_three() {
for seed in 0..50 {
let app = seeded("4d6dl1", seed);
assert_eq!(app.dice.len(), 4);
let mut vals: Vec<u32> = app.dice.iter().map(|d| d.final_value).collect();
vals.sort_unstable();
let expected: i32 = vals[1..].iter().map(|&v| v as i32).sum(); assert_eq!(app.total(), expected);
assert_eq!(app.dice.iter().filter(|d| !d.kept).count(), 1);
}
}
#[test]
fn exploding_happens_during_the_animation_not_up_front() {
for seed in 0..30 {
let app = seeded("6d6!", seed);
assert_eq!(app.dice.len(), 6, "seed {seed}: roll() pre-spawned dice");
}
let mut grew = false;
for seed in 0..200 {
let mut app = seeded("6d6!", seed);
let base_max = app.dice.iter().filter(|d| d.final_value == 6).count();
settle(&mut app, 20000).expect("exploding pool never settled");
if base_max > 0 {
assert!(
app.dice.len() > 6,
"seed {seed}: base roll had a six but pool never grew"
);
grew = true;
break;
}
}
assert!(grew, "no seed in range rolled a six to explode");
}
#[test]
fn every_settled_max_die_spawned_exactly_one_more() {
for seed in 0..30 {
let mut app = seeded("6d6!", seed);
settle(&mut app, 20000).expect("never settled");
let sixes = app.dice.iter().filter(|d| d.final_value == 6).count();
let spawned = app.dice.len() - 6;
assert_eq!(
spawned,
sixes.min(MAX_EXPLOSIONS),
"seed {seed}: {sixes} sixes but {spawned} spawned"
);
assert!(
app.dice.iter().all(|d| d.exploded),
"a settled die never got its explosion check"
);
}
}
#[test]
fn frequent_explosions_hit_the_cap_and_still_converge() {
for seed in 0..20 {
let mut app = seeded("4d3!>1", seed);
settle(&mut app, 40000).expect("capped explosion chain never settled");
assert!(app.dice.len() <= 4 + MAX_EXPLOSIONS, "blew past the cap");
for d in &app.dice {
assert!(in_box(d.pos), "die escaped the tray at {:?}", d.pos);
}
}
}
#[test]
fn exploding_stays_capped_and_total_is_the_full_sum() {
for seed in 0..30 {
let mut app = seeded("6d6!", seed);
settle(&mut app, 20000).expect("never settled");
assert!(
app.dice.len() <= 6 + MAX_EXPLOSIONS,
"explosion count is capped"
);
let expected: i32 = app.dice.iter().map(|d| d.final_value as i32).sum();
assert_eq!(app.total(), expected);
}
}
#[test]
fn multiply_scales_only_its_term() {
for seed in 0..50 {
let app = seeded("3d6*2+d8", seed);
let t0: i32 = app
.dice
.iter()
.filter(|d| d.term_idx == 0)
.map(|d| d.final_value as i32)
.sum();
let t1: i32 = app
.dice
.iter()
.filter(|d| d.term_idx == 1)
.map(|d| d.final_value as i32)
.sum();
assert_eq!(app.total(), t0 * 2 + t1);
}
}
#[test]
fn exploding_pool_still_converges_and_stays_in_arena() {
let mut app = seeded("6d6!", 3);
let mut settled = false;
for _ in 0..20000 {
app.update(1.0 / 60.0);
for d in &app.dice {
assert!(in_box(d.pos), "die escaped the tray at {:?}", d.pos);
}
if app.all_settled() {
settled = true;
break;
}
}
assert!(settled, "exploding pool never settled");
}
#[test]
fn dice_settle_and_total_is_consistent() {
let mut app = App::new("4d6+2".to_string());
app.arena_w = 60.0;
app.arena_h = 20.0;
let frame = settle(&mut app, 6000).expect("dice never came to rest");
assert!(frame < 6000);
for d in &app.dice {
assert_eq!(d.shown, d.final_value);
assert!((1..=d.sides).contains(&d.final_value));
}
let t = app.total();
assert!((4 + 2..=24 + 2).contains(&t), "total {t} out of range");
}
#[test]
fn dice_stay_inside_the_arena() {
let mut app = App::new("6d8".to_string());
for _ in 0..20000 {
app.update(1.0 / 60.0);
for d in &app.dice {
assert!(in_box(d.pos), "die escaped the tray at {:?}", d.pos);
}
if app.all_settled() {
break;
}
}
assert!(app.all_settled());
}
#[test]
#[ignore]
fn debug_converge() {
let mut app = App::new("6d8".to_string());
for f in 0..20000 {
app.update(1.0 / 60.0);
if f % 500 == 0 || app.all_settled() {
let settled = app.dice.iter().filter(|d| d.settled).count();
eprintln!("f={f:5} settled={settled}/{}", app.dice.len());
if app.all_settled() {
break;
}
}
}
for (i, d) in app.dice.iter().enumerate() {
eprintln!(" die {i}: pos={:?} settled={}", d.pos, d.settled);
}
}
#[test]
fn settled_dice_do_not_overlap() {
let mut app = App::new("8d6".to_string());
settle(&mut app, 20000).expect("crowded pool never settled");
let min_gap = 2.0 * physics::DIE_R / 3.0_f32.sqrt() * 0.96;
for i in 0..app.dice.len() {
for j in (i + 1)..app.dice.len() {
let gap = (app.dice[i].pos - app.dice[j].pos).length();
assert!(
gap > min_gap,
"dice {i} and {j} overlap: centres {gap:.2} apart (< {min_gap:.2})"
);
}
}
}
#[test]
fn settled_dice_stay_in_bounds_when_cramped() {
for spec in ["12d6", "16d6", "20d6", "60d6"] {
for _ in 0..15 {
let mut app = App::new(spec.to_string());
settle(&mut app, 20000).expect("cramped pool never settled");
for (i, d) in app.dice.iter().enumerate() {
assert!(
in_box(d.pos),
"{spec} die {i} settled out of bounds at {:?}",
d.pos
);
}
}
}
}
#[test]
fn a_plain_roll_comes_to_rest() {
let mut app = App::new("3d6".to_string());
assert!(
settle(&mut app, 20000).is_some(),
"a plain roll must come to rest"
);
}
#[test]
fn a_completed_roll_is_recorded_in_history_exactly_once() {
let mut app = seeded("3d6+1", 5);
assert!(app.history.is_empty(), "nothing recorded before it settles");
settle(&mut app, 6000).expect("never settled");
for _ in 0..200 {
app.update(1.0 / 60.0);
}
assert_eq!(app.history.len(), 1, "recorded once and only once");
let e = &app.history[0];
assert_eq!(e.expr, "3d6+1");
assert_eq!(e.values.len(), 3); let face_sum: i32 = e.values.iter().map(|&v| v as i32).sum();
assert_eq!(e.total, face_sum + 1);
assert_eq!(e.total, app.total());
}
#[test]
fn history_records_only_kept_dice() {
let mut app = seeded("2d20kh1", 9);
settle(&mut app, 6000).expect("never settled");
assert_eq!(app.history.len(), 1);
assert_eq!(
app.history[0].values.len(),
1,
"only the kept die is stored"
);
assert_eq!(app.history[0].total, app.history[0].values[0] as i32);
}
#[test]
fn history_is_capped() {
let mut app = seeded("d6", 1);
for n in 0..(MAX_HISTORY + 50) {
app.history.push(HistoryEntry {
expr: "d6".into(),
values: vec![1],
total: n as i32,
});
}
app.input = "d6".into();
app.roll();
settle(&mut app, 6000).expect("never settled");
assert!(app.history.len() <= MAX_HISTORY, "history exceeded its cap");
}
#[test]
fn sampled_stats_match_known_dice_ranges() {
let mut app = seeded("3d6", 1);
let s = app.stats().expect("3d6 parses");
assert_eq!(s.min, 3);
assert_eq!(s.max, 18);
assert!((s.mean - 10.5).abs() < 0.3, "mean {} far from 10.5", s.mean);
let total: f64 = s.dist.iter().map(|b| b.fraction).sum();
assert!((total - 1.0).abs() < 1e-6, "fractions sum to {total}");
}
#[test]
fn sampled_stats_reflect_modifiers() {
let adv = App::new("2d20kh1".to_string()).stats().unwrap();
assert_eq!(adv.min, 1);
assert_eq!(adv.max, 20);
assert!(
adv.mean > 12.0,
"advantage mean {} should beat a flat d20",
adv.mean
);
let doubled = App::new("1d6*2".to_string()).stats().unwrap();
assert_eq!(doubled.min, 2);
assert_eq!(doubled.max, 12);
}
#[test]
fn session_stats_summarize_matching_rolls() {
let mut app = seeded("3d6", 2);
settle(&mut app, 6000).expect("settle 1");
app.input = "3d6".into();
app.roll();
settle(&mut app, 6000).expect("settle 2");
let s = app.stats().unwrap();
assert_eq!(s.session.count, 2, "both 3d6 rolls counted");
assert_eq!(s.total_rolls, 2);
assert!(s.session.min <= s.session.max);
assert!(s.session.mean >= 3.0 && s.session.mean <= 18.0);
}
#[test]
fn stats_error_surfaces_for_bad_input() {
let mut app = App::new(String::new());
app.input = "garbage".into();
assert!(app.stats().is_err());
}
}