use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::parse::{self, DiceTerm, Roll, TermMod};
pub const DIE_W: f32 = 6.0;
pub const DIE_H: f32 = 4.0;
const GRAVITY: f32 = 60.0;
const WALL_RESTITUTION: f32 = 0.72;
const FLOOR_RESTITUTION: f32 = 0.5;
const FLOOR_FRICTION: f32 = 0.7;
const AIR_DRAG: f32 = 0.4;
const TUMBLE_INTERVAL: f32 = 0.05; const SETTLE_SPEED_SQ: f32 = 6.0; const MIN_BOUNCE_VY: f32 = 3.5; const DIE_RESTITUTION: f32 = 0.2; const CONTACT_EPS: f32 = 0.7; const COLLISION_ITERS: usize = 8; const STUCK_TIMEOUT: f32 = 0.4; const TOPPLE_FACTOR: f32 = 1.4; const TOPPLE_MAX: f32 = 20.0; const MIN_SLIDE_VX: f32 = 2.5; const MAX_AIRBORNE: f32 = 8.0; const MAX_EXPLOSIONS: usize = 40;
const SHAKE_POWER_RATE: f32 = 3.9; const CUP_SWAY_RATE: f32 = 7.0; const THROW_SPEED_MIN: f32 = 26.0; const THROW_SPEED_MAX: f32 = 95.0; const MAX_HISTORY: usize = 200; const STAT_SAMPLES: usize = 20_000; const MAX_SOUNDS: usize = 64; const KNOCK_BUDGET: usize = 8; const SOUND_SPEED_MIN: f32 = 8.0; 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 x: f32,
pub y: f32,
pub vx: f32,
pub vy: f32,
pub settled: bool,
pub color_idx: usize,
pub term_idx: usize,
pub mult: i32,
pub kept: bool,
explode: Option<parse::Compare>,
exploded: bool,
tumble_acc: f32,
still_for: f32, age: f32, }
impl Die {
fn launch_at(&mut self, x: f32, y: f32, vx: f32, vy: f32) {
self.x = x;
self.y = y;
self.vx = vx;
self.vy = vy;
self.settled = false;
self.tumble_acc = 0.0;
self.still_for = 0.0;
self.age = 0.0;
}
}
pub fn check(total: i32, target: i32) -> (bool, i32) {
let margin = (total as i64 - target as i64).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
}
#[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 dice: Vec<Die>,
pub modifier: i32,
pub target: Option<i32>,
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,
needs_spawn: bool,
shake: Option<Shake>,
pending_throw: Option<(f32, f32)>,
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,
}
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(),
dice: Vec::new(),
modifier: 0,
target: None,
error: None,
history: Vec::new(),
pane: Pane::None,
mode: RollMode::Shake,
recorded: false,
arena_w: 0.0,
arena_h: 0.0,
spawned: false,
needs_spawn: false,
shake: None,
pending_throw: None,
last_throw: None,
particles: Vec::new(),
sounds: Vec::new(),
muted: false,
stats_cache: None,
explosions: Vec::new(),
rng,
};
if !initial.trim().is_empty() {
app.input = initial.trim().to_string();
app.roll();
}
app
}
pub fn roll(&mut self) {
match parse::parse(&self.input) {
Ok(Roll {
terms,
modifier,
target,
}) => {
self.error = None;
self.modifier = modifier;
self.target = target;
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.needs_spawn = true;
self.recorded = false;
self.particles.clear();
self.pending_throw = None;
self.last_throw = None;
}
Err(e) => {
self.error = Some(e);
}
}
}
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 {
Die {
sides,
final_value: self.rng.gen_range(1..=sides),
shown: self.rng.gen_range(1..=sides),
x: 0.0,
y: 0.0,
vx: 0.0,
vy: 0.0,
settled: false,
color_idx,
term_idx,
mult: 1,
kept: true,
explode,
exploded: false,
tumble_acc: 0.0,
still_for: 0.0,
age: 0.0,
}
}
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 throw(&mut self) {
let power = self.power();
let cup_x = self.cup_x();
let Some(shake) = self.shake.take() else {
return;
};
self.input = shake.expr;
self.roll();
if self.error.is_none() {
self.pending_throw = Some((power, cup_x));
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 target = self.target?;
if !self.all_settled() {
return None;
}
Some(check(self.total(), target))
}
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 max_xy(&self) -> (f32, f32) {
(
(self.arena_w - DIE_W).max(0.0),
(self.arena_h - DIE_H).max(0.0),
)
}
fn spawn(&mut self) {
let (maxx, maxy) = self.max_xy();
if let Some((power, cup_x)) = self.pending_throw.take() {
self.spawn_throw(power, cup_x, maxx, maxy);
} else {
let rng = &mut self.rng;
for die in &mut self.dice {
die.launch_at(
rng.gen_range(0.0..=maxx.max(0.01)),
rng.gen_range(0.0..=(maxy * 0.35).max(0.01)),
rng.gen_range(-42.0..=42.0),
rng.gen_range(-22.0..=18.0),
);
}
}
self.spawned = true;
self.needs_spawn = false;
}
fn spawn_throw(&mut self, power: f32, cup_x: f32, maxx: f32, maxy: f32) {
let rng = &mut self.rng;
let speed = THROW_SPEED_MIN + (THROW_SPEED_MAX - THROW_SPEED_MIN) * power;
let dir = if cup_x < maxx / 2.0 { 1.0 } else { -1.0 };
let upward = 0.9 - 0.35 * power; for die in &mut self.dice {
let s = speed * rng.gen_range(0.85..=1.15);
die.launch_at(
(cup_x - DIE_W / 2.0 + rng.gen_range(-2.0..=2.0)).clamp(0.0, maxx),
(maxy - rng.gen_range(0.0..=2.0)).max(0.0),
dir * s * rng.gen_range(0.25 + 0.4 * power..=0.45 + 0.5 * power),
-s * rng.gen_range(upward * 0.8..=upward * 1.1),
);
}
}
pub fn update(&mut self, dt: f32) {
let dt = dt.min(0.05);
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.needs_spawn && self.arena_w > 0.0 {
self.spawn();
}
if !self.spawned {
return;
}
let (maxx, maxy) = self.max_xy();
let drag = (1.0 - AIR_DRAG * dt).max(0.0);
let mut impacts: Vec<SoundEvent> = Vec::new();
fn clack(impacts: &mut Vec<SoundEvent>, sides: u32, speed: f32) {
if speed > SOUND_SPEED_MIN {
impacts.push(SoundEvent::Impact { sides, speed });
}
}
for die in &mut self.dice {
if die.settled {
continue;
}
if maxy < 0.5 {
die.settled = true;
die.shown = die.final_value;
die.x = die.x.clamp(0.0, maxx);
die.y = die.y.clamp(0.0, maxy);
continue;
}
die.vy += GRAVITY * dt;
die.vx *= drag;
die.x += die.vx * dt;
die.y += die.vy * dt;
if die.x < 0.0 {
die.x = 0.0;
clack(&mut impacts, die.sides, die.vx.abs());
die.vx = -die.vx * WALL_RESTITUTION;
} else if die.x > maxx {
die.x = maxx;
clack(&mut impacts, die.sides, die.vx.abs());
die.vx = -die.vx * WALL_RESTITUTION;
}
if die.y < 0.0 {
die.y = 0.0;
clack(&mut impacts, die.sides, die.vy.abs());
die.vy = -die.vy * WALL_RESTITUTION;
}
if die.y > maxy {
die.y = maxy;
clack(&mut impacts, die.sides, die.vy.abs());
die.vy = -die.vy * FLOOR_RESTITUTION;
die.vx *= FLOOR_FRICTION;
if die.vy.abs() < MIN_BOUNCE_VY {
die.vy = 0.0;
}
}
die.tumble_acc += dt;
if die.tumble_acc >= TUMBLE_INTERVAL {
die.tumble_acc = 0.0;
die.shown = self.rng.gen_range(1..=die.sides);
}
}
for ev in impacts {
self.emit(ev);
}
self.resolve_collisions(maxx, maxy);
self.settle_supported(dt, maxx, maxy);
if !self.recorded && self.all_settled() {
self.record_roll();
self.recorded = true;
}
}
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 crits: Vec<(f32, f32)> = self.crit_dice().map(|d| (d.x, d.y)).collect();
let fumbles: Vec<(f32, f32)> = self.fumble_dice().map(|d| (d.x, d.y)).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 + DIE_W / 2.0, y + DIE_H / 2.0);
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);
}
fn resolve_collisions(&mut self, maxx: f32, maxy: f32) {
let n = self.dice.len();
let mut knocks: Vec<SoundEvent> = Vec::new();
for iter in 0..COLLISION_ITERS {
for i in 0..n {
for j in (i + 1)..n {
let (left, right) = self.dice.split_at_mut(j);
let (a, b) = (&mut left[i], &mut right[0]);
let closing = resolve_pair(a, b);
if iter == 0 && knocks.len() < KNOCK_BUDGET {
if let Some(speed) = closing {
if speed > SOUND_SPEED_MIN {
knocks.push(SoundEvent::Knock {
sides: a.sides.max(b.sides),
speed,
});
}
}
}
}
}
}
for die in &mut self.dice {
if !die.settled {
die.x = die.x.clamp(0.0, maxx);
die.y = die.y.clamp(0.0, maxy);
}
}
for ev in knocks {
self.emit(ev);
}
}
fn settle_supported(&mut self, dt: f32, maxx: f32, maxy: f32) {
let mut resting: Vec<(f32, f32)> = self
.dice
.iter()
.filter(|d| d.settled)
.map(|d| (d.x, d.y))
.collect();
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;
}
self.dice[i].age += dt;
let overdue = self.dice[i].age >= MAX_AIRBORNE;
let slow = {
let d = &self.dice[i];
d.vx * d.vx + d.vy * d.vy < SETTLE_SPEED_SQ
};
if slow {
self.dice[i].still_for += dt;
} else {
self.dice[i].still_for = 0.0;
if !overdue {
continue; }
}
let stuck = overdue || self.dice[i].still_for >= STUCK_TIMEOUT;
let die = &self.dice[i];
let on_floor = die.y >= maxy - 0.5;
let bottom = die.y + DIE_H;
let support_top = resting
.iter()
.filter(|&&(rx, ry)| {
let x_overlap = die.x < rx + DIE_W && die.x + DIE_W > rx;
x_overlap && ry > die.y && (bottom - ry).abs() < CONTACT_EPS
})
.map(|&(_, ry)| ry)
.fold(f32::INFINITY, f32::min);
let (rest_x, rest_y) = if on_floor {
(die.x, maxy)
} else if support_top.is_finite() {
(die.x, support_top - DIE_H)
} else if stuck {
(die.x, die.y) } else {
continue; };
let rest_x = rest_x.clamp(0.0, maxx);
let rest_y = rest_y.clamp(0.0, maxy);
let clashes = !stuck
&& resting.iter().any(|&(rx, ry)| {
let (px, py) = penetration(rest_x, rest_y, rx, ry);
px.min(py) > 0.5
});
if clashes {
continue;
}
let die = &mut self.dice[i];
die.settled = true;
die.shown = die.final_value;
die.x = rest_x;
die.y = rest_y;
die.vx = 0.0;
die.vy = 0.0;
resting.push((rest_x, rest_y));
let sides = die.sides;
if !die.exploded {
die.exploded = true;
if let Some(cmp) = die.explode {
let term = die.term_idx;
if cmp.matches(die.final_value) && self.explosions[term] < MAX_EXPLOSIONS {
self.explosions[term] += 1;
to_explode.push((die.sides, term, die.mult, cmp));
}
}
}
self.emit(SoundEvent::Settle { sides });
}
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;
self.launch(&mut die, maxx, maxy);
self.dice.push(die);
}
}
fn launch(&mut self, die: &mut Die, maxx: f32, maxy: f32) {
die.launch_at(
self.rng.gen_range(0.0..=maxx.max(0.01)),
self.rng.gen_range(0.0..=(maxy * 0.35).max(0.01)),
self.rng.gen_range(-42.0..=42.0),
self.rng.gen_range(-22.0..=18.0),
);
}
pub fn all_settled(&self) -> bool {
self.spawned && !self.dice.is_empty() && self.dice.iter().all(|d| d.settled)
}
pub fn total(&self) -> i32 {
self.summed(|d| d.final_value as i32)
}
pub fn live_total(&self) -> i32 {
self.summed(|d| d.shown 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.target.map(|t| {
totals.iter().filter(|&&v| check(v, t).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,
target: roll.target,
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 target: Option<i32>,
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 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.target.map(|t| check(total, t));
Outcome {
expression: expression.to_string(),
terms,
modifier: roll.modifier,
total,
target: roll.target,
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;
}
}
fn penetration(ax: f32, ay: f32, bx: f32, by: f32) -> (f32, f32) {
(
(DIE_W - (ax - bx).abs()).max(0.0),
(DIE_H - (ay - by).abs()).max(0.0),
)
}
fn resolve_pair(a: &mut Die, b: &mut Die) -> Option<f32> {
if a.settled && b.settled {
return None; }
let dx = a.x - b.x;
let dy = a.y - b.y;
let (px, py) = penetration(a.x, a.y, b.x, b.y);
if px <= 0.0 || py <= 0.0 {
return None; }
let (a_share, b_share) = if a.settled {
(0.0, 1.0)
} else if b.settled {
(1.0, 0.0)
} else {
(0.5, 0.5)
};
if px < py {
let n = if dx >= 0.0 { 1.0 } else { -1.0 };
a.x += n * px * a_share;
b.x -= n * px * b_share;
let closing = -(a.vx - b.vx) * n;
resolve_velocity(&mut a.vx, &mut b.vx, n, a.settled, b.settled);
(closing > 0.0).then_some(closing)
} else {
let n = if dy >= 0.0 { 1.0 } else { -1.0 };
a.y += n * py * a_share;
b.y -= n * py * b_share;
let closing = -(a.vy - b.vy) * n;
resolve_velocity(&mut a.vy, &mut b.vy, n, a.settled, b.settled);
let (upper, lower) = if dy < 0.0 {
(&mut *a, &*b)
} else {
(&mut *b, &*a)
};
if closing > 0.0 && !upper.settled {
let kick = (upper.x - lower.x) / DIE_W * closing * TOPPLE_FACTOR;
upper.vx += kick.clamp(-TOPPLE_MAX, TOPPLE_MAX);
}
for d in [&mut *a, &mut *b] {
if d.settled {
continue;
}
if d.vy.abs() < MIN_BOUNCE_VY {
d.vy = 0.0;
}
if d.vx.abs() < MIN_SLIDE_VX {
d.vx = 0.0;
}
}
(closing > 0.0).then_some(closing)
}
}
fn resolve_velocity(va: &mut f32, vb: &mut f32, n: f32, a_static: bool, b_static: bool) {
let e = DIE_RESTITUTION;
match (a_static, b_static) {
(false, false) => {
if (*va - *vb) * n < 0.0 {
let (a0, b0) = (*va, *vb);
*va = 0.5 * ((1.0 - e) * a0 + (1.0 + e) * b0);
*vb = 0.5 * ((1.0 + e) * a0 + (1.0 - e) * b0);
}
}
(true, false) => {
if *vb * n > 0.0 {
*vb *= -e;
}
}
(false, true) => {
if *va * n < 0.0 {
*va *= -e;
}
}
(true, true) => {}
}
}
#[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 seeded(input: &str, seed: u64) -> App {
let mut app = App {
input: input.to_string(),
dice: Vec::new(),
modifier: 0,
target: None,
error: None,
arena_w: 60.0,
arena_h: 20.0,
spawned: false,
needs_spawn: false,
shake: None,
pending_throw: 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),
};
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); let cup = app.cup_x();
app.throw();
assert!(!app.shaking());
assert_eq!(app.dice.len(), 4);
app.update(1.0 / 60.0); let (maxx, maxy) = app.max_xy();
let avg_vy: f32 = app.dice.iter().map(|d| d.vy).sum::<f32>() / app.dice.len() as f32;
assert!(
avg_vy < 0.0,
"seed {seed}: the pool must launch upward on balance"
);
for d in &app.dice {
assert!(
(d.x - (cup - DIE_W / 2.0)).abs() <= 3.0 * DIE_W,
"seed {seed}: die spawned at x={} far from the cup at {cup}",
d.x
);
assert!(
d.y >= maxy - 3.0 - 2.0 * DIE_H,
"seed {seed}: die spawned at y={} nowhere near the floor",
d.y
);
}
settle(&mut app, 20000).expect("thrown dice never settled");
for d in &app.dice {
assert!(d.x >= -0.01 && d.x <= maxx + 0.01);
assert!(d.y >= -0.01 && d.y <= maxy + 0.01);
}
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.update(1.0 / 60.0); app.dice
.iter()
.map(|d| (d.vx * d.vx + d.vy * d.vy).sqrt())
.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 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.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_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"
);
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);
let (maxx, maxy) = app.max_xy();
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!(d.x >= -0.01 && d.x <= maxx + 0.01);
assert!(d.y >= -0.01 && d.y <= maxy + 0.01);
}
}
}
#[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 live_total_excludes_dropped_dice_mid_roll() {
let app = seeded("2d20kh1", 7);
let kept = app.dice.iter().find(|d| d.kept).unwrap();
let by_hand: i32 = app
.dice
.iter()
.filter(|d| d.kept)
.map(|d| d.shown as i32)
.sum();
assert_eq!(app.live_total(), by_hand);
let _ = kept; }
#[test]
fn exploding_pool_still_converges_and_stays_in_arena() {
let mut app = seeded("6d6!", 3);
let (maxx, maxy) = app.max_xy();
let mut settled = false;
for _ in 0..12000 {
app.update(1.0 / 60.0);
for d in &app.dice {
assert!(d.x >= -0.001 && d.x <= maxx + 0.001);
assert!(d.y >= -0.001 && d.y <= maxy + 0.001);
}
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));
}
assert_eq!(app.total(), app.live_total());
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());
app.arena_w = 40.0;
app.arena_h = 15.0;
let (maxx, maxy) = app.max_xy();
for _ in 0..6000 {
app.update(1.0 / 60.0);
for d in &app.dice {
assert!(d.x >= -0.001 && d.x <= maxx + 0.001, "x={} escaped", d.x);
assert!(d.y >= -0.001 && d.y <= maxy + 0.001, "y={} escaped", d.y);
}
if app.all_settled() {
break;
}
}
assert!(app.all_settled());
}
#[test]
#[ignore]
fn debug_converge() {
let mut app = App::new("6d8".to_string());
app.arena_w = 40.0;
app.arena_h = 15.0;
for f in 0..6000 {
app.update(1.0 / 60.0);
if f % 500 == 0 || app.all_settled() {
let settled = app.dice.iter().filter(|d| d.settled).count();
let max_sp = app
.dice
.iter()
.map(|d| (d.vx * d.vx + d.vy * d.vy).sqrt())
.fold(0.0f32, f32::max);
eprintln!("f={f:5} settled={settled}/6 max_speed={max_sp:6.2}");
if app.all_settled() {
break;
}
}
}
for (i, d) in app.dice.iter().enumerate() {
eprintln!(
" die {i}: x={:6.2} y={:6.2} settled={}",
d.x, d.y, d.settled
);
}
}
#[test]
fn settled_dice_do_not_overlap() {
let mut app = App::new("8d6".to_string());
app.arena_w = 22.0;
app.arena_h = 40.0;
settle(&mut app, 12000).expect("crowded pool never settled");
for i in 0..app.dice.len() {
for j in (i + 1)..app.dice.len() {
let (a, b) = (&app.dice[i], &app.dice[j]);
let (px, py) = penetration(a.x, a.y, b.x, b.y);
let pen = px.min(py);
assert!(
pen < 0.5,
"dice {i} and {j} overlap by {pen:.2} at ({},{}) / ({},{})",
a.x,
a.y,
b.x,
b.y
);
}
}
}
#[test]
fn settled_dice_stay_in_bounds_when_cramped() {
for (spec, w, h) in [
("12d6", 20.0, 14.0),
("12d6", 18.0, 14.0),
("16d6", 22.0, 16.0),
] {
for _ in 0..40 {
let mut app = App::new(spec.to_string());
app.arena_w = w;
app.arena_h = h;
let (maxx, maxy) = app.max_xy();
settle(&mut app, 15000).expect("cramped pool never settled");
for (i, d) in app.dice.iter().enumerate() {
assert!(
d.x >= -0.01 && d.x <= maxx + 0.01 && d.y >= -0.01 && d.y <= maxy + 0.01,
"{spec} die {i} settled out of bounds at ({}, {})",
d.x,
d.y
);
}
}
}
}
#[test]
fn tiny_arena_still_settles() {
let mut app = App::new("3d6".to_string());
app.arena_w = 3.0; app.arena_h = 2.0;
assert!(settle(&mut app, 100).is_some());
}
#[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());
}
}