diff --git a/README.md b/README.md index 2dab1fd..52755f0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,53 @@ # sidekick dicebot + +This bot rolls dice and evaluates simple math expressions in Discord messages + +You interact with it by sending messages in a channel the bot can see + +Requires you to update .env with a private DISCORD_TOKEN if you want to host your own + +## Commands +- Short form: + - `/r ` + - `.r ` + - `!r ` +- Long form: + - `/roll ` + - `.roll ` + - `!roll ` + +## Expression Syntax + +Supported pieces of an expression: + +- **Dice** + - `NdM` → roll N, M-sided dice (e.g. `3d6`) + - `dM` → shorthand for `1dM` (e.g. `d20`) +- **Integers** + - Whole numbers (e.g. `5`, `-2`) +- **Operators** (integer arithmetic) + - `+` addition + - `-` subtraction + - `*` multiplication + - `/` division (truncating, **DICE DON'T ROLL DECIMALS!**) + - `^` exponent (non-negative, limited size) +- **Parentheses** + - `(` and `)` to control order of operations + +Operator precedence: `^` > `*` `/` > `+` `-`. + +## Exploding Dice (Unbound DRNs) + +You can make dice “explode” – when a die meets a condition, it rolls again and adds to that die’s total. + +- Add `!` after a dice term: + +- `NdM!` → explode on **max** (e.g. `d6!` explodes on 6) +- `NdM! Result<(), Box> { } Ok(()) } + //Take the message event and check if it fits command syntax, if it does strip the command then send the numbers to the roller async fn process_message(client: Arc>, message_content: MessageCreate) -> Result<(), Box> { // Old Client = client: &Client Old msg = msg: MessageCreate @@ -88,6 +89,311 @@ async fn process_message(client: Arc>, message_content: MessageCr } Ok(()) } + +#[derive(Clone, Debug)] +struct EvalResult { + total: i128, + repr: String, +} + +// Helper to peek a single ASCII character at a byte offset +fn peek_char(s: &str, pos: usize) -> Option { + s.as_bytes().get(pos).map(|b| *b as char) +} + +// expression := term (('+'|'-') term)* +fn parse_expr( + s: &str, + pos: &mut usize, + rng: &mut R, +) -> Result { + let mut left = parse_term(s, pos, rng)?; + loop { + match peek_char(s, *pos) { + Some('+') => { + *pos += 1; + let right = parse_term(s, pos, rng)?; + left = EvalResult { + total: left.total + right.total, + repr: format!("{}+{}", left.repr, right.repr), + }; + } + Some('-') => { + *pos += 1; + let right = parse_term(s, pos, rng)?; + left = EvalResult { + total: left.total - right.total, + repr: format!("{}-{}", left.repr, right.repr), + }; + } + _ => break, + } + } + Ok(left) +} + +// term := unary (('*'|'/') unary)* +fn parse_term( + s: &str, + pos: &mut usize, + rng: &mut R, +) -> Result { + let mut left = parse_unary(s, pos, rng)?; + loop { + match peek_char(s, *pos) { + Some('*') => { + *pos += 1; + let right = parse_unary(s, pos, rng)?; + left = EvalResult { + total: left.total * right.total, + repr: format!("{}*{}", left.repr, right.repr), + }; + } + Some('/') => { + *pos += 1; + let right = parse_unary(s, pos, rng)?; + if right.total == 0 { + return Err("Division by zero".to_string()); + } + left = EvalResult { + total: left.total / right.total, + repr: format!("{}/{}", left.repr, right.repr), + }; + } + _ => break, + } + } + Ok(left) +} + +// unary := ('+'|'-') unary | power +fn parse_unary( + s: &str, + pos: &mut usize, + rng: &mut R, +) -> Result { + match peek_char(s, *pos) { + Some('+') => { + *pos += 1; + parse_unary(s, pos, rng) + } + Some('-') => { + *pos += 1; + let mut value = parse_unary(s, pos, rng)?; + value.total = -value.total; + value.repr = format!("-{}", value.repr); + Ok(value) + } + _ => parse_power(s, pos, rng), + } +} + +// power := primary ('^' power)? // right-associative +fn parse_power( + s: &str, + pos: &mut usize, + rng: &mut R, +) -> Result { + let mut left = parse_primary(s, pos, rng)?; + while let Some('^') = peek_char(s, *pos) { + *pos += 1; + let right = parse_power(s, pos, rng)?; + if right.total < 0 { + return Err("Negative exponents are not supported".to_string()); + } + if right.total > 128 { + return Err("Exponent too large".to_string()); + } + let exp = right.total as u32; + let total = match left.total.checked_pow(exp) { + Some(t) => t, + None => return Err("Exponent too large".to_string()), + }; + let repr = format!("{}^{}", left.repr, right.repr); + left = EvalResult { total, repr }; + } + Ok(left) +} + +// primary := '(' expression ')' | (number | dice) +// dice tokens look like: "d20", "2d6", "1d8!<3", "1d20!=1" +fn parse_primary( + s: &str, + pos: &mut usize, + rng: &mut R, +) -> Result { + // Handle parentheses first: '(' expr ')' + if let Some('(') = peek_char(s, *pos) { + // consume '(' + *pos += 1; + + // parse inner expression + let inner = parse_expr(s, pos, rng)?; + + // require a closing ')' + match peek_char(s, *pos) { + Some(')') => { + *pos += 1; // consume ')' + Ok(EvalResult { + total: inner.total, + repr: format!("({})", inner.repr), + }) + } + _ => Err("Missing closing parenthesis".to_string()), + } + } else { + // Otherwise parse a bare token (number or dice) + let start = *pos; + while let Some(c) = peek_char(s, *pos) { + // Stop at operators *or* parentheses + if "+-*/^()".contains(c) { + break; + } else { + *pos += 1; + } + } + + if *pos == start { + return Err("Error in roll formatting".to_string()); + } + + let token = &s[start..*pos]; + + if token.contains('d') { + // e.g. "2d6", "d20!<5", "1d8!=1" + eval_dice_token(token, rng) + } else { + // plain integer + let value = match token.parse::() { + Ok(v) => v, + Err(_) => return Err("Error in roll formatting".to_string()), + }; + Ok(EvalResult { + total: value, + repr: token.to_string(), + }) + } + } +} + +// Evaluate a single dice token like "2d6", "d20!<5", "1d8!=1" +fn eval_dice_token( + dice_roll: &str, + rng: &mut R, +) -> Result { + // Split the dice roll into number of dice and sides + let (num_of_dice, rest) = match dice_roll.split_once('d') { + Some(parts) => parts, + None => return Err("Error in roll formatting".to_string()), + }; + + let (num_of_sides, _explosion_condition) = + if let Some(explosion_pos) = rest.find('!') { + (&rest[..explosion_pos], Some(&rest[explosion_pos + 1..])) + } else { + (rest, None) + }; + + // Number of dice (empty = 1) + let dice: u128 = if num_of_dice.is_empty() { + 1 + } else { + match num_of_dice.parse::() { + Ok(n) => n, + Err(_) => return Err("Too many dice to count...".to_string()), + } + }; + + // Number of sides + let sides: u128 = match num_of_sides.parse::() { + Ok(s) => s, + Err(_) => return Err("Too many sides to count...".to_string()), + }; + + let explosion_limit = 512; + let mut explosion_count = 0; + + // Everything after '!' is the explosion condition text + let explode_condition = if let Some(pos) = dice_roll.find('!') { + dice_roll[pos + 1..].to_string() + } else { + String::new() + }; + + let can_explode = dice_roll.chars().any(|c| c == '!'); + + // NEW: store only the **final total per die**, not every individual reroll + let mut dice_totals: Vec = Vec::new(); + + for _ in 0..dice { + let mut roll = rng.gen_range(1..=sides); + let mut total_for_die = roll; + + // Handle explosion chain for this single die + while can_explode && explosion_count < explosion_limit { + // Decide whether this particular roll explodes + let explode = if explode_condition.is_empty() { + // Default: explode on max side + roll == sides + } else if let Some(c) = explode_condition.chars().next() { + match c { + '<' => { + roll + < explode_condition[1..] + .parse::() + .unwrap_or(1) + } + '=' => { + roll + == explode_condition[1..] + .parse::() + .unwrap_or(sides) + } + _ => false, + } + } else { + false + }; + + if explode { + let new_roll = rng.gen_range(1..=sides); + total_for_die += new_roll; + roll = new_roll; + explosion_count += 1; + } else { + break; + } + } + + dice_totals.push(total_for_die); + } + + // Sum of *final* per-die totals (same numeric result as before) + let sum_of_rolls: u128 = dice_totals.iter().copied().sum(); + + // Print only one value per original die, e.g. (1+1+1+1+5) + let rolls_text = if dice_totals.is_empty() { + String::from("") + } else { + dice_totals + .iter() + .map(|r| r.to_string()) + .collect::>() + .join("+") + }; + + let parenthesis_rolls = if dice_totals.is_empty() { + String::from("0") + } else { + format!("({})", rolls_text) + }; + + Ok(EvalResult { + total: sum_of_rolls as i128, + repr: parenthesis_rolls, + }) +} + fn roll_dice(input: &str) -> String { //Start RNG let mut rng = rand::thread_rng(); @@ -96,219 +402,60 @@ fn roll_dice(input: &str) -> String { let (input_without_comment, comment) = if let Some(comment_position) = input.find('#') { let (input_before_comment, comment_part) = input.split_at(comment_position); - (input_before_comment, comment_part[1..].trim().to_string()) // not sure if needed the string part - } - else { - (input, "".to_string()) // not sure if needed - }; + (input_before_comment, comment_part[1..].trim().to_string()) + } else { + (input, "".to_string()) + }; + //Remove all spaces let input_clean = input_without_comment.replace(|c: char| c.is_whitespace(), ""); - //Fucks empty roll + //Empty roll check if input_clean.is_empty() { return "Roll can not be empty!".to_string(); } + //Basic help command - if input_clean.starts_with("help"){ + if input_clean.starts_with("help") { return "/r [numOfDice]d[numSidesOfDice]".to_string(); } - // Detect and split dice expression from comparison part let comparison_operators = ["<=", ">=", "<", ">", "="]; // Gets the pure dice combo let mut dice_expression = input_clean.as_str(); - // Gets what to compare to - let mut comparison_section = ""; + // Gets what to compare to (still unused, but kept for future) + let mut _comparison_section = ""; + for operator in &comparison_operators { if let Some(pos) = input_clean.find(operator) { - dice_expression = &input_clean[..pos]; // command is before comparison - comparison_section = &input_clean[pos..]; // operator and value to compare it to + dice_expression = &input_clean[..pos]; + _comparison_section = &input_clean[pos..]; break; } } - // Check if the input matches valid terms (dice or constants with optional signs) - let regex_check = Regex::new(r"^([+-]?(\d*d\d+(!\d*)?|\d+)([+-](\d*d\d+(!\d*)?|\d+))*)$").unwrap(); - // If doesn't match regex syntax + // Basic character whitelist check (now includes *, /, ^) + let regex_check = + Regex::new(r"^[0-9d!<>=+\-*/^()]+$").unwrap(); if !regex_check.is_match(&dice_expression) { - return "Invalid character in command, accepted characters: [0-9],[+-!^],[d]".to_string(); + return "Invalid character in command, accepted characters: [0-9],[+-!^*/()],[d]".to_string(); } - // regex filter for split - // i.e. Regex("([+-]?[^+-]+)") - let regex_terms = Regex::new(r"([+-]?[^+-]+)").unwrap(); - // Split into individual terms with signs - // i.e. ["1d20", "+3d30"] - let split_dice: Vec<&str> = regex_terms.find_iter(dice_expression).map(|m| m.as_str()).collect(); - - // Verify the split dice and reconstruct the cleaned input, just error checking - // Also catches me if i fuck up the regex one day by adding a shit ton of shitty features - let repaired_dice: String = split_dice.iter().copied().collect(); - if repaired_dice != dice_expression { - return "Error in formatting".to_string(); - } - - // Prepare for output, formatted_parts is for the equation visual output string, and total_sum is the final output - let mut formatted_parts = String::new(); - let mut total_sum = 0; - let mut all_rolls = Vec::new(); - - // Parse and enumerate over each indidivudal part of the split dice - for (i, dice_segment) in split_dice.iter().enumerate() { - // Parse the sign and value part from an individual segment - // 1 = + -1 = -, nothing = 1 because its positive - // dice_roll is the pure input without signs, i.e. -59d1 -> 59d1 - let (sign_character, dice_roll) = - if dice_segment.starts_with('+') { - (1, &dice_segment[1..])} - else if dice_segment.starts_with('-') { - (-1, &dice_segment[1..])} - else { - (1, *dice_segment)}; - - - - // Check if num of dice is really a dice, because sidekick allows "math" - // At the end of this formatted parts and total sum are combined, with the else of this statement combining the normal nums - if dice_roll.contains('d') { - // Split the dice roll into the total dice and sides of the dice being rolled - let (num_of_dice, rest) = match dice_roll.split_once('d') { - Some(parts) => parts, - None => return "Error in roll formatting".to_string(), - }; - let (num_of_sides, _explosion_condition) = if let Some(explosion_pos) = rest.find('!') { - (&rest[..explosion_pos], Some(&rest[explosion_pos + 1..])) - } else { - (rest, None) - }; - - // Make sure total dice fits into u128 and sets empty to 1 - let dice = if num_of_dice.is_empty() {1} - else { - match num_of_dice.parse::() { - Ok(n) => n, - Err(_) => return "Too many dice to count...".to_string() - }}; - // Make sure total sides fits into u128 - let sides = match num_of_sides.parse::() { - Ok(s) => s, - Err(_) => return "Too many sides to count...".to_string(), - }; + // Parse and evaluate the expression with proper precedence + let mut pos: usize = 0; + let result = match parse_expr(dice_expression, &mut pos, &mut rng) { + Ok(v) => v, + Err(msg) => return msg, + }; - let explosion_limit = 512; - let mut explosion_count = 0; - // Check if the dice roll has an explosion condition - // doesnt work rn - let explode_condition = if let Some(pos) = dice_roll.find('!') { - dice_roll[pos+1..].to_string() - } - else { - String::new() - }; - let can_explode = dice_roll.chars().any(|c| c == '!'); - // rolling of dice - let mut rolls: Vec = vec![]; - for _ in 0..dice { - let mut roll = rng.gen_range(1..=sides); - rolls.push(roll); - while explosion_count < explosion_limit { - // Check explosion condition - let explode = if explode_condition.is_empty() { - roll == sides // Default: explode on max roll - } else if let Some(c) = explode_condition.chars().next() { - match c { - '<' => roll < explode_condition[1..].parse::().unwrap_or(1), // d20!<5 = explode below 5 - '=' => roll == explode_condition[1..].parse::().unwrap_or(sides), // d20!=1 = explode on 1 - _ => false - } - } else { - false - }; - if can_explode { - // If explosion condition is met, roll again - if explode { - let new_roll = rng.gen_range(1..=sides); - rolls.push(new_roll); - roll = new_roll; - explosion_count += 1; - } else { - break; // Stop exploding - } - } else { break;} - } - } - // Add up the rolls - let sum_of_rolls: u128 = rolls.iter().sum(); - // Add back the negative/positive - let sign_applied_sum = sum_of_rolls as i128 * sign_character; - - // The text now, copying sidekick and making each roll visible - let rolls_text = if rolls.is_empty() { - String::from("") - } - else { - rolls.iter().map(|r| r.to_string()).collect::>().join("+") - }; - // put em in parenthesis - let parenthesis_rolls = if rolls.is_empty() { - String::from("0") - } - else { - format!("({})", rolls_text) - }; + // If we didn't consume the whole expression, something went wrong + if pos != dice_expression.len() { + return "Error in roll formatting".to_string(); + } - // add back the -/+ and apply negative if first - let signed_parenthesis_rolls = if i == 0 { - if sign_character == -1 { - format!("-{}", parenthesis_rolls) - } - else { - parenthesis_rolls - } - } - else { - if sign_character == 1 { - format!("+{}", parenthesis_rolls) - } - else { - format!("-{}", parenthesis_rolls) - } - }; - // Append roll text and sum - formatted_parts.push_str(&signed_parenthesis_rolls); - total_sum += sign_applied_sum; - all_rolls.extend(rolls); - } - // If it isn't a dice, then it's a normal constant number - else { - // Make sure the constant can fit in a i128 - let constant = match dice_roll.parse::() { - Ok(c) => c, - Err(_) => return "Error number too big...".to_string(), - }; - let signed_constant = constant * sign_character; - // string display for signs copeid from above - let formatted_part = if i == 0 { - if sign_character == -1 { - format!("-{}", constant) - } - else { - dice_roll.to_string() - } - } - else { - if sign_character == 1 { - format!("+{}", constant) - } - else { - format!("-{}", constant) - } - }; - formatted_parts.push_str(&formatted_part); - total_sum += signed_constant; - } + let formatted_parts = result.repr; + let total_sum = result.total; - } // Final output ` ` around block like sidekick did format!("`{}` {} = {} = {}", input_clean, comment, formatted_parts, total_sum) }