diff --git a/Cargo.lock b/Cargo.lock index 4344468..76dea2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,6 +328,7 @@ dependencies = [ "fedserv-api", "fedserv-botserv", "fedserv-chanserv", + "fedserv-diceserv", "fedserv-example", "fedserv-hostserv", "fedserv-inspircd", @@ -372,6 +373,14 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-diceserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", + "rand", +] + [[package]] name = "fedserv-example" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 81c7558..ebf73fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv"] [package] name = "fedserv" @@ -18,6 +18,7 @@ fedserv-memoserv = { path = "memoserv" } fedserv-statserv = { path = "statserv" } fedserv-hostserv = { path = "hostserv" } fedserv-operserv = { path = "operserv" } +fedserv-diceserv = { path = "diceserv" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/diceserv/Cargo.toml b/diceserv/Cargo.toml new file mode 100644 index 0000000..e91705e --- /dev/null +++ b/diceserv/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fedserv-diceserv" +version = "0.0.1" +edition = "2021" +description = "DiceServ: a dice-roll and math-expression service for tabletop games over IRC." + +[dependencies] +fedserv-api = { path = "../api" } +rand = "0.8" diff --git a/diceserv/src/expr.rs b/diceserv/src/expr.rs new file mode 100644 index 0000000..52b2444 --- /dev/null +++ b/diceserv/src/expr.rs @@ -0,0 +1,348 @@ +//! A dice-and-math expression evaluator: tokenise, parse (recursive descent), +//! then evaluate — rolling dice as it goes and recording each roll for the +//! extended output. The reference ships its own Mersenne-Twister in C and a +//! shunting-yard parser with a variadic-argument hack; this is a plain typed AST +//! + the OS/`rand` generator, which is shorter and far easier to reason about. + +use rand::Rng; + +// Guardrails so a single expression can't ask us to roll forever. +const MAX_DICE: u64 = 99_999; // dice in one NdM roll +const MAX_SIDES: u64 = 99_999; // sides on a die +const MAX_TOTAL: u64 = 1_000_000; // dice rolled across the whole expression + +// One resolved dice roll, kept for the extended (EX) output. +pub struct Roll { + pub spec: String, // e.g. "2d6" + pub values: Vec, // each die's face + pub total: u64, +} + +pub struct Evaluated { + pub value: f64, + pub rolls: Vec, +} + +// ---- tokens ---------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +enum Tok { + Num(f64), + Ident(String), // includes the dice operator "d"/"D", constants, function names + Op(char), // + - * / % ^ + LParen, + RParen, + Comma, +} + +fn tokenize(input: &str) -> Result, String> { + let chars: Vec = input.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + } else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + out.push(Tok::Num(s.parse().map_err(|_| format!("bad number '{s}'"))?)); + } else if c.is_ascii_alphabetic() { + let start = i; + while i < chars.len() && chars[i].is_ascii_alphabetic() { + i += 1; + } + out.push(Tok::Ident(chars[start..i].iter().collect())); + } else { + match c { + '+' | '-' | '*' | '/' | '%' | '^' => out.push(Tok::Op(c)), + '(' | '[' | '{' => out.push(Tok::LParen), + ')' | ']' | '}' => out.push(Tok::RParen), + ',' => out.push(Tok::Comma), + _ => return Err(format!("unexpected character '{c}'")), + } + i += 1; + } + } + Ok(out) +} + +// ---- AST ------------------------------------------------------------------- + +enum Ast { + Num(f64), + Neg(Box), + Bin(char, Box, Box), + Dice(Box, Box), + Func(String, Vec), +} + +struct Parser { + toks: Vec, + pos: usize, +} + +impl Parser { + fn peek(&self) -> Option<&Tok> { + self.toks.get(self.pos) + } + fn is_dice(&self) -> bool { + matches!(self.peek(), Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("d")) + } + + fn parse(mut self) -> Result { + let e = self.add()?; + if self.pos != self.toks.len() { + return Err("trailing characters in expression".to_string()); + } + Ok(e) + } + + fn add(&mut self) -> Result { + let mut left = self.mul()?; + while let Some(Tok::Op(c @ ('+' | '-'))) = self.peek() { + let c = *c; + self.pos += 1; + left = Ast::Bin(c, Box::new(left), Box::new(self.mul()?)); + } + Ok(left) + } + + fn mul(&mut self) -> Result { + let mut left = self.unary()?; + loop { + if let Some(Tok::Op(c @ ('*' | '/' | '%'))) = self.peek() { + let c = *c; + self.pos += 1; + left = Ast::Bin(c, Box::new(left), Box::new(self.unary()?)); + } else if self.starts_atom() && !self.is_dice() { + // Implicit multiplication: 2pi, 3(4), 2d6 2. + left = Ast::Bin('*', Box::new(left), Box::new(self.unary()?)); + } else { + return Ok(left); + } + } + } + + // Whether the next token can begin an atom (for implicit multiplication). + fn starts_atom(&self) -> bool { + matches!(self.peek(), Some(Tok::Num(_) | Tok::Ident(_) | Tok::LParen)) + } + + fn unary(&mut self) -> Result { + match self.peek() { + Some(Tok::Op('-')) => { + self.pos += 1; + Ok(Ast::Neg(Box::new(self.unary()?))) + } + Some(Tok::Op('+')) => { + self.pos += 1; + self.unary() + } + _ => self.pow(), + } + } + + fn pow(&mut self) -> Result { + let base = self.dice()?; + if matches!(self.peek(), Some(Tok::Op('^'))) { + self.pos += 1; + // Right-associative, and the exponent may be signed (2^-3). + return Ok(Ast::Bin('^', Box::new(base), Box::new(self.unary()?))); + } + Ok(base) + } + + fn dice(&mut self) -> Result { + // A leading `d` means one die: d6 == 1d6. + let mut left = if self.is_dice() { Ast::Num(1.0) } else { self.atom()? }; + while self.is_dice() { + self.pos += 1; + // `d%` is percentile — 100 sides. + let sides = if matches!(self.peek(), Some(Tok::Op('%'))) { + self.pos += 1; + Ast::Num(100.0) + } else { + self.atom()? + }; + left = Ast::Dice(Box::new(left), Box::new(sides)); + } + Ok(left) + } + + fn atom(&mut self) -> Result { + match self.peek().cloned() { + Some(Tok::Num(n)) => { + self.pos += 1; + Ok(Ast::Num(n)) + } + Some(Tok::LParen) => { + self.pos += 1; + let e = self.add()?; + if !matches!(self.peek(), Some(Tok::RParen)) { + return Err("missing closing parenthesis".to_string()); + } + self.pos += 1; + Ok(e) + } + Some(Tok::Ident(name)) => { + let lname = name.to_ascii_lowercase(); + self.pos += 1; + match lname.as_str() { + "e" => Ok(Ast::Num(std::f64::consts::E)), + "pi" => Ok(Ast::Num(std::f64::consts::PI)), + _ if matches!(self.peek(), Some(Tok::LParen)) => { + self.pos += 1; + let mut args = vec![self.add()?]; + while matches!(self.peek(), Some(Tok::Comma)) { + self.pos += 1; + args.push(self.add()?); + } + if !matches!(self.peek(), Some(Tok::RParen)) { + return Err(format!("missing ) after {lname}(...)")); + } + self.pos += 1; + Ok(Ast::Func(lname, args)) + } + _ => Err(format!("unknown name '{name}'")), + } + } + _ => Err("expected a value".to_string()), + } + } +} + +// ---- evaluation ------------------------------------------------------------ + +// Evaluate a single expression (no repeat prefix), rolling dice via `rng`. +pub fn evaluate(input: &str, rng: &mut impl Rng) -> Result { + let toks = tokenize(input)?; + if toks.is_empty() { + return Err("nothing to roll".to_string()); + } + let ast = Parser { toks, pos: 0 }.parse()?; + let mut rolls = Vec::new(); + let mut total_dice = 0u64; + let value = eval(&ast, rng, &mut rolls, &mut total_dice)?; + if !value.is_finite() { + return Err("that doesn't come out to a real number".to_string()); + } + Ok(Evaluated { value, rolls }) +} + +fn eval(ast: &Ast, rng: &mut impl Rng, rolls: &mut Vec, total: &mut u64) -> Result { + match ast { + Ast::Num(n) => Ok(*n), + Ast::Neg(a) => Ok(-eval(a, rng, rolls, total)?), + Ast::Bin(op, a, b) => { + let (x, y) = (eval(a, rng, rolls, total)?, eval(b, rng, rolls, total)?); + Ok(match op { + '+' => x + y, + '-' => x - y, + '*' => x * y, + '/' => { + if y == 0.0 { + return Err("division by zero".to_string()); + } + x / y + } + '%' => { + if y == 0.0 { + return Err("modulo by zero".to_string()); + } + x % y + } + '^' => x.powf(y), + _ => unreachable!(), + }) + } + Ast::Dice(n, m) => { + let num = round_pos(eval(n, rng, rolls, total)?, "number of dice")?; + let sides = round_pos(eval(m, rng, rolls, total)?, "sides")?; + if num > MAX_DICE { + return Err(format!("that's more than {MAX_DICE} dice")); + } + if sides > MAX_SIDES { + return Err(format!("a die can't have more than {MAX_SIDES} sides")); + } + *total += num; + if *total > MAX_TOTAL { + return Err("that expression rolls too many dice".to_string()); + } + let mut values = Vec::with_capacity(num as usize); + let mut sum = 0u64; + for _ in 0..num { + let face = rng.gen_range(1..=sides); + values.push(face); + sum += face; + } + rolls.push(Roll { spec: format!("{num}d{sides}"), values, total: sum }); + Ok(sum as f64) + } + Ast::Func(name, args) => call(name, args, rng, rolls, total), + } +} + +// Round a value to a positive integer count (dice / sides must be >= 1). +fn round_pos(v: f64, what: &str) -> Result { + if !v.is_finite() { + return Err(format!("the {what} isn't a number")); + } + let n = v.round(); + if n < 1.0 { + return Err(format!("the {what} must be at least 1")); + } + Ok(n as u64) +} + +fn call(name: &str, args: &[Ast], rng: &mut impl Rng, rolls: &mut Vec, total: &mut u64) -> Result { + let mut v = Vec::with_capacity(args.len()); + for a in args { + v.push(eval(a, rng, rolls, total)?); + } + let one = |v: &[f64]| -> Result { + match v { + [x] => Ok(*x), + _ => Err(format!("{name}() takes one argument")), + } + }; + Ok(match name { + "abs" => one(&v)?.abs(), + "ceil" => one(&v)?.ceil(), + "floor" => one(&v)?.floor(), + "round" => one(&v)?.round(), + "trunc" | "int" => one(&v)?.trunc(), + "sqrt" => one(&v)?.sqrt(), + "cbrt" => one(&v)?.cbrt(), + "exp" => one(&v)?.exp(), + "ln" | "log" => one(&v)?.ln(), + "log10" => one(&v)?.log10(), + "log2" => one(&v)?.log2(), + "sin" => one(&v)?.sin(), + "cos" => one(&v)?.cos(), + "tan" => one(&v)?.tan(), + "asin" => one(&v)?.asin(), + "acos" => one(&v)?.acos(), + "atan" => one(&v)?.atan(), + "sinh" => one(&v)?.sinh(), + "cosh" => one(&v)?.cosh(), + "tanh" => one(&v)?.tanh(), + "deg" => one(&v)?.to_degrees(), + "rad" => one(&v)?.to_radians(), + "sign" => one(&v)?.signum(), + "min" if !v.is_empty() => v.into_iter().fold(f64::INFINITY, f64::min), + "max" if !v.is_empty() => v.into_iter().fold(f64::NEG_INFINITY, f64::max), + "hypot" => match v.as_slice() { + [a, b] => a.hypot(*b), + _ => return Err("hypot() takes two arguments".to_string()), + }, + "pow" => match v.as_slice() { + [a, b] => a.powf(*b), + _ => return Err("pow() takes two arguments".to_string()), + }, + _ => return Err(format!("unknown function '{name}'")), + }) +} diff --git a/diceserv/src/lib.rs b/diceserv/src/lib.rs new file mode 100644 index 0000000..26e0351 --- /dev/null +++ b/diceserv/src/lib.rs @@ -0,0 +1,154 @@ +//! DiceServ rolls dice and evaluates math expressions for tabletop games over +//! 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. + +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; + +#[path = "expr.rs"] +mod expr; + +const MAX_REPEATS: u32 = 25; + +pub struct DiceServ { + pub uid: String, +} + +impl Service for DiceServ { + fn nick(&self) -> &str { + "DiceServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Dice Roller" + } + + 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(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; + use rand::rngs::StdRng; + use rand::SeedableRng; + + fn eval(s: &str) -> f64 { + let mut rng = StdRng::seed_from_u64(1); + evaluate(s, &mut rng).unwrap().value + } + + #[test] + fn arithmetic_and_precedence() { + assert_eq!(eval("2+3*4"), 14.0); + assert_eq!(eval("(2+3)*4"), 20.0); + assert_eq!(eval("2^3^2"), 512.0); // right-associative + assert_eq!(eval("-2^2"), -4.0); // unary looser than ^ + assert_eq!(eval("10%3"), 1.0); + assert_eq!(eval("2pi/pi"), 2.0); // implicit multiplication + constant + assert_eq!(eval("sqrt(16)+floor(3.9)"), 7.0); + assert_eq!(eval("min(5,2,8)"), 2.0); + } + + #[test] + fn dice_stay_in_range() { + let mut rng = StdRng::seed_from_u64(42); + for _ in 0..200 { + let ev = evaluate("3d6", &mut rng).unwrap(); + assert!((3.0..=18.0).contains(&ev.value), "3d6 out of range: {}", ev.value); + assert_eq!(ev.rolls[0].values.len(), 3); + assert!(ev.rolls[0].values.iter().all(|&f| (1..=6).contains(&f))); + } + // A leading d is one die; percentile is 1..=100. + assert!((1.0..=20.0).contains(&evaluate("d20", &mut rng).unwrap().value)); + assert!((1.0..=100.0).contains(&evaluate("d%", &mut rng).unwrap().value)); + } + + #[test] + fn rejects_bad_input_and_abuse() { + let mut rng = StdRng::seed_from_u64(1); + assert!(evaluate("2d6+", &mut rng).is_err()); + assert!(evaluate("1/0", &mut rng).is_err()); + assert!(evaluate("999999d6", &mut rng).is_err()); // too many dice + assert!(evaluate("2d999999", &mut rng).is_err()); // too many sides + assert!(evaluate("frobnicate(2)", &mut rng).is_err()); + assert!(evaluate("2 @ 3", &mut rng).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 1b67d70..ac7c8bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ use fedserv_memoserv::MemoServ; use fedserv_statserv::StatServ; use fedserv_hostserv::HostServ; use fedserv_operserv::OperServ; +use fedserv_diceserv::DiceServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -89,6 +90,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAH", cfg.server.sid), })); } + if enabled("diceserv") { + services.push(Box::new(DiceServ { + uid: format!("{}AAAAAI", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),