From 92383cee0e426cd97ee61ab17eac0f96e587ca09 Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 14:49:42 +0000 Subject: [PATCH] Split DiceServ command out of lib.rs The one command handler (ROLL/EXROLL/CALC/EXCALC all route to it) and its formatting helpers move to roll.rs; lib.rs keeps the dispatcher, the expression-evaluator module, and its tests. No behaviour change. --- modules/diceserv/src/lib.rs | 83 +++++------------------------------- modules/diceserv/src/roll.rs | 70 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 73 deletions(-) create mode 100644 modules/diceserv/src/roll.rs diff --git a/modules/diceserv/src/lib.rs b/modules/diceserv/src/lib.rs index 26e0351..a8dccdc 100644 --- a/modules/diceserv/src/lib.rs +++ b/modules/diceserv/src/lib.rs @@ -2,13 +2,16 @@ //! IRC. Stateless — a command parses an expression, rolls, and replies. ROLL/ //! CALC give the result (ROLL rounds to a whole number, CALC keeps decimals); //! the EX variants also show each die. `N~expr` repeats a roll N times. +//! +//! `lib.rs` holds the dispatcher; the command lives in roll.rs and the +//! expression evaluator in expr.rs. use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; #[path = "expr.rs"] mod expr; - -const MAX_REPEATS: u32 = 25; +#[path = "roll.rs"] +mod roll; pub struct DiceServ { pub uid: String, @@ -28,82 +31,16 @@ impl Service for DiceServ { fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, _db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { - Some("ROLL") => roll(me, from, &args[1..], ctx, false, true), - Some("EXROLL") => roll(me, from, &args[1..], ctx, true, true), - Some("CALC") => roll(me, from, &args[1..], ctx, false, false), - Some("EXCALC") => roll(me, from, &args[1..], ctx, true, false), - Some("HELP") | None => help(me, from, ctx), + Some("ROLL") => roll::handle(me, from, &args[1..], ctx, false, true), + Some("EXROLL") => roll::handle(me, from, &args[1..], ctx, true, true), + Some("CALC") => roll::handle(me, from, &args[1..], ctx, false, false), + Some("EXCALC") => roll::handle(me, from, &args[1..], ctx, true, false), + Some("HELP") | None => ctx.notice(me, from.uid, "DiceServ rolls dice and does maths. \x02ROLL\x02 (whole-number result), \x02CALC\x02 (keeps decimals), \x02EXROLL\x02/\x02EXCALC\x02 also show each die. Dice: \x022d6\x02, \x02d20\x02, \x02d%\x02. Also + - * / ^ %, parentheses, functions (sqrt, floor, min, …), \x02pi\x02/\x02e\x02, and \x023~2d6\x02 to repeat."), Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02ROLL\x02, \x02CALC\x02, or \x02HELP\x02.")), } } } -fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { - ctx.notice(me, from.uid, "DiceServ rolls dice and does maths. \x02ROLL\x02 (whole-number result), \x02CALC\x02 (keeps decimals), \x02EXROLL\x02/\x02EXCALC\x02 also show each die. Dice: \x022d6\x02, \x02d20\x02, \x02d%\x02. Also + - * / ^ %, parentheses, functions (sqrt, floor, min, …), \x02pi\x02/\x02e\x02, and \x023~2d6\x02 to repeat."); -} - -fn roll(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, extended: bool, round_result: bool) { - let input = rest.join(" "); - let input = input.trim(); - if input.is_empty() { - ctx.notice(me, from.uid, "Give me something to roll, e.g. \x022d6+3\x02."); - return; - } - // `N~expr` repeats the roll N times. - let (times, body) = match input.split_once('~') { - Some((n, body)) => match n.trim().parse::() { - Ok(t) if (1..=MAX_REPEATS).contains(&t) => (t, body.trim()), - _ => { - ctx.notice(me, from.uid, format!("The repeat count before \x02~\x02 must be a number from 1 to {MAX_REPEATS}.")); - return; - } - }, - None => (1, input), - }; - - let mut rng = rand::thread_rng(); - for i in 0..times { - match expr::evaluate(body, &mut rng) { - Ok(ev) => { - let result = format_value(ev.value, round_result); - let prefix = if times > 1 { format!("[{}/{}] ", i + 1, times) } else { String::new() }; - let detail = if extended && !ev.rolls.is_empty() { - format!(" — {}", ev.rolls.iter().map(render_roll).collect::>().join(", ")) - } else { - String::new() - }; - ctx.notice(me, from.uid, format!("{prefix}\x02{body}\x02 = \x02{result}\x02{detail}")); - } - Err(why) => { - ctx.notice(me, from.uid, format!("I couldn't roll \x02{body}\x02: {why}.")); - return; // the whole batch shares the expression, so it'll fail the same way - } - } - } -} - -// "2d6: 4+5=9" for the extended output. -fn render_roll(r: &expr::Roll) -> String { - if r.values.len() == 1 { - format!("{}: {}", r.spec, r.total) - } else { - let each = r.values.iter().map(u64::to_string).collect::>().join("+"); - format!("{}: {}={}", r.spec, each, r.total) - } -} - -// ROLL rounds to a whole number; CALC keeps up to four decimals, trimmed. -fn format_value(v: f64, round_result: bool) -> String { - if round_result { - return format!("{}", v.round() as i64); - } - if v.fract() == 0.0 && v.abs() < 1e15 { - return format!("{}", v as i64); - } - let s = format!("{v:.4}"); - s.trim_end_matches('0').trim_end_matches('.').to_string() -} - #[cfg(test)] mod tests { use super::expr::evaluate; diff --git a/modules/diceserv/src/roll.rs b/modules/diceserv/src/roll.rs new file mode 100644 index 0000000..238e7c0 --- /dev/null +++ b/modules/diceserv/src/roll.rs @@ -0,0 +1,70 @@ +use fedserv_api::{Sender, ServiceCtx}; + +use super::expr; + +// `N~expr` may repeat a roll at most this many times. +const MAX_REPEATS: u32 = 25; + +// ROLL/EXROLL/CALC/EXCALC : evaluate a dice/math expression and reply. +// `round_result` gives a whole number (ROLL); `extended` also shows each die. +pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, extended: bool, round_result: bool) { + let input = rest.join(" "); + let input = input.trim(); + if input.is_empty() { + ctx.notice(me, from.uid, "Give me something to roll, e.g. \x022d6+3\x02."); + return; + } + // `N~expr` repeats the roll N times. + let (times, body) = match input.split_once('~') { + Some((n, body)) => match n.trim().parse::() { + Ok(t) if (1..=MAX_REPEATS).contains(&t) => (t, body.trim()), + _ => { + ctx.notice(me, from.uid, format!("The repeat count before \x02~\x02 must be a number from 1 to {MAX_REPEATS}.")); + return; + } + }, + None => (1, input), + }; + + let mut rng = rand::thread_rng(); + for i in 0..times { + match expr::evaluate(body, &mut rng) { + Ok(ev) => { + let result = format_value(ev.value, round_result); + let prefix = if times > 1 { format!("[{}/{}] ", i + 1, times) } else { String::new() }; + let detail = if extended && !ev.rolls.is_empty() { + format!(" — {}", ev.rolls.iter().map(render_roll).collect::>().join(", ")) + } else { + String::new() + }; + ctx.notice(me, from.uid, format!("{prefix}\x02{body}\x02 = \x02{result}\x02{detail}")); + } + Err(why) => { + ctx.notice(me, from.uid, format!("I couldn't roll \x02{body}\x02: {why}.")); + return; // the whole batch shares the expression, so it'll fail the same way + } + } + } +} + +// "2d6: 4+5=9" for the extended output. +fn render_roll(r: &expr::Roll) -> String { + if r.values.len() == 1 { + format!("{}: {}", r.spec, r.total) + } else { + let each = r.values.iter().map(u64::to_string).collect::>().join("+"); + format!("{}: {}={}", r.spec, each, r.total) + } +} + +// ROLL rounds to a whole number; CALC keeps up to four decimals, trimmed. +fn format_value(v: f64, round_result: bool) -> String { + if round_result { + return format!("{}", v.round() as i64); + } + if v.fract() == 0.0 && v.abs() < 1e15 { + return format!("{}", v as i64); + } + let s = format!("{v:.4}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +}