Bound dice batches, game count, and report/help queues, and harden community votekick
This commit is contained in:
parent
64c4bbeae4
commit
b2ffc01e52
7 changed files with 59 additions and 7 deletions
|
|
@ -9,7 +9,7 @@ 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
|
||||
pub 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 {
|
||||
|
|
@ -21,6 +21,7 @@ pub struct Roll {
|
|||
pub struct Evaluated {
|
||||
pub value: f64,
|
||||
pub rolls: Vec<Roll>,
|
||||
pub total: u64, // dice rolled in this evaluation, for cross-repeat budgeting
|
||||
}
|
||||
|
||||
// ---- tokens ----------------------------------------------------------------
|
||||
|
|
@ -230,7 +231,7 @@ pub fn evaluate(input: &str, rng: &mut impl Rng) -> Result<Evaluated, String> {
|
|||
if !value.is_finite() {
|
||||
return Err("that doesn't come out to a real number".to_string());
|
||||
}
|
||||
Ok(Evaluated { value, rolls })
|
||||
Ok(Evaluated { value, rolls, total: total_dice })
|
||||
}
|
||||
|
||||
fn eval(ast: &Ast, rng: &mut impl Rng, rolls: &mut Vec<Roll>, total: &mut u64) -> Result<f64, String> {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte
|
|||
};
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut spent = 0u64; // dice rolled across the whole repeat batch
|
||||
for i in 0..times {
|
||||
match expr::evaluate(body, &mut rng) {
|
||||
Ok(ev) => {
|
||||
spent += ev.total;
|
||||
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() {
|
||||
|
|
@ -38,6 +40,14 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte
|
|||
String::new()
|
||||
};
|
||||
ctx.notice(me, from.uid, format!("{prefix}\x02{body}\x02 = \x02{result}\x02{detail}"));
|
||||
// Bound total work across the whole `N~` batch, not per-evaluate, so
|
||||
// `25~99999d1+…` can't multiply the per-roll cap into tens of millions.
|
||||
if spent >= expr::MAX_TOTAL {
|
||||
if i + 1 < times {
|
||||
ctx.notice(me, from.uid, "That's a lot of dice — stopping the repeats here.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(why) => {
|
||||
ctx.notice(me, from.uid, format!("I couldn't roll \x02{body}\x02: {why}."));
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ impl GameServ {
|
|||
ctx.notice(me, from.uid, "You cannot challenge yourself.");
|
||||
return;
|
||||
}
|
||||
// Global cap: the per-user cap keys on a churnable identity (a guest's nick),
|
||||
// so a single connection could otherwise cycle nicks to grow the games map
|
||||
// without bound. Bounding the total makes that impossible regardless.
|
||||
const MAX_GAMES_TOTAL: usize = 500;
|
||||
if self.games.len() >= MAX_GAMES_TOTAL {
|
||||
ctx.notice(me, from.uid, "The game server is at capacity right now. Please try again shortly.");
|
||||
return;
|
||||
}
|
||||
let meid = Self::ident(from).to_string();
|
||||
let mine = self.games.values().filter(|g| g.acc_a == meid || g.acc_b == meid).count();
|
||||
if mine >= MAX_GAMES_PER_USER {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db:
|
|||
ctx.notice(me, from.uid, "Tell us what you need help with: REQUEST <message>");
|
||||
return;
|
||||
}
|
||||
let requester = from.account.unwrap_or(from.nick);
|
||||
// Require identification, so the cooldown keys on a stable account rather than a
|
||||
// spoofable nick a flooder can cycle to reset it.
|
||||
let Some(requester) = from.account else {
|
||||
ctx.notice(me, from.uid, "Please identify to NickServ before opening a ticket.");
|
||||
return;
|
||||
};
|
||||
match db.help_request(requester, &message) {
|
||||
Some(id) => ctx.notice(me, from.uid, format!("Thanks — your request (\x02#{id}\x02) is in the queue. A staff member will be with you.")),
|
||||
None => ctx.notice(me, from.uid, "You just opened a ticket — please wait a moment before opening another."),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db:
|
|||
return;
|
||||
}
|
||||
let reason = reason_words.join(" ");
|
||||
let reporter = from.account.unwrap_or(from.nick);
|
||||
// Require identification, so the cooldown keys on a stable account rather than a
|
||||
// spoofable nick a flooder can cycle to reset it.
|
||||
let Some(reporter) = from.account else {
|
||||
ctx.notice(me, from.uid, "Please identify to NickServ before filing a report.");
|
||||
return;
|
||||
};
|
||||
match db.report_file(reporter, target, &reason) {
|
||||
Some(id) => ctx.notice(me, from.uid, format!("Thanks — your report (\x02#{id}\x02) about \x02{target}\x02 has been sent to the staff.")),
|
||||
None => ctx.notice(me, from.uid, "You just filed a report — please wait a moment before filing another."),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
use super::*;
|
||||
|
||||
// Bound a moderation queue (reports / help tickets): when it's at the cap, drop the
|
||||
// oldest resolved entry — or the oldest entry if all are still open — so a flood
|
||||
// can't grow it without limit. Called before each push.
|
||||
fn prune_queue<T>(q: &mut Vec<T>, is_open: impl Fn(&T) -> bool) {
|
||||
const MAX_QUEUE: usize = 2000;
|
||||
if q.len() < MAX_QUEUE {
|
||||
return;
|
||||
}
|
||||
let pos = q.iter().position(|e| !is_open(e)).unwrap_or(0);
|
||||
q.remove(pos);
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Add (or refresh) a `kind` network ban. Returns whether it was newly added.
|
||||
pub fn akill_add(&mut self, kind: XlineKind, mask: &str, setter: &str, reason: &str, expires: Option<u64>) -> Result<bool, RegError> {
|
||||
|
|
@ -456,6 +468,7 @@ impl Db {
|
|||
return None;
|
||||
}
|
||||
self.report_times.insert(key, now);
|
||||
prune_queue(&mut self.net.reports, |r| r.open);
|
||||
let id = self.net.report_seq;
|
||||
let _ = self.log.append(Event::ReportFiled { id, reporter: reporter.to_string(), target: target.to_string(), reason: reason.to_string(), ts: now });
|
||||
self.net.report_seq = id + 1;
|
||||
|
|
@ -511,6 +524,7 @@ impl Db {
|
|||
return None;
|
||||
}
|
||||
self.report_times.insert(tkey, now);
|
||||
prune_queue(&mut self.net.help, |h| h.open);
|
||||
let id = self.net.help_seq;
|
||||
let _ = self.log.append(Event::HelpRequested { id, requester: requester.to_string(), message: message.to_string(), ts: now });
|
||||
self.net.help_seq = id + 1;
|
||||
|
|
|
|||
|
|
@ -213,16 +213,25 @@ impl Engine {
|
|||
};
|
||||
let target_nick = target_raw.trim();
|
||||
if target_nick.is_empty() || target_nick.contains(' ') {
|
||||
return Some(Vec::new());
|
||||
return None; // not a real vote — let the line reach the flood/badword kicker
|
||||
}
|
||||
let threshold = self.db.channel(channel).map(|c| c.kickers.votekick).unwrap_or(0);
|
||||
let botnick = self.db.channel(channel).and_then(|c| c.assigned_bot.clone());
|
||||
let (Some(botnick), true) = (botnick, threshold > 0) else { return Some(Vec::new()) };
|
||||
let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return Some(Vec::new()) };
|
||||
let (Some(botnick), true) = (botnick, threshold > 0) else { return None };
|
||||
let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return None };
|
||||
let Some(target_uid) = self.network.uid_by_nick(target_nick).map(str::to_string) else {
|
||||
return Some(vec![NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("There's no \x02{target_nick}\x02 here to vote on.") }]);
|
||||
};
|
||||
let target_display = self.network.nick_of(&target_uid).unwrap_or(target_nick).to_string();
|
||||
// Never let the community vote out a service bot, an oper, or a user with
|
||||
// channel op-access — moderation authority isn't a valid vote target.
|
||||
let target_account = self.network.account_of(&target_uid).map(str::to_string);
|
||||
let is_bot = self.bot_uids.values().any(|b| b == &target_uid);
|
||||
let is_oper = target_account.as_deref().is_some_and(|a| self.oper_privs(a).any());
|
||||
let is_chanop = target_account.as_deref().is_some_and(|a| self.db.channel(channel).is_some_and(|c| c.is_op(a)));
|
||||
if is_bot || is_oper || is_chanop {
|
||||
return Some(vec![NetAction::Privmsg { from: botuid.clone(), to: channel.to_string(), text: format!("\x02{target_display}\x02 can't be voted out.") }]);
|
||||
}
|
||||
let now = self.now_secs();
|
||||
let key = (channel.to_ascii_lowercase(), target_display.to_ascii_lowercase());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue