Skip to main content

bloop_server_framework/evaluator/
spelling_bee.rs

1use crate::achievement::AchievementContext;
2use crate::evaluator::{EvalResult, Evaluator};
3use std::collections::HashMap;
4use std::fmt::Debug;
5use std::time::Duration;
6use thiserror::Error;
7
8/// Macro to conveniently create a [`HashMap<char, &str>`] mapping characters to
9/// client IDs.
10///
11/// # Examples
12///
13/// ```
14/// use bloop_server_framework::char_map;
15///
16/// let letters = char_map! {
17///     'a' => "client1",
18///     'b' => "client2",
19///     'c' => "client3",
20/// };
21/// ```
22#[macro_export]
23macro_rules! char_map {
24    ($($char:literal => $client_id:expr),* $(,)?) => {{
25        let mut map = ::std::collections::HashMap::new();
26        $(
27            map.insert($char, $client_id);
28        )*
29        map
30    }};
31}
32
33/// Evaluates whether a player has correctly spelled a given word within a time
34/// limit.
35///
36/// The evaluator uses a mapping of characters to client IDs and expects the
37/// player to "bloop" each letter in the word in the correct order, within a
38/// time window calculated as `time_per_character * word.len()`.
39#[derive(Debug)]
40pub struct SpellingBeeEvaluator {
41    client_ids: Vec<String>,
42    max_time: Duration,
43}
44
45#[derive(Debug, Error)]
46pub enum Error {
47    #[error("the character '{0}' does not exist in character map")]
48    UnregisteredCharacter(char),
49    #[error("the provided word must be at least one character")]
50    WordTooShort,
51    #[error("the provided word is too long")]
52    WordTooLong,
53}
54
55type Result<T> = std::result::Result<T, Error>;
56
57impl SpellingBeeEvaluator {
58    /// Creates a new [`SpellingBeeEvaluator`].
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use std::time::Duration;
64    /// use bloop_server_framework::evaluator::spelling_bee::SpellingBeeEvaluator;
65    /// use bloop_server_framework::char_map;
66    ///
67    /// let characters = char_map! {
68    ///     'h' => "clientA",
69    ///     'e' => "clientB",
70    ///     'l' => "clientC",
71    ///     'o' => "clientD",
72    /// };
73    ///
74    /// let evaluator = SpellingBeeEvaluator::new(
75    ///     "hello",
76    ///     &characters,
77    ///     Duration::from_secs(60 * 2),
78    /// );
79    /// ```
80    pub fn new(
81        word: &str,
82        characters: &HashMap<char, &str>,
83        time_per_character: Duration,
84    ) -> Result<Self> {
85        let mut client_ids = Vec::with_capacity(word.len());
86
87        if word.is_empty() {
88            return Err(Error::WordTooShort);
89        }
90
91        if word.len() > 100 {
92            return Err(Error::WordTooLong);
93        }
94
95        let max_time = time_per_character
96            .checked_mul(word.len() as u32)
97            .ok_or(Error::WordTooLong)?;
98
99        for char in word.chars().rev() {
100            let Some(client_id) = characters.get(&char) else {
101                return Err(Error::UnregisteredCharacter(char));
102            };
103
104            client_ids.push(client_id.to_string());
105        }
106
107        Ok(Self {
108            client_ids,
109            max_time,
110        })
111    }
112}
113
114impl<Player, Metadata, Trigger> Evaluator<Player, Metadata, Trigger> for SpellingBeeEvaluator {
115    /// Evaluates if the player's recent bloops match the target spelling sequence.
116    ///
117    /// Checks that the bloops for letters happen in order within the allowed time
118    /// window.
119    fn evaluate(
120        &self,
121        ctx: &AchievementContext<Player, Metadata, Trigger>,
122    ) -> impl Into<EvalResult> {
123        if ctx.current_bloop.client_id != *self.client_ids.first().unwrap() {
124            return false;
125        }
126
127        let last_client_ids = ctx
128            .global_bloops()
129            .filter(ctx.filter_within_window(self.max_time))
130            .filter(ctx.filter_current_player())
131            .take(self.client_ids.len() - 1)
132            .map(|bloop| &bloop.client_id);
133
134        last_client_ids.eq(self.client_ids.iter().skip(1))
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::bloop::Bloop;
142    use crate::evaluator::{EvalResult, Evaluator};
143    use crate::test_utils::{MockPlayer, TestCtxBuilder};
144    use chrono::Utc;
145    use std::collections::HashMap;
146    use std::sync::{Arc, RwLock};
147    use std::time::Duration;
148
149    fn make_bloop(
150        player: &Arc<RwLock<MockPlayer>>,
151        client_id: &str,
152        offset_secs: u64,
153    ) -> Bloop<MockPlayer> {
154        let recorded_at = Utc::now() - Duration::from_secs(offset_secs);
155        Bloop::new(player.clone(), client_id.to_string(), recorded_at)
156    }
157
158    #[test]
159    fn matches_correct_sequence() {
160        let word = "hey";
161        let client_map = char_map! {
162            'h' => "client1",
163            'e' => "client2",
164            'y' => "client3"
165        };
166
167        let (player, _) = MockPlayer::builder().build();
168        let bloops = vec![
169            make_bloop(&player, "client1", 20),
170            make_bloop(&player, "client2", 10),
171        ];
172        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client3", 0)).bloops(bloops);
173
174        let evaluator =
175            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
176        assert_eq!(
177            evaluator.evaluate(&builder.build()).into(),
178            EvalResult::AwardSelf
179        );
180    }
181
182    #[test]
183    fn matches_correct_sequence_with_prior_bloops() {
184        let word = "hey";
185        let client_map = char_map! {
186            'h' => "client1",
187            'e' => "client2",
188            'y' => "client3"
189        };
190
191        let (player, _) = MockPlayer::builder().build();
192        let bloops = vec![
193            make_bloop(&player, "client1", 20),
194            make_bloop(&player, "client2", 10),
195            // Extra older bloop from a previous unrelated sequence
196            make_bloop(&player, "client3", 30),
197        ];
198        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client3", 0)).bloops(bloops);
199
200        let evaluator =
201            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(25)).unwrap();
202        assert_eq!(
203            evaluator.evaluate(&builder.build()).into(),
204            EvalResult::AwardSelf
205        );
206    }
207
208    #[test]
209    fn fails_on_wrong_order() {
210        let word = "hey";
211        let client_map = char_map! {
212            'h' => "client1",
213            'e' => "client2",
214            'y' => "client3"
215        };
216
217        let (player, _) = MockPlayer::builder().build();
218        let bloops = vec![
219            make_bloop(&player, "client1", 20),
220            make_bloop(&player, "client3", 10),
221        ];
222
223        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client2", 0)).bloops(bloops);
224
225        let evaluator =
226            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
227        assert_eq!(
228            evaluator.evaluate(&builder.build()).into(),
229            EvalResult::NoAward
230        );
231    }
232
233    #[test]
234    fn fails_if_current_id_mismatch() {
235        let word = "abc";
236        let client_map = char_map! {
237            'a' => "client1",
238            'b' => "client2",
239            'c' => "client3"
240        };
241
242        let (player, _) = MockPlayer::builder().build();
243        let bloops = vec![
244            make_bloop(&player, "client1", 20),
245            make_bloop(&player, "client2", 10),
246        ];
247
248        let mut builder =
249            TestCtxBuilder::new(make_bloop(&player, "wrong_client", 0)).bloops(bloops);
250
251        let evaluator =
252            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
253        assert_eq!(
254            evaluator.evaluate(&builder.build()).into(),
255            EvalResult::NoAward
256        );
257    }
258
259    #[test]
260    fn fails_if_too_slow() {
261        let word = "abc";
262        let client_map = char_map! {
263            'a' => "client1",
264            'b' => "client2",
265            'c' => "client3"
266        };
267
268        let (player, _) = MockPlayer::builder().build();
269        let player = player.clone();
270        let bloops = vec![
271            make_bloop(&player, "client1", 100),
272            make_bloop(&player, "client2", 50),
273        ];
274
275        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client3", 0)).bloops(bloops);
276
277        let evaluator =
278            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(10)).unwrap();
279        assert_eq!(
280            evaluator.evaluate(&builder.build()).into(),
281            EvalResult::NoAward
282        );
283    }
284
285    #[test]
286    fn build_fails_if_unregistered_char() {
287        let client_map = char_map! {
288            'a' => "client1",
289            'b' => "client2"
290        };
291
292        let result = SpellingBeeEvaluator::new("abc", &client_map, Duration::from_secs(5));
293        assert!(matches!(result, Err(Error::UnregisteredCharacter('c'))));
294    }
295
296    #[test]
297    fn build_fails_if_word_too_short() {
298        let client_map = HashMap::new();
299
300        let result = SpellingBeeEvaluator::new("", &client_map, Duration::from_secs(1));
301        assert!(matches!(result, Err(Error::WordTooShort)));
302    }
303
304    #[test]
305    fn build_fails_if_word_too_long() {
306        let client_map = ('a'..='z').map(|c| (c, "client")).collect();
307        let long_word: String = std::iter::repeat('a').take(1_000).collect();
308
309        let result = SpellingBeeEvaluator::new(&long_word, &client_map, Duration::from_secs(1));
310        assert!(matches!(result, Err(Error::WordTooLong)));
311    }
312}