mod app;
mod cli;
mod foley;
mod parse;
mod ui;
use std::io::{self, IsTerminal};
use std::time::{Duration, Instant};
use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use app::{App, Pane, RollMode};
use cli::Cli;
const FRAME: Duration = Duration::from_millis(16); const MAX_CLICKS_PER_FRAME: usize = 8;
fn toggle(current: Pane, target: Pane) -> Pane {
if current == target {
Pane::None
} else {
target
}
}
fn main() -> io::Result<()> {
let cli = Cli::parse();
let expr = cli.expression();
let one_shot = cli.print || cli.json || cli.verbose || !io::stdout().is_terminal();
if one_shot {
if expr.is_empty() {
eprintln!("roll: no dice expression given (e.g. `roll 3d6`)");
std::process::exit(2);
}
return cli::run_one_shot(&cli, &expr);
}
let mut terminal = ratatui::init();
let mut app = match cli.seed {
Some(seed) => App::with_seed(expr, seed),
None => App::new(expr),
};
app.muted = cli.mute;
let result = run(&mut terminal, &mut app);
ratatui::restore();
result
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Continue,
Quit,
}
fn handle_key(app: &mut App, code: KeyCode, ctrl: bool) -> Action {
if ctrl && code == KeyCode::Char('c') {
return Action::Quit;
}
match code {
KeyCode::Char('?') => {
app.pane = toggle(app.pane, Pane::Help);
return Action::Continue;
}
KeyCode::Char('h') if ctrl => {
app.pane = toggle(app.pane, Pane::History);
return Action::Continue;
}
KeyCode::Char('s') if ctrl => {
app.pane = toggle(app.pane, Pane::Stats);
return Action::Continue;
}
KeyCode::Char('q') if ctrl => {
app.muted = !app.muted;
return Action::Continue;
}
_ => {}
}
if app.pane != Pane::None {
if matches!(code, KeyCode::Esc | KeyCode::Char('q')) {
app.pane = Pane::None;
}
return Action::Continue;
}
if app.shaking() {
match code {
KeyCode::Enter | KeyCode::Tab => app.throw(),
KeyCode::Esc => app.cancel_shake(),
_ => {}
}
return Action::Continue;
}
match code {
KeyCode::Esc => return Action::Quit,
KeyCode::Enter => match app.mode {
RollMode::Shake => app.start_shake(),
RollMode::Roll => app.roll(),
RollMode::Insta => app.insta_roll(),
},
KeyCode::Tab => app.mode = app.mode.next(),
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) if !ctrl => app.input.push(c),
_ => {}
}
Action::Continue
}
fn run(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> io::Result<()> {
let mut last = Instant::now();
let mut sound: Option<foley::Foley> = None;
let mut sound_tried = false;
loop {
terminal.draw(|f| ui::render(f, app))?;
if event::poll(FRAME)? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if handle_key(app, key.code, ctrl) == Action::Quit {
break;
}
}
}
let now = Instant::now();
let dt = (now - last).as_secs_f32();
last = now;
app.update(dt);
let mut clicks = 0usize;
for ev in app.take_sounds() {
if !sound_tried {
sound_tried = true;
sound = foley::Foley::new();
}
let Some(f) = &sound else { break };
if matches!(
ev,
app::SoundEvent::Impact { .. } | app::SoundEvent::Knock { .. }
) {
clicks += 1;
if clicks > MAX_CLICKS_PER_FRAME {
continue;
}
}
f.play(ev);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use app::Pane;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
assert_eq!(handle_key(app, KeyCode::Char(c), false), Action::Continue);
}
}
#[test]
fn typing_kh_reaches_the_input_and_does_not_open_a_pane() {
let mut app = App::new(String::new());
type_str(&mut app, "2d20kh1");
assert_eq!(app.input, "2d20kh1");
assert_eq!(app.pane, Pane::None, "typing must never open a pane");
let mut app2 = App::new(String::new());
type_str(&mut app2, "3d6s"); assert_eq!(app2.input, "3d6s");
assert_eq!(app2.pane, Pane::None);
}
#[test]
fn ctrl_chords_toggle_panes_without_typing() {
let mut app = App::new("3d6".to_string());
assert_eq!(
handle_key(&mut app, KeyCode::Char('h'), true),
Action::Continue
);
assert_eq!(app.pane, Pane::History);
assert_eq!(app.input, "3d6", "the chord must not type 'h'");
handle_key(&mut app, KeyCode::Char('h'), true);
assert_eq!(app.pane, Pane::None);
handle_key(&mut app, KeyCode::Char('s'), true);
assert_eq!(app.pane, Pane::Stats);
handle_key(&mut app, KeyCode::Char('?'), false);
assert_eq!(app.pane, Pane::Help);
}
#[test]
fn enter_shakes_then_throws_by_default() {
let mut app = App::new(String::new());
type_str(&mut app, "2d20kh1");
assert_eq!(
handle_key(&mut app, KeyCode::Enter, false),
Action::Continue
);
assert!(app.shaking());
assert!(app.dice.is_empty(), "shaking must not roll");
type_str(&mut app, "9d9");
assert_eq!(app.input, "2d20kh1", "input changed mid-shake");
assert_eq!(handle_key(&mut app, KeyCode::Esc, false), Action::Continue);
assert!(!app.shaking());
assert!(app.dice.is_empty(), "a cancelled shake must not roll");
handle_key(&mut app, KeyCode::Enter, false);
assert_eq!(
handle_key(&mut app, KeyCode::Enter, false),
Action::Continue
);
assert!(!app.shaking());
assert_eq!(app.dice.len(), 2, "the throw rolls the locked expression");
}
#[test]
fn tab_cycles_the_roll_mode() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap(); type_str(&mut app, "2d6");
assert_eq!(app.mode, RollMode::Shake);
assert_eq!(handle_key(&mut app, KeyCode::Tab, false), Action::Continue);
assert_eq!(app.mode, RollMode::Roll);
assert_eq!(app.input, "2d6", "Tab must not type or roll");
handle_key(&mut app, KeyCode::Enter, false);
assert!(!app.shaking(), "roll mode must not shake");
assert_eq!(app.dice.len(), 2);
assert!(!app.all_settled(), "a plain roll still animates");
handle_key(&mut app, KeyCode::Tab, false);
assert_eq!(app.mode, RollMode::Insta);
handle_key(&mut app, KeyCode::Enter, false);
assert!(!app.shaking(), "insta must not shake");
assert_eq!(app.dice.len(), 2);
assert!(app.all_settled(), "insta must land already at rest");
handle_key(&mut app, KeyCode::Tab, false);
assert_eq!(app.mode, RollMode::Shake);
}
#[test]
fn shaking_renders_the_cup_and_power_meter() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(72, 24)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
type_str(&mut app, "3d6");
handle_key(&mut app, KeyCode::Enter, false);
app.update(0.3);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let screen = flatten(&terminal);
assert!(screen.contains("shaking"), "title should say shaking");
assert!(screen.contains("╲____╱"), "the cup is missing");
assert!(screen.contains("power"), "the power meter is missing");
assert!(screen.contains("throw"), "the release hint is missing");
handle_key(&mut app, KeyCode::Enter, false);
for _ in 0..6000 {
app.update(1.0 / 60.0);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
if app.all_settled() {
break;
}
}
assert!(app.all_settled(), "thrown dice never settled on screen");
let screen = flatten(&terminal);
assert!(
!screen.contains("╲____╱"),
"the cup should be gone after the throw"
);
assert!(
screen.contains("settled"),
"the roll should finish as usual"
);
}
#[test]
fn ctrl_q_toggles_mute_everywhere() {
let mut app = App::new(String::new());
assert!(!app.muted, "sound starts on");
type_str(&mut app, "3d6");
assert_eq!(
handle_key(&mut app, KeyCode::Char('q'), true),
Action::Continue
);
assert!(app.muted);
assert_eq!(app.input, "3d6", "the chord must not type 'q'");
app.pane = Pane::Stats;
handle_key(&mut app, KeyCode::Char('q'), true);
assert!(!app.muted);
assert_eq!(app.pane, Pane::Stats);
app.pane = Pane::None;
handle_key(&mut app, KeyCode::Enter, false);
assert!(app.shaking());
handle_key(&mut app, KeyCode::Char('q'), true);
assert!(app.muted);
assert!(app.shaking(), "muting must not disturb the shake");
app.cancel_shake();
type_str(&mut app, "q");
assert_eq!(app.input, "3d6q");
}
#[test]
fn a_staked_roll_renders_its_verdict() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(72, 24)).unwrap();
roll_to_settle(&mut app, "2d20kh1 vs 12", &mut terminal);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let screen = flatten(&terminal);
assert!(screen.contains("vs 12"), "the target is not on screen");
let (success, _) = app.verdict().expect("settled staked roll has a verdict");
if success {
assert!(
screen.contains("SUCCESS"),
"verdict chip missing:\n{screen}"
);
} else {
assert!(screen.contains("FAIL"), "verdict chip missing:\n{screen}");
}
}
#[test]
fn a_tiny_terminal_skips_the_cup_rather_than_breaking_the_border() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(9, 12)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
type_str(&mut app, "d6");
handle_key(&mut app, KeyCode::Enter, false);
app.update(0.3);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let screen = flatten(&terminal);
assert!(
!screen.contains("╲____╱"),
"the cup must not squeeze into 7 cells"
);
assert!(!screen.contains("power"), "nor the meter");
let buf = terminal.backend().buffer();
for y in 1..5u16 {
assert_eq!(
buf[(0, y)].symbol(),
"│",
"left border corrupted at row {y}"
);
}
}
#[test]
fn enter_with_a_bad_expression_errors_instead_of_shaking() {
let mut app = App::new(String::new());
type_str(&mut app, "garbage");
handle_key(&mut app, KeyCode::Enter, false);
assert!(!app.shaking());
assert!(app.error.is_some(), "the typo surfaces at pickup");
type_str(&mut app, "x");
assert_eq!(app.input, "garbagex");
}
#[test]
fn esc_closes_a_pane_then_quits() {
let mut app = App::new("3d6".to_string());
app.pane = Pane::History;
assert_eq!(handle_key(&mut app, KeyCode::Esc, false), Action::Continue);
assert_eq!(app.pane, Pane::None);
assert_eq!(handle_key(&mut app, KeyCode::Esc, false), Action::Quit);
}
#[test]
fn keys_are_swallowed_while_a_pane_is_open() {
let mut app = App::new(String::new());
app.pane = Pane::Stats;
type_str(&mut app, "9d9");
assert_eq!(app.input, "", "input changed while a pane was open");
assert_eq!(app.pane, Pane::Stats);
}
fn flatten(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
#[test]
fn renders_through_a_full_roll_without_panicking() {
let mut app = App::new("3d6".to_string());
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
for _ in 0..6000 {
app.update(1.0 / 60.0);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
if app.all_settled() {
break;
}
}
assert!(app.all_settled(), "dice did not settle within the budget");
let screen = flatten(&terminal);
assert!(screen.contains("tinhorn"), "missing title");
assert!(screen.contains("total"), "missing total label");
assert!(screen.contains("┌────┐"), "no die boxes rendered");
}
#[test]
#[ignore]
fn snapshot() {
let expr = std::env::var("SNAP").unwrap_or_else(|_| "d4+d6+d8+d10+d12+d20".to_string());
let mut app = App::new(expr);
let mut terminal = Terminal::new(TestBackend::new(72, 18)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
for _ in 0..40000 {
app.update(1.0 / 60.0);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
if app.all_settled() {
break;
}
}
let buf = terminal.backend().buffer();
let area = buf.area();
eprintln!();
for y in 0..area.height {
let mut line = String::new();
for x in 0..area.width {
line.push_str(buf[(x, y)].symbol());
}
eprintln!("{line}");
}
}
#[test]
#[ignore]
fn record_demo() {
use ratatui::style::Color;
use serde_json::json;
const W: u16 = 64;
const H: u16 = 20;
fn color_code(c: Color) -> char {
match c {
Color::Black => 'k',
Color::Red => 'r',
Color::Green => 'g',
Color::Yellow => 'y',
Color::Blue => 'b',
Color::Magenta => 'm',
Color::Cyan => 'c',
Color::Gray => 'a',
Color::DarkGray => 'd',
Color::LightRed => 'R',
Color::LightGreen => 'G',
Color::LightYellow => 'Y',
Color::LightBlue => 'B',
Color::LightMagenta => 'M',
Color::LightCyan => 'C',
Color::White => 'w',
_ => '.',
}
}
fn snap(terminal: &Terminal<TestBackend>) -> serde_json::Value {
let buf = terminal.backend().buffer();
let area = *buf.area();
let mut text = Vec::new();
let mut fg = Vec::new();
let mut bg = Vec::new();
for y in 0..area.height {
let (mut t, mut f, mut b) = (String::new(), String::new(), String::new());
let mut skip = false;
for x in 0..area.width {
let cell = &buf[(x, y)];
if skip {
skip = false;
continue;
}
let sym = cell.symbol();
skip = sym.chars().next().is_some_and(|c| c == '🎲');
t.push_str(if sym.is_empty() { " " } else { sym });
f.push(color_code(cell.fg));
b.push(color_code(cell.bg));
}
text.push(t);
fg.push(f);
bg.push(b);
}
json!({ "x": text, "f": fg, "b": bg })
}
fn sound_json(ev: &app::SoundEvent) -> serde_json::Value {
use app::SoundEvent as E;
match *ev {
E::Impact { sides, speed } => json!(["impact", sides, speed]),
E::Knock { sides, speed } => json!(["knock", sides, speed]),
E::Settle { sides } => json!(["settle", sides]),
E::Rattle { power } => json!(["rattle", power]),
E::Throw { power } => json!(["throw", power]),
E::Crit => json!(["crit"]),
E::Fumble => json!(["fumble"]),
E::Success => json!(["success"]),
E::Failure => json!(["failure"]),
}
}
fn record(expr: &str, seed: u64, shake_secs: f32) -> serde_json::Value {
let mut app = App::with_seed(String::new(), seed);
let mut terminal = Terminal::new(TestBackend::new(W, H)).unwrap();
let mut frames = Vec::new();
let mut n = 0usize;
let mut step = |app: &mut App,
terminal: &mut Terminal<TestBackend>,
frames: &mut Vec<serde_json::Value>| {
app.update(1.0 / 60.0);
terminal.draw(|f| ui::render(f, app)).unwrap();
if n.is_multiple_of(2) {
let sounds: Vec<serde_json::Value> =
app.sounds.drain(..).map(|e| sound_json(&e)).collect();
let mut frame = snap(terminal);
frame["s"] = json!(sounds);
frames.push(frame);
}
n += 1;
};
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
for _ in 0..10 {
step(&mut app, &mut terminal, &mut frames);
}
for c in expr.chars() {
handle_key(&mut app, KeyCode::Char(c), false);
for _ in 0..4 {
step(&mut app, &mut terminal, &mut frames);
}
}
for _ in 0..12 {
step(&mut app, &mut terminal, &mut frames);
}
handle_key(&mut app, KeyCode::Enter, false);
for _ in 0..(shake_secs * 60.0) as usize {
step(&mut app, &mut terminal, &mut frames);
}
handle_key(&mut app, KeyCode::Enter, false);
for _ in 0..1500 {
step(&mut app, &mut terminal, &mut frames);
if app.all_settled() {
break;
}
}
assert!(app.all_settled(), "demo roll never settled");
for _ in 0..50 {
step(&mut app, &mut terminal, &mut frames);
}
json!({ "w": W, "h": H, "fps": 30, "expr": expr, "frames": frames })
}
let find_seed = |expr: &str, wanted: fn(&[u32]) -> bool| -> u64 {
(0..)
.find(|&s| {
let mut probe = App::with_seed(String::new(), s);
probe.input = expr.into();
probe.roll();
let vals: Vec<u32> = probe.dice.iter().map(|d| d.final_value).collect();
wanted(&vals)
})
.unwrap()
};
let seed = find_seed("2d20kh1 vs 15", |vals| {
vals.iter().max() == Some(&20) && vals.iter().min().is_some_and(|&v| v <= 8)
});
let fail_seed = find_seed("d20+2 vs 15", |vals| (3..=9).contains(&vals[0]));
let out = json!({
"rocket": record("2d20kh1 vs 15", seed, 0.78),
"fail": record("d20+2 vs 15", fail_seed, 1.05),
"lob": record("4d6", 7, 1.5),
});
let path = std::env::var("DEMO_OUT").expect("set DEMO_OUT=<path> for the frame dump");
std::fs::write(&path, serde_json::to_vec(&out).unwrap()).unwrap();
eprintln!("wrote {path} (rocket seed {seed})");
}
fn roll_to_settle(app: &mut App, expr: &str, terminal: &mut Terminal<TestBackend>) {
app.input = expr.to_string();
app.roll();
terminal.draw(|f| ui::render(f, app)).unwrap();
for _ in 0..6000 {
app.update(1.0 / 60.0);
if app.all_settled() {
break;
}
}
}
#[test]
fn history_pane_lists_recent_rolls() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(72, 28)).unwrap();
app.pane = app::Pane::History;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(flatten(&terminal).contains("no rolls yet"));
app.pane = app::Pane::None;
roll_to_settle(&mut app, "3d6", &mut terminal);
roll_to_settle(&mut app, "d20", &mut terminal);
app.pane = app::Pane::History;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let screen = flatten(&terminal);
assert!(screen.contains("history"), "history title missing");
assert!(screen.contains("3d6"), "first roll not listed");
assert!(screen.contains("d20"), "second roll not listed");
}
#[test]
fn stats_pane_shows_odds_and_session_summary() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(72, 28)).unwrap();
roll_to_settle(&mut app, "3d6", &mut terminal);
app.pane = app::Pane::Stats;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let screen = flatten(&terminal);
assert!(screen.contains("statistics"), "stats title missing");
assert!(screen.contains("Odds for"), "odds header missing");
assert!(screen.contains("min 3"), "min not shown for 3d6");
assert!(screen.contains("max 18"), "max not shown for 3d6");
assert!(screen.contains("This session"), "session summary missing");
}
#[test]
fn panes_are_mutually_exclusive() {
let mut app = App::new("3d6".to_string());
let mut terminal = Terminal::new(TestBackend::new(72, 28)).unwrap();
for pane in [app::Pane::Help, app::Pane::History, app::Pane::Stats] {
app.pane = pane;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let s = flatten(&terminal);
let titles = ["dice notation", "🎲 history", "🎲 statistics"];
let showing = titles.iter().filter(|t| s.contains(**t)).count();
assert_eq!(
showing, 1,
"exactly one pane should be visible for {pane:?}"
);
}
}
#[test]
fn advantage_renders_the_dropped_die_dimmed() {
use ratatui::style::Color;
let mut app = App::new("2d20kh1".to_string());
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
for _ in 0..6000 {
app.update(1.0 / 60.0);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
if app.all_settled() {
break;
}
}
assert!(app.all_settled());
let dropped = app.dice.iter().filter(|d| !d.kept).count();
assert_eq!(dropped, 1, "advantage should drop exactly one die");
let dropped_val = app.dice.iter().find(|d| !d.kept).unwrap().final_value;
let first_digit = dropped_val.to_string().chars().next().unwrap().to_string();
let buf = terminal.backend().buffer();
let has_dimmed_face = buf
.content()
.iter()
.any(|c| c.fg == Color::DarkGray && c.symbol() == first_digit);
assert!(
has_dimmed_face,
"dropped die's face was not rendered in its dimmed colour"
);
}
#[test]
fn help_overlay_shows_the_notation_when_toggled() {
let mut app = App::new("3d6".to_string());
let mut terminal = Terminal::new(TestBackend::new(72, 28)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(!flatten(&terminal).contains("dice notation"));
app.pane = app::Pane::Help;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let screen = flatten(&terminal);
assert!(screen.contains("dice notation"), "help title missing");
assert!(screen.contains("advantage"), "keep/drop section missing");
assert!(screen.contains("explode"), "exploding section missing");
assert!(screen.contains("The Throw"), "Throw section missing");
assert!(screen.contains("to close"), "dismiss hint missing");
app.pane = app::Pane::None;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(!flatten(&terminal).contains("dice notation"));
}
#[test]
fn help_overlay_fits_a_short_terminal_without_panicking() {
let mut app = App::new("3d6".to_string());
app.pane = app::Pane::Help;
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(
flatten(&terminal).contains("notation"),
"panel did not render"
);
}
#[test]
fn bad_input_shows_an_error_not_a_crash() {
let mut app = App::new(String::new());
app.input = "nonsense".to_string();
app.roll();
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(flatten(&terminal).contains("⚠"), "error not surfaced");
}
}