diff --git a/src/dice.rs b/src/dice.rs index 9c1b21e..169ea03 100644 --- a/src/dice.rs +++ b/src/dice.rs @@ -1,8 +1,10 @@ +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; @@ -40,16 +42,7 @@ 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); @@ -61,7 +54,7 @@ impl Expression { Ok(average) } - fn evaluate( + pub(crate) fn evaluate( &self, rng: &mut impl Rng, w: &mut W, @@ -71,6 +64,7 @@ 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; @@ -96,7 +90,7 @@ impl Expression { } else { 1 }; - if count > DICE_POOL_LIMIT { + if count > MAX_DICE_COUNT_PER_ROLL { bail!("Too many dice.") }; write!(w, "d")?; @@ -128,7 +122,7 @@ impl Expression { } let mut i = 0; while i < rolls.len() { - if i > DICE_POOL_LIMIT { + if i > MAX_DICE_COUNT_PER_ROLL { bail!("Explosion added too many dice.") } if comparers.len() == 0 { @@ -298,7 +292,7 @@ impl Expression { } else { 1 }; - if count > DICE_POOL_LIMIT { + if count > MAX_DICE_COUNT_PER_ROLL { bail!("Too many dice.") }; let size = size.avg()? as i64; @@ -390,7 +384,7 @@ impl Expression { } } -trait Witness { +pub(crate) trait Witness { type Ok; type Error: Error; type WitnessSet<'a>: WitnessSet @@ -405,9 +399,10 @@ trait Witness { { self.witness_source_text(&format(args)) } + fn witness_total_result(&mut self, result: f64) -> Result; } -trait WitnessSet { +pub(crate) trait WitnessSet { type Ok; type Error: Error; @@ -416,7 +411,7 @@ trait WitnessSet { } #[derive(Debug)] -struct DiceRoll { +pub(crate) struct DiceRoll { size: usize, roll: usize, is_admitted: bool, @@ -436,27 +431,56 @@ impl Default for DiceRoll { } } -struct DiscordMdWitness { - buffer: String, - dice_written: usize, +#[derive(Default)] +pub(crate) struct DiscordMd { + pub(crate) buffers: Vec, + pub(crate) dice_written: usize, } -struct DiscordMdWitnessSet<'a> { - parent: &'a mut DiscordMdWitness, +pub(crate) struct DiscordMdWitness { + parent: Arc>, + buffer_idx: usize, + result: Option, +} +pub(crate) struct DiscordMdWitnessSet<'a> { + witness: &'a mut DiscordMdWitness, index: usize, dice_elided: bool, } #[derive(Error, Debug)] -enum DiscordMdWitnessError { - #[error("formatting failed")] +pub(crate) enum DiscordMdWitnessError { + #[error("Formatting failed.")] Format(#[from] std::fmt::Error), } -impl Default for DiscordMdWitness { - fn default() -> Self { - Self { - buffer: String::new(), - dice_written: 0, +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}** = ")); } } } @@ -467,65 +491,87 @@ impl Witness for DiscordMdWitness { type WitnessSet<'a> = DiscordMdWitnessSet<'a>; fn witness_source_text(&mut self, text: &str) -> Result { - self.buffer.push_str(text); + self.parent.write().unwrap().buffers[self.buffer_idx].push_str(text); Ok(()) } fn witness_set(&mut self) -> Result, Self::Error> { - write!(self.buffer, "‹ ")?; + write!(self.parent.write().unwrap().buffers[self.buffer_idx], "‹ ")?; Ok(DiscordMdWitnessSet { - parent: self, + witness: 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 { - if self.parent.dice_written >= 50 { + let mut parent = self.witness.parent.write().unwrap(); + if parent.dice_written >= MAX_DISPLAYED_DICE_PER_MESSAGE { if !self.dice_elided { - write!(self.parent.buffer, "…")?; + write!( + parent.buffers[self.witness.buffer_idx], + "…" + )?; 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!(self.parent.buffer, " ")?; + write!(buf, ",")?; } if !dice.is_admitted { - write!(self.parent.buffer, "~~")?; + write!(buf, "~~")?; } if dice.is_from_proliferation { - write!(self.parent.buffer, "*")?; + write!(buf, "*")?; } - if is_extreme || dice.did_proliferate { - write!(self.parent.buffer, "**")?; + if is_extreme { + write!(buf, "**")?; + } + if dice.did_proliferate { + write!(buf, "__")?; } - write!(self.parent.buffer, "`{}`", dice.roll)?; + write!(buf, "`{}`", dice.roll)?; - if is_extreme || dice.did_proliferate { - write!(self.parent.buffer, "**")?; + if dice.did_proliferate { + write!(buf, "__")?; + } + if is_extreme { + write!(buf, "**")?; } if dice.is_from_proliferation { - write!(self.parent.buffer, "*")?; + write!(buf, "*")?; } if !dice.is_admitted { - write!(self.parent.buffer, "~~")?; + write!(buf, "~~")?; } self.index += 1; - self.parent.dice_written += 1; + parent.dice_written += 1; Ok(()) } fn end(self) -> Result { - write!(self.parent.buffer, " ›")?; + write!( + self.witness.parent.write().unwrap().buffers[self.witness.buffer_idx], + " ›" + )?; Ok(()) } } @@ -545,6 +591,10 @@ 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 new file mode 100644 index 0000000..41f855c --- /dev/null +++ b/src/limits.rs @@ -0,0 +1,4 @@ +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 321753e..8b34858 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,18 @@ mod dice; +mod limits; mod parsing; -use anyhow::bail; +use crate::dice::{DiscordMd, Witness}; +use anyhow::{Context as AnyCtx, 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, - CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateTextDisplay, + CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse, CreateInteractionResponseMessage, CreateTextDisplay, }; use serenity::{async_trait, prelude::*}; use std::sync::Arc; @@ -98,7 +103,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:#?}"); @@ -110,141 +115,148 @@ impl EventHandler for Handler { } pub async fn roll(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 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); - } + 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.") }; - 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 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()); - command - .create_followup( - &ctx.http, - CreateInteractionResponseFollowup::new() + 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() .components(vec![CreateComponent::Container(CreateContainer::new( text_components, ))]) .ephemeral(private) - .flags(MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS), - ) - .await?; + .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? + }; Ok(()) }