mod app;
mod cli;
mod foley;
mod parse;
mod physics;
mod render3d;
mod render3d_view;
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.set_pane(toggle(app.pane, Pane::Help));
return Action::Continue;
}
KeyCode::Char('h') if ctrl => {
app.set_pane(toggle(app.pane, Pane::History));
return Action::Continue;
}
KeyCode::Char('s') if ctrl => {
app.set_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 {
match code {
KeyCode::Esc | KeyCode::Char('q') => app.set_pane(Pane::None),
KeyCode::Up => app.pane_scroll = app.pane_scroll.saturating_sub(1),
KeyCode::Down => app.pane_scroll = app.pane_scroll.saturating_add(1),
KeyCode::PageUp => app.pane_scroll = app.pane_scroll.saturating_sub(10),
KeyCode::PageDown => app.pane_scroll = app.pane_scroll.saturating_add(10),
KeyCode::Home => app.pane_scroll = 0,
KeyCode::End => app.pane_scroll = u16::MAX,
_ => {}
}
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::Left => app.cursor_left(),
KeyCode::Right => app.cursor_right(),
KeyCode::Home => app.cursor_home(),
KeyCode::End => app.cursor_end(),
KeyCode::Backspace => app.input_backspace(),
KeyCode::Delete => app.input_delete(),
KeyCode::Char(c) if !ctrl => app.input_insert(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;
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() {
let f = sound.get_or_insert_with(foley::Foley::spawn);
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();
let snapshot = |t: &Terminal<TestBackend>| -> Vec<(String, ratatui::style::Color)> {
let buf = t.backend().buffer();
(0..buf.area().width)
.flat_map(|x| (1..16u16).map(move |y| (x, y)))
.map(|(x, y)| {
let c = &buf[(x, y)];
(c.symbol().to_string(), c.fg)
})
.collect()
};
type_str(&mut app, "3d6");
handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Enter, false); terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let baseline = snapshot(&terminal);
handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Enter, false); app.update(0.3);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let shaking = snapshot(&terminal); let changed = baseline
.iter()
.zip(&shaking)
.filter(|(a, b)| a != b)
.count();
assert!(
changed > 50,
"the 3D cup didn't render (only {changed} arena cells changed)"
);
let digit_cells = |snap: &[(String, ratatui::style::Color)]| {
snap.iter()
.filter(|(s, _)| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.count()
};
assert!(
digit_cells(&baseline) > 0,
"the settled roll should show its numbers on the felt"
);
assert_eq!(
digit_cells(&shaking),
0,
"die numbers must not render over the cup while shaking"
);
let screen = flatten(&terminal);
assert!(screen.contains("shaking"), "title should say shaking");
assert!(
screen.contains('▀'),
"the 3D arena should render while shaking"
);
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("power"),
"the power meter 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_meter_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("power"), "the meter must not squeeze in");
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);
}
#[test]
fn arrows_move_the_caret_and_edits_land_at_it() {
let mut app = App::new(String::new());
type_str(&mut app, "3d6");
assert_eq!(app.cursor, 3);
handle_key(&mut app, KeyCode::Left, false);
assert_eq!(app.cursor, 2);
handle_key(&mut app, KeyCode::Char('0'), false);
assert_eq!(app.input, "3d06", "the digit lands under the caret");
assert_eq!(app.cursor, 3);
handle_key(&mut app, KeyCode::Backspace, false);
assert_eq!(app.input, "3d6");
assert_eq!(app.cursor, 2);
handle_key(&mut app, KeyCode::Delete, false);
assert_eq!(app.input, "3d");
assert_eq!(app.cursor, 2);
handle_key(&mut app, KeyCode::Home, false);
assert_eq!(app.cursor, 0);
handle_key(&mut app, KeyCode::Char('+'), false);
assert_eq!(app.input, "+3d");
handle_key(&mut app, KeyCode::End, false);
assert_eq!(app.cursor, app.input.len());
handle_key(&mut app, KeyCode::Right, false);
assert_eq!(app.cursor, app.input.len());
handle_key(&mut app, KeyCode::Home, false);
handle_key(&mut app, KeyCode::Left, false);
assert_eq!(app.cursor, 0);
}
#[test]
fn the_caret_renders_over_the_character_it_covers() {
use ratatui::style::Color;
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
type_str(&mut app, "3d6");
handle_key(&mut app, KeyCode::Left, false); handle_key(&mut app, KeyCode::Left, false); terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let caret: Vec<_> = buf
.content()
.iter()
.filter(|c| c.bg == Color::Cyan)
.collect();
assert_eq!(caret.len(), 1, "exactly one block caret is drawn");
assert_eq!(caret[0].symbol(), "d", "the caret covers the char under it");
}
#[test]
fn the_input_scrolls_horizontally_to_keep_the_caret_visible() {
use ratatui::style::Color;
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(24, 24)).unwrap();
type_str(&mut app, "d6+d6+d6+d6+d6+d6"); terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let cyan = terminal
.backend()
.buffer()
.content()
.iter()
.filter(|c| c.bg == Color::Cyan)
.count();
assert_eq!(cyan, 1, "the caret follows the edit point into view");
}
#[test]
fn delete_at_end_of_line_is_a_no_op() {
let mut app = App::new(String::new());
type_str(&mut app, "3d6");
handle_key(&mut app, KeyCode::End, false);
handle_key(&mut app, KeyCode::Delete, false);
assert_eq!(app.input, "3d6");
assert_eq!(app.cursor, 3);
}
#[test]
fn the_caret_survives_a_throw() {
let mut app = App::new(String::new());
type_str(&mut app, "2d20kh1");
handle_key(&mut app, KeyCode::Home, false); handle_key(&mut app, KeyCode::Enter, false); assert!(app.shaking());
handle_key(&mut app, KeyCode::Enter, false); assert!(!app.shaking());
assert_eq!(app.input, "2d20kh1");
assert!(
app.cursor <= app.input.len(),
"caret must stay within the input"
);
handle_key(&mut app, KeyCode::Char('x'), false);
assert_eq!(
app.input, "x2d20kh1",
"the next keystroke edits at the caret"
);
}
#[test]
fn page_keys_scroll_a_pane_by_a_chunk() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap();
handle_key(&mut app, KeyCode::Char('?'), false); terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert_eq!(app.pane_scroll, 0);
handle_key(&mut app, KeyCode::PageDown, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let after_pgdn = app.pane_scroll;
assert!(after_pgdn >= 6, "PageDown scrolls by a chunk, not a line");
handle_key(&mut app, KeyCode::PageUp, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(app.pane_scroll < after_pgdn, "PageUp scrolls back up");
}
#[test]
fn history_pane_scrolls_to_older_rolls() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(72, 8)).unwrap();
for expr in ["1d4", "2d4", "3d4", "4d4", "5d4", "6d4", "7d4", "8d4"] {
roll_to_settle(&mut app, expr, &mut terminal);
}
app.pane = app::Pane::History;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let top = flatten(&terminal);
assert!(top.contains("8d4"), "the newest roll shows at the top");
assert!(!top.contains("1d4"), "the oldest roll is below the fold");
handle_key(&mut app, KeyCode::End, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(
flatten(&terminal).contains("1d4"),
"scrolling reaches the oldest roll"
);
}
#[test]
fn stats_pane_scrolls_when_it_overflows() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(72, 12)).unwrap();
roll_to_settle(&mut app, "3d6", &mut terminal);
app.pane = app::Pane::Stats;
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let top = flatten(&terminal);
assert!(top.contains("Odds for"), "the header shows at the top");
assert!(
!top.contains("This session"),
"the session summary is below the fold"
);
handle_key(&mut app, KeyCode::End, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert!(
flatten(&terminal).contains("This session"),
"scrolling reveals the session summary"
);
}
#[test]
fn arrows_scroll_a_pane_taller_than_the_frame() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap();
handle_key(&mut app, KeyCode::Char('?'), false);
assert_eq!(app.pane, Pane::Help);
assert_eq!(app.pane_scroll, 0);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let top = flatten(&terminal);
assert!(top.contains("Dice"), "the first heading shows at the top");
assert!(!top.contains("to close"), "the footer is below the fold");
handle_key(&mut app, KeyCode::End, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let bottom = flatten(&terminal);
assert!(bottom.contains("to close"), "the footer scrolls into view");
assert!(!bottom.contains("Dice"), "the top heading scrolled away");
let settled = app.pane_scroll;
assert!(settled > 0, "the pane actually scrolled");
handle_key(&mut app, KeyCode::Down, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert_eq!(app.pane_scroll, settled, "scrolling stops at the bottom");
handle_key(&mut app, KeyCode::Home, false);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
assert_eq!(app.pane_scroll, 0);
assert!(
flatten(&terminal).contains("Dice"),
"Home shows the top again"
);
}
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 3D dice rendered");
}
#[test]
fn renders_the_roll_in_3d_with_burned_values() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
type_str(&mut app, "1d6");
handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Enter, false);
assert!(app.all_settled());
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let arena: Vec<(u16, u16)> = (0..buf.area().width)
.flat_map(|x| (0..14u16).map(move |y| (x, y)))
.collect();
let has_block = arena.iter().any(|&(x, y)| buf[(x, y)].symbol() == "▀");
assert!(has_block, "the 3D die should fill arena cells with '▀'");
let value = app.dice[0].final_value.to_string();
let has_value = arena.iter().any(|&(x, y)| buf[(x, y)].symbol() == value);
assert!(has_value, "the die's value should be burned onto the face");
}
#[test]
fn the_number_ducks_out_when_the_die_rolls_edge_on() {
use crate::render3d::dice;
use crate::render3d::math::{Quat, Vec3};
let faces = dice::face_geometry(6); let (_c, face_on) = ui::read_face(faces, Quat::IDENTITY, Vec3::Z);
assert!(
face_on > 0.5,
"a squarely-presented face should read clearly, got clarity {face_on}"
);
let edge = Quat::from_rotation_y(std::f32::consts::FRAC_PI_4);
let (_c, edge_on) = ui::read_face(faces, edge, Vec3::Z);
assert!(
edge_on < 0.1,
"an edge-on cube ties two faces, so clarity should be ~0, got {edge_on}"
);
}
#[test]
fn the_number_scales_with_the_die() {
assert_eq!(ui::number_scale(3.0, 2.0, 1), 0, "small die → single cell");
assert_eq!(ui::number_scale(6.0, 3.0, 1), 1);
assert_eq!(ui::number_scale(12.0, 6.0, 1), 2);
assert!(ui::number_scale(40.0, 20.0, 1) > ui::number_scale(12.0, 6.0, 1));
assert!(ui::number_scale(9.0, 6.0, 2) <= ui::number_scale(9.0, 6.0, 1));
}
#[test]
#[ignore]
fn preview_cup_3d() {
let mut app = App::new(String::new());
let mut terminal = Terminal::new(TestBackend::new(66, 22)).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.45); terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let area = buf.area();
eprintln!("\n=== shaking (3D cup) ===");
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 preview_dice_3d() {
let expr = std::env::var("SNAP").unwrap_or_else(|_| "d20+d12+d10+d8+d6+d4".to_string());
let parse_env = |k: &str, d: u16| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let (w, h) = (parse_env("W", 66), parse_env("H", 22));
let mut app = App::new(expr);
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
let felt = ratatui::style::Color::Rgb(28, 46, 40);
let dump = |terminal: &Terminal<TestBackend>, tag: &str| {
let buf = terminal.backend().buffer();
let area = buf.area();
let (mut die_cells, mut arena_cells) = (0u32, 0u32);
eprintln!("\n=== {tag} ===");
for y in 0..area.height {
let mut line = String::new();
for x in 0..area.width {
let cell = &buf[(x, y)];
let sym = cell.symbol();
if sym == "▀" || sym.chars().all(|c| c.is_ascii_digit()) {
arena_cells += 1;
let is_die = cell.fg != felt || cell.bg != felt;
if is_die {
die_cells += 1;
}
if sym.chars().all(|c| c.is_ascii_digit()) {
line.push_str(sym);
} else if is_die {
line.push('#');
} else {
line.push(' ');
}
continue;
}
line.push_str(sym);
}
eprintln!("{line}");
}
eprintln!(
"dice cover {die_cells}/{arena_cells} arena cells ({:.0}%)",
100.0 * die_cells as f32 / arena_cells.max(1) as f32
);
};
for i in 0..40000 {
app.update(1.0 / 60.0);
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
if i == 14 {
dump(&terminal, "mid-flight (physics: dice scattered)");
}
if app.all_settled() {
break;
}
}
dump(&terminal, "settled (values burned in)");
}
#[test]
#[ignore]
fn snapshot() {
let expr = std::env::var("SNAP").unwrap_or_else(|_| "d4+d6+d8+d10+d12+d20".to_string());
let dim = |k: &str, d: u16| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let mut app = App::new(expr);
let mut terminal = Terminal::new(TestBackend::new(dim("W", 72), dim("H", 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}");
}
}
fn write_ppm(terminal: &Terminal<TestBackend>, path: &str) {
use ratatui::style::Color;
fn rgb(c: Color) -> [u8; 3] {
match c {
Color::Rgb(r, g, b) => [r, g, b],
Color::Black => [0, 0, 0],
Color::White => [230, 230, 230],
Color::Red => [200, 70, 70],
Color::Green => [70, 190, 90],
Color::Yellow => [220, 200, 60],
Color::Blue => [80, 120, 230],
Color::Magenta => [200, 90, 200],
Color::Cyan => [70, 190, 200],
Color::Gray => [160, 160, 160],
Color::DarkGray => [90, 90, 90],
Color::LightGreen => [130, 230, 130],
Color::LightMagenta => [230, 130, 230],
_ => [13, 17, 23], }
}
let buf = terminal.backend().buffer();
let area = *buf.area();
let (pw, ph) = (area.width as usize, area.height as usize * 2);
let mut px = vec![0u8; pw * ph * 3];
for cy in 0..area.height {
for cx in 0..area.width {
let cell = &buf[(cx, cy)];
let (top, bot) = if cell.symbol() == "▀" {
(rgb(cell.fg), rgb(cell.bg))
} else {
(rgb(cell.fg), rgb(cell.fg))
};
for (row, color) in [(cy as usize * 2, top), (cy as usize * 2 + 1, bot)] {
let i = (row * pw + cx as usize) * 3;
px[i..i + 3].copy_from_slice(&color);
}
}
}
let mut out = format!("P6\n{pw} {ph}\n255\n").into_bytes();
out.extend_from_slice(&px);
std::fs::write(path, out).unwrap();
}
#[test]
#[ignore]
fn preview_png() {
let expr = std::env::var("SNAP").unwrap_or_else(|_| "d20+d12+d8+d6".to_string());
let parse_env = |k: &str, d: u16| {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(d)
};
let (w, h) = (parse_env("W", 96), parse_env("H", 30));
let shake = std::env::var("SHAKE").is_ok();
let mut app = match std::env::var("SEED").ok().and_then(|v| v.parse().ok()) {
Some(seed) => App::with_seed(String::new(), seed),
None => App::new(String::new()),
};
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap(); type_str(&mut app, &expr);
handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Enter, false);
if !shake && std::env::var("THROW").is_err() {
for _ in 0..90 {
app.update(1.0 / 60.0);
}
}
if shake {
handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Enter, false); app.update(0.45); }
if let Ok(n) = std::env::var("THROW").map(|v| v.parse::<u32>().unwrap_or(6)) {
let mut fresh = App::with_seed(String::new(), 7);
fresh.arena_w = app.arena_w;
fresh.arena_h = app.arena_h;
type_str(&mut fresh, &expr);
handle_key(&mut fresh, KeyCode::Tab, false); handle_key(&mut fresh, KeyCode::Enter, false); for _ in 0..n {
fresh.update(1.0 / 60.0);
}
app = fresh;
}
terminal.draw(|f| ui::render(f, &mut app)).unwrap();
write_ppm(&terminal, "/tmp/tinhorn.ppm");
eprintln!("wrote /tmp/tinhorn.ppm");
}
#[test]
#[ignore]
fn preview_designs() {
use crate::render3d::color::Rgb;
use ui::ArenaStyle;
let d = ArenaStyle::DEFAULT;
let variants: [(&str, ArenaStyle); 6] = [
("0-slate", d), (
"1-casino",
ArenaStyle {
background: Rgb(12, 22, 16),
floor: Rgb(26, 74, 46),
wall: Rgb(58, 44, 32),
key: Rgb(255, 244, 222),
fill: Rgb(80, 96, 88),
..d
},
),
(
"2-noir",
ArenaStyle {
background: Rgb(9, 9, 11),
floor: Rgb(28, 30, 34),
wall: Rgb(66, 56, 50),
key: Rgb(255, 230, 198),
fill: Rgb(64, 68, 78),
..d
},
),
(
"3-leather",
ArenaStyle {
background: Rgb(20, 15, 11),
floor: Rgb(104, 80, 52),
wall: Rgb(66, 44, 28),
key: Rgb(255, 242, 220),
fill: Rgb(96, 84, 70),
..d
},
),
(
"4-twilight",
ArenaStyle {
background: Rgb(13, 11, 22),
floor: Rgb(42, 32, 62),
wall: Rgb(70, 46, 84),
key: Rgb(232, 224, 255),
fill: Rgb(110, 104, 140),
..d
},
),
(
"5-ocean",
ArenaStyle {
background: Rgb(10, 18, 24),
floor: Rgb(30, 58, 72),
wall: Rgb(44, 64, 74),
key: Rgb(240, 248, 255),
fill: Rgb(88, 110, 120),
..d
},
),
];
for (name, style) in variants {
ui::set_arena_style(Some(style));
let mut app = App::with_seed(String::new(), 12);
let mut terminal = Terminal::new(TestBackend::new(110, 34)).unwrap();
terminal.draw(|f| ui::render(f, &mut app)).unwrap(); type_str(&mut app, "d20+d12+d8+d6");
handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Tab, false); handle_key(&mut app, KeyCode::Enter, false); terminal.draw(|f| ui::render(f, &mut app)).unwrap();
write_ppm(&terminal, &format!("/tmp/design-{name}.ppm"));
eprintln!("wrote /tmp/design-{name}.ppm");
}
ui::set_arena_style(None);
}
#[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 markers = ["dice notation", "no rolls yet", "Odds for"];
let showing = markers.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");
}
}