diff --git a/src/dice.rs b/src/dice.rs index 169ea03..9c1b21e 100644 --- a/src/dice.rs +++ b/src/dice.rs @@ -1,10 +1,8 @@ -use crate::limits::{MAX_DICE_COUNT_PER_ROLL, MAX_DISPLAYED_DICE_PER_MESSAGE, RESULT_CULL_CHARACTER_THRESHOLD}; use anyhow::bail; use rand::{Rng, RngExt}; use std::convert::Infallible; use std::error::Error; use std::fmt::{Write, format}; -use std::sync::{Arc, RwLock}; use thiserror::Error; pub type ExpBox = Box; @@ -42,7 +40,16 @@ pub enum CompareFragment { Le(ExpBox), } +const DICE_POOL_LIMIT: usize = 10_000; + impl Expression { + pub fn collect_evaluation(&self, mut rng: impl Rng) -> anyhow::Result { + let mut witness = DiscordMdWitness::default(); + let total = self.evaluate(&mut rng, &mut witness, 100)?; + let result_text = witness.buffer; + Ok(format!("**{total}** = {result_text}")) + } + pub fn average(&self, rng: &mut impl Rng) -> anyhow::Result { if let Ok(avg) = self.avg() { return Ok(avg); @@ -54,7 +61,7 @@ impl Expression { Ok(average) } - pub(crate) fn evaluate( + fn evaluate( &self, rng: &mut impl Rng, w: &mut W, @@ -64,7 +71,6 @@ impl Expression { W: Witness, E: Error + Send + Sync + 'static, { - use crate::limits::MAX_DICE_COUNT_PER_ROLL; use Expression::*; let precedence = self.precedence(); let needs_parens = precedence > outer_precedence; @@ -90,7 +96,7 @@ impl Expression { } else { 1 }; - if count > MAX_DICE_COUNT_PER_ROLL { + if count > DICE_POOL_LIMIT { bail!("Too many dice.") }; write!(w, "d")?; @@ -122,7 +128,7 @@ impl Expression { } let mut i = 0; while i < rolls.len() { - if i > MAX_DICE_COUNT_PER_ROLL { + if i > DICE_POOL_LIMIT { bail!("Explosion added too many dice.") } if comparers.len() == 0 { @@ -292,7 +298,7 @@ impl Expression { } else { 1 }; - if count > MAX_DICE_COUNT_PER_ROLL { + if count > DICE_POOL_LIMIT { bail!("Too many dice.") }; let size = size.avg()? as i64; @@ -384,7 +390,7 @@ impl Expression { } } -pub(crate) trait Witness { +trait Witness { type Ok; type Error: Error; type WitnessSet<'a>: WitnessSet @@ -399,10 +405,9 @@ pub(crate) trait Witness { { self.witness_source_text(&format(args)) } - fn witness_total_result(&mut self, result: f64) -> Result; } -pub(crate) trait WitnessSet { +trait WitnessSet { type Ok; type Error: Error; @@ -411,7 +416,7 @@ pub(crate) trait WitnessSet { } #[derive(Debug)] -pub(crate) struct DiceRoll { +struct DiceRoll { size: usize, roll: usize, is_admitted: bool, @@ -431,56 +436,27 @@ impl Default for DiceRoll { } } -#[derive(Default)] -pub(crate) struct DiscordMd { - pub(crate) buffers: Vec, - pub(crate) dice_written: usize, +struct DiscordMdWitness { + buffer: String, + dice_written: usize, } -pub(crate) struct DiscordMdWitness { - parent: Arc>, - buffer_idx: usize, - result: Option, -} -pub(crate) struct DiscordMdWitnessSet<'a> { - witness: &'a mut DiscordMdWitness, +struct DiscordMdWitnessSet<'a> { + parent: &'a mut DiscordMdWitness, index: usize, dice_elided: bool, } #[derive(Error, Debug)] -pub(crate) enum DiscordMdWitnessError { - #[error("Formatting failed.")] +enum DiscordMdWitnessError { + #[error("formatting failed")] Format(#[from] std::fmt::Error), } -impl DiscordMd { - pub(crate) fn create_witness(md: &Arc>) -> DiscordMdWitness { - let mut parent = md.write().unwrap(); - parent.buffers.push(String::new()); - DiscordMdWitness { - parent: md.clone(), - buffer_idx: parent.buffers.len() - 1, - result: None, - } - } - - pub(crate) fn character_count(&self) -> usize { - self.buffers.iter().map(|buf| buf.chars().count()).sum() - } -} - - -impl DiscordMdWitness { - pub(crate) fn end(self) { - let mut parent = self.parent.write().unwrap(); - if parent.character_count() > RESULT_CULL_CHARACTER_THRESHOLD { - parent.buffers[self.buffer_idx] = self - .result - .map(|result| format!("**{result} = …**")) - .unwrap_or_else(|| String::new()); - } else if let Some(result) = self.result { - parent.buffers[self.buffer_idx] - .insert_str(0, &format!("**{result}** = ")); +impl Default for DiscordMdWitness { + fn default() -> Self { + Self { + buffer: String::new(), + dice_written: 0, } } } @@ -491,87 +467,65 @@ impl Witness for DiscordMdWitness { type WitnessSet<'a> = DiscordMdWitnessSet<'a>; fn witness_source_text(&mut self, text: &str) -> Result { - self.parent.write().unwrap().buffers[self.buffer_idx].push_str(text); + self.buffer.push_str(text); Ok(()) } fn witness_set(&mut self) -> Result, Self::Error> { - write!(self.parent.write().unwrap().buffers[self.buffer_idx], "‹ ")?; + write!(self.buffer, "‹ ")?; Ok(DiscordMdWitnessSet { - witness: self, + parent: self, index: 0, dice_elided: false, }) } - - fn witness_total_result(&mut self, result: f64) -> Result { - self.result = Some(result); - Ok(()) - } } - impl<'a> WitnessSet for DiscordMdWitnessSet<'a> { type Ok = (); type Error = DiscordMdWitnessError; fn witness_roll(&mut self, dice: DiceRoll) -> Result { - let mut parent = self.witness.parent.write().unwrap(); - if parent.dice_written >= MAX_DISPLAYED_DICE_PER_MESSAGE { + if self.parent.dice_written >= 50 { if !self.dice_elided { - write!( - parent.buffers[self.witness.buffer_idx], - "…" - )?; + write!(self.parent.buffer, "…")?; self.dice_elided = true; } return Ok(()); } - let buf = &mut parent.buffers[self.witness.buffer_idx]; let is_extreme = dice.size >= 4 && (dice.roll == 1 || dice.roll == dice.size); - // A visible separator is required despite the monospace boxing, - // due to iOS Discord being weird. if self.index > 0 { - write!(buf, ",")?; + write!(self.parent.buffer, " ")?; } if !dice.is_admitted { - write!(buf, "~~")?; + write!(self.parent.buffer, "~~")?; } if dice.is_from_proliferation { - write!(buf, "*")?; + write!(self.parent.buffer, "*")?; } - if is_extreme { - write!(buf, "**")?; - } - if dice.did_proliferate { - write!(buf, "__")?; + if is_extreme || dice.did_proliferate { + write!(self.parent.buffer, "**")?; } - write!(buf, "`{}`", dice.roll)?; + write!(self.parent.buffer, "`{}`", dice.roll)?; - if dice.did_proliferate { - write!(buf, "__")?; - } - if is_extreme { - write!(buf, "**")?; + if is_extreme || dice.did_proliferate { + write!(self.parent.buffer, "**")?; } if dice.is_from_proliferation { - write!(buf, "*")?; + write!(self.parent.buffer, "*")?; } if !dice.is_admitted { - write!(buf, "~~")?; + write!(self.parent.buffer, "~~")?; } self.index += 1; - parent.dice_written += 1; + self.parent.dice_written += 1; Ok(()) } fn end(self) -> Result { - write!( - self.witness.parent.write().unwrap().buffers[self.witness.buffer_idx], - " ›" - )?; + write!(self.parent.buffer, " ›")?; Ok(()) } } @@ -591,10 +545,6 @@ impl Witness for std::io::Sink { fn witness_set(&mut self) -> Result, Self::Error> { Ok(self) } - - fn witness_total_result(&mut self, _result: f64) -> Result { - Ok(()) - } } impl WitnessSet for &mut std::io::Sink { diff --git a/src/limits.rs b/src/limits.rs deleted file mode 100644 index 41f855c..0000000 --- a/src/limits.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub const MAX_DICE_COUNT_PER_ROLL: usize = 20_000; -pub const RESULT_CULL_CHARACTER_THRESHOLD: usize = 3000; -pub const MAX_DISPLAYED_DICE_PER_MESSAGE: usize = 100; -pub const MAX_ROLL_REPEATS: usize = 35; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 8b34858..321753e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,13 @@ mod dice; -mod limits; mod parsing; -use crate::dice::{DiscordMd, Witness}; -use anyhow::{Context as AnyCtx, bail}; +use anyhow::bail; use dotenv::dotenv; -use limits::*; use rand::{Rng, SeedableRng}; -use serenity::all::{ - Command, CommandInteraction, CommandOptionType, CreateCommandOption, CreateComponent, - FullEvent, Interaction, InteractionContext, MessageFlags, ResolvedOption, ResolvedValue, -}; +use serenity::all::{Command, CommandInteraction, CommandOptionType, CreateCommandOption, CreateComponent, FullEvent, Interaction, InteractionContext, MessageFlags, ResolvedOption, ResolvedValue}; use serenity::builder::{ - CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse, CreateInteractionResponseMessage, CreateTextDisplay, + CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse, + CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateTextDisplay, }; use serenity::{async_trait, prelude::*}; use std::sync::Arc; @@ -103,7 +98,7 @@ impl EventHandler for Handler { .add_context(InteractionContext::BotDm) .add_context(InteractionContext::PrivateChannel), ) - .await; + .await; debug!("I created the following global slash command: {global_command:#?}"); @@ -115,148 +110,141 @@ impl EventHandler for Handler { } pub async fn roll(ctx: &Context, command: &CommandInteraction) -> anyhow::Result<()> { - pub async fn inner(ctx: &Context, command: &CommandInteraction) -> anyhow::Result<()> { - let options = command.data.options(); - let Some(ResolvedOption { - value: ResolvedValue::String(formula), - .. - }) = options.iter().find(|option| option.name == "formula") - else { - bail!("Formula was not provided.") + let options = command.data.options(); + let Some(ResolvedOption { + value: ResolvedValue::String(formula), + .. + }) = options.iter().find(|option| option.name == "formula") + else { + bail!("Formula was not provided.") + }; + let repeat = options + .iter() + .find(|option| option.name == "repeat") + .and_then(|option| { + if let ResolvedValue::Integer(repeat) = option.value { + Some(repeat) + } else { + None + } + }) + .unwrap_or(1) + .min(20); + let private = options + .iter() + .find(|option| option.name == "private") + .and_then(|option| { + if let ResolvedValue::Boolean(private) = option.value { + Some(private) + } else { + None + } + }) + .unwrap_or(false); + let description = options + .iter() + .find(|option| option.name == "description") + .and_then(|option| { + if let ResolvedValue::String(description) = option.value { + Some(description) + } else { + None + } + }); + let fixed_seed = options + .iter() + .find(|option| option.name == "seed") + .and_then(|option| { + if let ResolvedValue::String(fixed_seed) = option.value { + Some(fixed_seed) + } else { + None + } + }) + .and_then(|s| s.parse::().ok()); + + let result = parsing::parse(&formula); + let expression = match result { + Ok(expression) => expression, + Err(error) => { + command + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::default() + .content(format!("Dice formula could not be parsed.\n{}", error)) + .ephemeral(true), + ), + ) + .await?; + return Ok(()); + } + }; + if private { + command.defer_ephemeral(&ctx.http).await?; + } else { + command.defer(&ctx.http).await?; + } + + let seed = fixed_seed.unwrap_or_else(|| rand::rng().next_u64()); + let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(seed); + + let average = match expression.average(&mut rng) { + Ok(res) => res, + Err(err) => { + command + .create_followup( + &ctx.http, + CreateInteractionResponseFollowup::new().content(format!("{}", err)), + ) + .await?; + return Err(err); + } + }; + let ev = (average * 10.0).round() / 10.0; + + let mut text_components = Vec::with_capacity((repeat + 2) as usize); + if let Some(description) = description { + text_components.push(CreateContainerComponent::TextDisplay( + CreateTextDisplay::new(format!(">>> {description}\n")), + )); + } + for _ in 0..repeat { + let result = match expression.collect_evaluation(&mut rng) { + Ok(res) => res, + Err(err) => { + command + .create_followup( + &ctx.http, + CreateInteractionResponseFollowup::new().content(format!("{}", err)), + ) + .await?; + return Err(err); + } }; - let repeat = options - .iter() - .find(|option| option.name == "repeat") - .and_then(|option| { - if let ResolvedValue::Integer(repeat) = option.value { - Some(repeat) - } else { - None - } - }) - .unwrap_or(1) - .min(MAX_ROLL_REPEATS as i64); - let private = options - .iter() - .find(|option| option.name == "private") - .and_then(|option| { - if let ResolvedValue::Boolean(private) = option.value { - Some(private) - } else { - None - } - }) - .unwrap_or(false); - let description = options - .iter() - .find(|option| option.name == "description") - .and_then(|option| { - if let ResolvedValue::String(description) = option.value { - Some(description) - } else { - None - } - }); - let fixed_seed = options - .iter() - .find(|option| option.name == "seed") - .and_then(|option| { - if let ResolvedValue::String(fixed_seed) = option.value { - Some(fixed_seed) - } else { - None - } - }) - .and_then(|s| s.parse::().ok()); + text_components.push(CreateContainerComponent::TextDisplay( + CreateTextDisplay::new(format!("{}\n", result)), + )); + } + if fixed_seed.is_some() { + text_components.push(CreateContainerComponent::TextDisplay( + CreateTextDisplay::new("***⚠️ This is not a fair roll. The result was generated based on a fixed seed and is thus fully reproducible.***".to_string()), + )); + } + text_components.push(CreateContainerComponent::TextDisplay( + CreateTextDisplay::new(format!("-# Expected value: {ev} | Seed: {seed}")), + )); - let expression = parsing::parse(&formula).context("Dice formula could not be parsed.")?; - - let seed = fixed_seed.unwrap_or_else(|| rand::rng().next_u64()); - let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(seed); - - let average = expression.average(&mut rng)?; - let ev = (average * 10.0).round() / 10.0; - - let discord_md = Arc::new(std::sync::RwLock::new(DiscordMd { - buffers: vec![], - dice_written: 0, - })); - if let Some(description) = description { - discord_md - .write() - .unwrap() - .buffers - .push(format!(">>> {description}\n")); - } - for _ in 0..repeat { - let mut witness = DiscordMd::create_witness(&discord_md); - let result = expression.evaluate(&mut rng, &mut witness, i64::MAX)?; - witness.witness_total_result(result)?; - witness.end(); - } - if fixed_seed.is_some() { - discord_md - .write() - .unwrap() - .buffers - .push("***⚠️ This is not a fair roll. The result was generated based on a fixed seed and is thus fully reproducible.***".to_string()); - } - discord_md - .write() - .unwrap() - .buffers - .push(format!("-# Expected value: {ev} | Seed: {seed}")); - - let text_components = Arc::into_inner(discord_md) - .unwrap() - .into_inner()? - .buffers - .into_iter() - .map(|text| CreateContainerComponent::TextDisplay(CreateTextDisplay::new(text))) - .collect::>(); - - let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() + command + .create_followup( + &ctx.http, + CreateInteractionResponseFollowup::new() .components(vec![CreateComponent::Container(CreateContainer::new( text_components, ))]) .ephemeral(private) - .flags( - MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS, - ), - ); - - debug!("Returning response: {response:#?}"); - - command - .create_response( - &ctx.http, - response, - ) - .await?; - Ok(()) - } - - if let Ok(_) = inner(ctx, command).await { - } else if let Err(err) = inner(ctx, command).await { - warn!("Error encountered: {err:?}"); - command - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .components(vec![CreateComponent::Container(CreateContainer::new( - vec![CreateContainerComponent::TextDisplay( - CreateTextDisplay::new(err.to_string()), - )], - ))]) - .ephemeral(true) - .flags( - MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS, - ), - ), - ) - .await? - }; + .flags(MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS), + ) + .await?; Ok(()) }