rustacean_roulette/
lib.rs

1mod commands;
2mod constants;
3
4pub use commands::Commands;
5use frankenstein::{
6    client_reqwest::Bot, methods::{DeleteMyCommandsParams, SetMyCommandsParams, SetMyDefaultAdministratorRightsParams}, types::BotCommandScope, AsyncTelegramApi, Error
7};
8use rand::{Rng, seq::index::sample};
9use serde::Deserialize;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// Configuration for the bot.
13#[derive(Deserialize)]
14pub struct Config {
15    /// The token for the bot.
16    pub token: String,
17    /// The configuration for the Russian Roulette game.
18    #[serde(default)]
19    pub game: RouletteConfig,
20    /// The override configuration for groups.
21    #[serde(default)]
22    pub groups: Vec<GroupConfig>,
23}
24
25/// Configuration for the Russian Roulette game.
26#[derive(Clone, Debug, Deserialize)]
27pub struct RouletteConfig {
28    /// Number of chambers in the revolver.
29    #[serde(default = "constants::chambers")]
30    chambers: usize,
31    /// Number of bullets in the revolver.
32    #[serde(default = "constants::bullets")]
33    bullets: usize,
34    /// Probability of the gun getting jammed.
35    #[serde(default = "constants::jam_probability")]
36    jam_probability: f64,
37    /// Minimum time to mute in seconds.
38    #[serde(default = "constants::min_mute_time")]
39    min_mute_time: u32,
40    /// Maximum time to mute in seconds.
41    #[serde(default = "constants::max_mute_time")]
42    max_mute_time: u32,
43}
44
45impl RouletteConfig {
46    /// Starts a new game of Russian Roulette.
47    pub fn start(self) -> Result<Roulette, &'static str> {
48        // Sanity check
49        if self.chambers <= 0 {
50            return Err("Number of chambers must be greater than 0");
51        }
52        if self.bullets <= 0 {
53            return Err("Number of bullets must be greater than 0");
54        }
55        if self.bullets > self.chambers {
56            return Err("Number of bullets must be less than or equal to number of chambers");
57        }
58        if self.min_mute_time < 30 {
59            return Err("Minimum mute time must be greater than or equal to 30 seconds");
60        }
61        if self.max_mute_time > 3600 {
62            // FIXME: 365 days
63            return Err("Maximum mute time must be less than or equal to 3600 seconds");
64        }
65        if self.min_mute_time > self.max_mute_time {
66            return Err("Minimum mute time must be less than or equal to maximum mute time");
67        }
68
69        // Initialize the contents of the chambers
70        let contents = vec![false; self.chambers];
71        let mut roulette = Roulette {
72            config: self,
73            contents,
74            position: 0,
75        };
76        roulette.reload();
77
78        Ok(roulette)
79    }
80
81    /// Get the number of bullets and chambers.
82    pub fn info(&self) -> (usize, usize) {
83        (self.bullets, self.chambers)
84    }
85
86    /// Generate a random mute time and the time until which the user will be muted.
87    pub fn random_mute_until(&self) -> (u64, u64) {
88        // Generate a random mute time between min and max
89        let mut rng = rand::rng();
90        let duration: u64 = rng
91            .random_range(self.min_mute_time..=self.max_mute_time)
92            .into();
93        // Convert to seconds and add to current time
94        let now = SystemTime::now()
95            .duration_since(UNIX_EPOCH)
96            .expect("Time went backwards")
97            .as_secs();
98        (duration, now + duration)
99    }
100}
101
102impl Default for RouletteConfig {
103    fn default() -> Self {
104        Self {
105            chambers: constants::chambers(),
106            bullets: constants::bullets(),
107            jam_probability: constants::jam_probability(),
108            min_mute_time: constants::min_mute_time(),
109            max_mute_time: constants::max_mute_time(),
110        }
111    }
112}
113
114/// A Russian Roulette game.
115#[derive(Clone, Debug)]
116pub struct Roulette {
117    /// Configuration for the game.
118    config: RouletteConfig,
119    /// An array of boolean values representing the contents of the chambers. `true` means the chamber is loaded with a bullet, `false` means it is empty.
120    contents: Vec<bool>,
121    /// The current chamber index.
122    position: usize,
123}
124
125impl Roulette {
126    /// Reload the revolver.
127    pub fn reload(&mut self) {
128        self.position = 0;
129        self.contents.fill(false);
130
131        // Randomly choose `bullets` chambers to be loaded with bullets.
132        let mut rng = rand::rng();
133        let selected = sample(&mut rng, self.contents.len(), self.config.bullets);
134        for i in selected {
135            self.contents[i] = true;
136        }
137    }
138
139    /// Get the number of bullets and chambers.
140    pub fn info(&self) -> (usize, usize) {
141        self.config.info()
142    }
143
144    /// Generate a random mute time and the time until which the user will be muted.
145    pub fn random_mute_until(&self) -> (u64, u64) {
146        self.config.random_mute_until()
147    }
148
149    /// Try to fire the current chamber.
150    ///
151    /// - If the chamber is loaded with a bullet, return `Some(true)`
152    /// - If the chamber is empty, return `Some(false)`
153    /// - If we have fired all filled chambers, return `None`
154    pub fn fire(&mut self) -> FireResult {
155        if self.peek().0 == 0 {
156            // No filled chambers left
157            return FireResult::NoBullets;
158        }
159
160        // Check if the gun is jammed
161        let jammed = rand::rng().random_bool(self.config.jam_probability);
162        if jammed {
163            return FireResult::Jammed;
164        }
165
166        let result = self.contents[self.position];
167        self.position += 1;
168
169        if result {
170            FireResult::Bullet
171        } else {
172            FireResult::Empty
173        }
174    }
175
176    /// Peek the left-over chambers, returning count of filled and left chambers.
177    pub fn peek(&self) -> (usize, usize) {
178        let filled = self
179            .contents[self.position..]
180            .iter()
181            .filter(|&&x| x)
182            .count();
183        let left = self.contents.len() - self.position;
184        (filled, left)
185    }
186}
187
188/// Result of firing the revolver.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum FireResult {
191    /// The chamber was empty.
192    Empty,
193    /// The chamber was loaded with a bullet.
194    Bullet,
195    /// The gun got jammed.
196    Jammed,
197    /// No more bullets left.
198    NoBullets,
199}
200
201/// Configuration for a group.
202#[derive(Debug, Deserialize)]
203pub struct GroupConfig {
204    /// The ID of the group.
205    pub id: i64,
206    /// Override number of chambers in the revolver.
207    chambers: Option<usize>,
208    /// Override number of bullets in the revolver.
209    bullets: Option<usize>,
210    /// Override probability of the gun getting jammed.
211    jam_probability: Option<f64>,
212    /// Override minimum time to mute in seconds.
213    min_mute_time: Option<u32>,
214    /// Override maximum time to mute in seconds.
215    max_mute_time: Option<u32>,
216}
217
218impl GroupConfig {
219    /// Resolves to a [`RouletteConfig`].
220    pub fn resolve(&self, default: &RouletteConfig) -> RouletteConfig {
221        let Self {
222            chambers,
223            bullets,
224            jam_probability,
225            min_mute_time,
226            max_mute_time,
227            ..
228        } = self;
229        let (chambers, bullets, jam_probability, min_mute_time, max_mute_time) = (
230            chambers.unwrap_or(default.chambers),
231            bullets.unwrap_or(default.bullets),
232            jam_probability.unwrap_or(default.jam_probability),
233            min_mute_time.unwrap_or(default.min_mute_time),
234            max_mute_time.unwrap_or(default.max_mute_time),
235        );
236        RouletteConfig {
237            chambers,
238            bullets,
239            jam_probability,
240            min_mute_time,
241            max_mute_time,
242        }
243    }
244}
245
246/// Set commands and default admin rights for the bot.
247pub async fn init_commands_and_rights(bot: &Bot) -> Result<(), Error> {
248    let delete_param = DeleteMyCommandsParams::builder().build();
249    bot.delete_my_commands(&delete_param).await?;
250
251    let commands_param = SetMyCommandsParams::builder()
252        .commands(Commands::list())
253        .scope(BotCommandScope::AllGroupChats)
254        .build();
255    bot.set_my_commands(&commands_param).await?;
256
257    let rights_param = SetMyDefaultAdministratorRightsParams::builder()
258        .rights(constants::RECOMMENDED_ADMIN_RIGHTS)
259        .build();
260    bot.set_my_default_administrator_rights(&rights_param)
261        .await?;
262
263    Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_fire() {
272        let config = RouletteConfig {
273            chambers: 3,
274            bullets: 1,
275            jam_probability: 0.0, // For testing purposes
276            min_mute_time: 60,
277            max_mute_time: 600,
278        };
279        // let mut roulette = config.start().unwrap();
280        let mut roulette = Roulette {
281            config,
282            contents: vec![false, true, false],
283            position: 0,
284        };
285
286        assert_eq!(roulette.fire(), FireResult::Empty);
287        assert_eq!(roulette.fire(), FireResult::Bullet);
288        assert_eq!(roulette.fire(), FireResult::NoBullets);
289        assert_eq!(roulette.fire(), FireResult::NoBullets);
290    }
291
292    #[test]
293    fn test_restart() {
294        let mut roulette = RouletteConfig::default().start().unwrap();
295
296        for _ in 0..10 {
297            roulette.reload();
298        }
299
300        assert_eq!(roulette.contents.len(), 6);
301        assert_eq!(roulette.peek().0, 2);
302        assert_eq!(roulette.position, 0);
303    }
304}