added more graceful handling of oversized inputs

This commit is contained in:
Lilith Schier 2026-08-20 18:01:31 +02:00
parent e0cf21d3db
commit 0eec30fe82
3 changed files with 238 additions and 180 deletions

View file

@ -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 anyhow::bail;
use rand::{Rng, RngExt}; use rand::{Rng, RngExt};
use std::convert::Infallible; use std::convert::Infallible;
use std::error::Error; use std::error::Error;
use std::fmt::{Write, format}; use std::fmt::{Write, format};
use std::sync::{Arc, RwLock};
use thiserror::Error; use thiserror::Error;
pub type ExpBox = Box<Expression>; pub type ExpBox = Box<Expression>;
@ -40,16 +42,7 @@ pub enum CompareFragment {
Le(ExpBox), Le(ExpBox),
} }
const DICE_POOL_LIMIT: usize = 10_000;
impl Expression { impl Expression {
pub fn collect_evaluation(&self, mut rng: impl Rng) -> anyhow::Result<String> {
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<f64> { pub fn average(&self, rng: &mut impl Rng) -> anyhow::Result<f64> {
if let Ok(avg) = self.avg() { if let Ok(avg) = self.avg() {
return Ok(avg); return Ok(avg);
@ -61,7 +54,7 @@ impl Expression {
Ok(average) Ok(average)
} }
fn evaluate<W, E>( pub(crate) fn evaluate<W, E>(
&self, &self,
rng: &mut impl Rng, rng: &mut impl Rng,
w: &mut W, w: &mut W,
@ -71,6 +64,7 @@ impl Expression {
W: Witness<Error = E>, W: Witness<Error = E>,
E: Error + Send + Sync + 'static, E: Error + Send + Sync + 'static,
{ {
use crate::limits::MAX_DICE_COUNT_PER_ROLL;
use Expression::*; use Expression::*;
let precedence = self.precedence(); let precedence = self.precedence();
let needs_parens = precedence > outer_precedence; let needs_parens = precedence > outer_precedence;
@ -96,7 +90,7 @@ impl Expression {
} else { } else {
1 1
}; };
if count > DICE_POOL_LIMIT { if count > MAX_DICE_COUNT_PER_ROLL {
bail!("Too many dice.") bail!("Too many dice.")
}; };
write!(w, "d")?; write!(w, "d")?;
@ -128,7 +122,7 @@ impl Expression {
} }
let mut i = 0; let mut i = 0;
while i < rolls.len() { while i < rolls.len() {
if i > DICE_POOL_LIMIT { if i > MAX_DICE_COUNT_PER_ROLL {
bail!("Explosion added too many dice.") bail!("Explosion added too many dice.")
} }
if comparers.len() == 0 { if comparers.len() == 0 {
@ -298,7 +292,7 @@ impl Expression {
} else { } else {
1 1
}; };
if count > DICE_POOL_LIMIT { if count > MAX_DICE_COUNT_PER_ROLL {
bail!("Too many dice.") bail!("Too many dice.")
}; };
let size = size.avg()? as i64; let size = size.avg()? as i64;
@ -390,7 +384,7 @@ impl Expression {
} }
} }
trait Witness { pub(crate) trait Witness {
type Ok; type Ok;
type Error: Error; type Error: Error;
type WitnessSet<'a>: WitnessSet<Ok = Self::Ok, Error = Self::Error> type WitnessSet<'a>: WitnessSet<Ok = Self::Ok, Error = Self::Error>
@ -405,9 +399,10 @@ trait Witness {
{ {
self.witness_source_text(&format(args)) self.witness_source_text(&format(args))
} }
fn witness_total_result(&mut self, result: f64) -> Result<Self::Ok, Self::Error>;
} }
trait WitnessSet { pub(crate) trait WitnessSet {
type Ok; type Ok;
type Error: Error; type Error: Error;
@ -416,7 +411,7 @@ trait WitnessSet {
} }
#[derive(Debug)] #[derive(Debug)]
struct DiceRoll { pub(crate) struct DiceRoll {
size: usize, size: usize,
roll: usize, roll: usize,
is_admitted: bool, is_admitted: bool,
@ -436,27 +431,56 @@ impl Default for DiceRoll {
} }
} }
struct DiscordMdWitness { #[derive(Default)]
buffer: String, pub(crate) struct DiscordMd {
dice_written: usize, pub(crate) buffers: Vec<String>,
pub(crate) dice_written: usize,
} }
struct DiscordMdWitnessSet<'a> { pub(crate) struct DiscordMdWitness {
parent: &'a mut DiscordMdWitness, parent: Arc<RwLock<DiscordMd>>,
buffer_idx: usize,
result: Option<f64>,
}
pub(crate) struct DiscordMdWitnessSet<'a> {
witness: &'a mut DiscordMdWitness,
index: usize, index: usize,
dice_elided: bool, dice_elided: bool,
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
enum DiscordMdWitnessError { pub(crate) enum DiscordMdWitnessError {
#[error("formatting failed")] #[error("Formatting failed.")]
Format(#[from] std::fmt::Error), Format(#[from] std::fmt::Error),
} }
impl Default for DiscordMdWitness { impl DiscordMd {
fn default() -> Self { pub(crate) fn create_witness(md: &Arc<RwLock<Self>>) -> DiscordMdWitness {
Self { let mut parent = md.write().unwrap();
buffer: String::new(), parent.buffers.push(String::new());
dice_written: 0, 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,73 +491,87 @@ impl Witness for DiscordMdWitness {
type WitnessSet<'a> = DiscordMdWitnessSet<'a>; type WitnessSet<'a> = DiscordMdWitnessSet<'a>;
fn witness_source_text(&mut self, text: &str) -> Result<Self::Ok, Self::Error> { fn witness_source_text(&mut self, text: &str) -> Result<Self::Ok, Self::Error> {
self.buffer.push_str(text); self.parent.write().unwrap().buffers[self.buffer_idx].push_str(text);
Ok(()) Ok(())
} }
fn witness_set(&mut self) -> Result<Self::WitnessSet<'_>, Self::Error> { fn witness_set(&mut self) -> Result<Self::WitnessSet<'_>, Self::Error> {
write!(self.buffer, "‹ ")?; write!(self.parent.write().unwrap().buffers[self.buffer_idx], "‹ ")?;
Ok(DiscordMdWitnessSet { Ok(DiscordMdWitnessSet {
parent: self, witness: self,
index: 0, index: 0,
dice_elided: false, dice_elided: false,
}) })
} }
fn witness_total_result(&mut self, result: f64) -> Result<Self::Ok, Self::Error> {
self.result = Some(result);
Ok(())
}
} }
impl<'a> WitnessSet for DiscordMdWitnessSet<'a> { impl<'a> WitnessSet for DiscordMdWitnessSet<'a> {
type Ok = (); type Ok = ();
type Error = DiscordMdWitnessError; type Error = DiscordMdWitnessError;
fn witness_roll(&mut self, dice: DiceRoll) -> Result<Self::Ok, Self::Error> { fn witness_roll(&mut self, dice: DiceRoll) -> Result<Self::Ok, Self::Error> {
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 { if !self.dice_elided {
write!(self.parent.buffer, "…")?; write!(
parent.buffers[self.witness.buffer_idx],
"…"
)?;
self.dice_elided = true; self.dice_elided = true;
} }
return Ok(()); return Ok(());
} }
let buf = &mut parent.buffers[self.witness.buffer_idx];
let is_extreme = dice.size >= 4 && (dice.roll == 1 || dice.roll == dice.size); let is_extreme = dice.size >= 4 && (dice.roll == 1 || dice.roll == dice.size);
// A visible separator is required despite the monospace boxing, // A visible separator is required despite the monospace boxing,
// due to iOS Discord being weird. // due to iOS Discord being weird.
if self.index > 0 { if self.index > 0 {
write!(self.parent.buffer, ",")?; write!(buf, ",")?;
} }
if !dice.is_admitted { if !dice.is_admitted {
write!(self.parent.buffer, "~~")?; write!(buf, "~~")?;
} }
if dice.is_from_proliferation { if dice.is_from_proliferation {
write!(self.parent.buffer, "*")?; write!(buf, "*")?;
} }
if is_extreme { if is_extreme {
write!(self.parent.buffer, "**")?; write!(buf, "**")?;
} }
if dice.did_proliferate { if dice.did_proliferate {
write!(self.parent.buffer, "__")?; write!(buf, "__")?;
} }
write!(self.parent.buffer, "`{}`", dice.roll)?; write!(buf, "`{}`", dice.roll)?;
if dice.did_proliferate { if dice.did_proliferate {
write!(self.parent.buffer, "__")?; write!(buf, "__")?;
} }
if is_extreme { if is_extreme {
write!(self.parent.buffer, "**")?; write!(buf, "**")?;
} }
if dice.is_from_proliferation { if dice.is_from_proliferation {
write!(self.parent.buffer, "*")?; write!(buf, "*")?;
} }
if !dice.is_admitted { if !dice.is_admitted {
write!(self.parent.buffer, "~~")?; write!(buf, "~~")?;
} }
self.index += 1; self.index += 1;
self.parent.dice_written += 1; parent.dice_written += 1;
Ok(()) Ok(())
} }
fn end(self) -> Result<Self::Ok, Self::Error> { fn end(self) -> Result<Self::Ok, Self::Error> {
write!(self.parent.buffer, " ›")?; write!(
self.witness.parent.write().unwrap().buffers[self.witness.buffer_idx],
" ›"
)?;
Ok(()) Ok(())
} }
} }
@ -553,6 +591,10 @@ impl Witness for std::io::Sink {
fn witness_set(&mut self) -> Result<Self::WitnessSet<'_>, Self::Error> { fn witness_set(&mut self) -> Result<Self::WitnessSet<'_>, Self::Error> {
Ok(self) Ok(self)
} }
fn witness_total_result(&mut self, _result: f64) -> Result<Self::Ok, Self::Error> {
Ok(())
}
} }
impl WitnessSet for &mut std::io::Sink { impl WitnessSet for &mut std::io::Sink {

4
src/limits.rs Normal file
View file

@ -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;

View file

@ -1,13 +1,18 @@
mod dice; mod dice;
mod limits;
mod parsing; mod parsing;
use anyhow::bail; use crate::dice::{DiscordMd, Witness};
use anyhow::{Context as AnyCtx, bail};
use dotenv::dotenv; use dotenv::dotenv;
use limits::*;
use rand::{Rng, SeedableRng}; 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::{ use serenity::builder::{
CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse, CreateCommand, CreateContainer, CreateContainerComponent, CreateInteractionResponse, CreateInteractionResponseMessage, CreateTextDisplay,
CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateTextDisplay,
}; };
use serenity::{async_trait, prelude::*}; use serenity::{async_trait, prelude::*};
use std::sync::Arc; use std::sync::Arc;
@ -98,7 +103,7 @@ impl EventHandler for Handler {
.add_context(InteractionContext::BotDm) .add_context(InteractionContext::BotDm)
.add_context(InteractionContext::PrivateChannel), .add_context(InteractionContext::PrivateChannel),
) )
.await; .await;
debug!("I created the following global slash command: {global_command:#?}"); 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<()> { pub async fn roll(ctx: &Context, command: &CommandInteraction) -> anyhow::Result<()> {
let options = command.data.options(); pub async fn inner(ctx: &Context, command: &CommandInteraction) -> anyhow::Result<()> {
let Some(ResolvedOption { let options = command.data.options();
value: ResolvedValue::String(formula), let Some(ResolvedOption {
.. value: ResolvedValue::String(formula),
}) = options.iter().find(|option| option.name == "formula") ..
else { }) = options.iter().find(|option| option.name == "formula")
bail!("Formula was not provided.") 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::<u64>().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);
}
}; };
text_components.push(CreateContainerComponent::TextDisplay( let repeat = options
CreateTextDisplay::new(format!("{}\n", result)), .iter()
)); .find(|option| option.name == "repeat")
} .and_then(|option| {
if fixed_seed.is_some() { if let ResolvedValue::Integer(repeat) = option.value {
text_components.push(CreateContainerComponent::TextDisplay( Some(repeat)
CreateTextDisplay::new("***⚠️ This is not a fair roll. The result was generated based on a fixed seed and is thus fully reproducible.***".to_string()), } else {
)); None
} }
text_components.push(CreateContainerComponent::TextDisplay( })
CreateTextDisplay::new(format!("-# Expected value: {ev} | Seed: {seed}")), .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::<u64>().ok());
command let expression = parsing::parse(&formula).context("Dice formula could not be parsed.")?;
.create_followup(
&ctx.http, let seed = fixed_seed.unwrap_or_else(|| rand::rng().next_u64());
CreateInteractionResponseFollowup::new() 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::<Vec<_>>();
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.components(vec![CreateComponent::Container(CreateContainer::new( .components(vec![CreateComponent::Container(CreateContainer::new(
text_components, text_components,
))]) ))])
.ephemeral(private) .ephemeral(private)
.flags(MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS), .flags(
) MessageFlags::IS_COMPONENTS_V2 | MessageFlags::SUPPRESS_NOTIFICATIONS,
.await?; ),
);
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(()) Ok(())
} }