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())
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::char_map;
143    use crate::evaluator::{EvalResult, Evaluator};
144    use crate::test_utils::{MockPlayer, TestCtxBuilder};
145    use chrono::Utc;
146    use std::collections::HashMap;
147    use std::sync::{Arc, RwLock};
148    use std::time::Duration;
149
150    fn make_bloop(
151        player: &Arc<RwLock<MockPlayer>>,
152        client_id: &str,
153        offset_secs: u64,
154    ) -> Bloop<MockPlayer> {
155        let recorded_at = Utc::now() - Duration::from_secs(offset_secs);
156        Bloop::new(player.clone(), client_id.to_string(), recorded_at)
157    }
158
159    #[test]
160    fn matches_correct_sequence() {
161        let word = "hey";
162        let client_map = char_map! {
163            'h' => "client1",
164            'e' => "client2",
165            'y' => "client3"
166        };
167
168        let (player, _) = MockPlayer::builder().build();
169        let bloops = vec![
170            make_bloop(&player, "client1", 20),
171            make_bloop(&player, "client2", 10),
172        ];
173        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client3", 0)).bloops(bloops);
174
175        let evaluator =
176            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
177        assert_eq!(
178            evaluator.evaluate(&builder.build()).into(),
179            EvalResult::AwardSelf
180        );
181    }
182
183    #[test]
184    fn fails_on_wrong_order() {
185        let word = "hey";
186        let client_map = char_map! {
187            'h' => "client1",
188            'e' => "client2",
189            'y' => "client3"
190        };
191
192        let (player, _) = MockPlayer::builder().build();
193        let bloops = vec![
194            make_bloop(&player, "client1", 20),
195            make_bloop(&player, "client3", 10),
196        ];
197
198        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client2", 0)).bloops(bloops);
199
200        let evaluator =
201            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
202        assert_eq!(
203            evaluator.evaluate(&builder.build()).into(),
204            EvalResult::NoAward
205        );
206    }
207
208    #[test]
209    fn fails_if_current_id_mismatch() {
210        let word = "abc";
211        let client_map = char_map! {
212            'a' => "client1",
213            'b' => "client2",
214            'c' => "client3"
215        };
216
217        let (player, _) = MockPlayer::builder().build();
218        let bloops = vec![
219            make_bloop(&player, "client1", 20),
220            make_bloop(&player, "client2", 10),
221        ];
222
223        let mut builder =
224            TestCtxBuilder::new(make_bloop(&player, "wrong_client", 0)).bloops(bloops);
225
226        let evaluator =
227            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
228        assert_eq!(
229            evaluator.evaluate(&builder.build()).into(),
230            EvalResult::NoAward
231        );
232    }
233
234    #[test]
235    fn fails_if_too_slow() {
236        let word = "abc";
237        let client_map = char_map! {
238            'a' => "client1",
239            'b' => "client2",
240            'c' => "client3"
241        };
242
243        let (player, _) = MockPlayer::builder().build();
244        let player = player.clone();
245        let bloops = vec![
246            make_bloop(&player, "client1", 100),
247            make_bloop(&player, "client2", 50),
248        ];
249
250        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client3", 0)).bloops(bloops);
251
252        let evaluator =
253            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(10)).unwrap();
254        assert_eq!(
255            evaluator.evaluate(&builder.build()).into(),
256            EvalResult::NoAward
257        );
258    }
259
260    #[test]
261    fn build_fails_if_unregistered_char() {
262        let client_map = char_map! {
263            'a' => "client1",
264            'b' => "client2"
265        };
266
267        let result = SpellingBeeEvaluator::new("abc", &client_map, Duration::from_secs(5));
268        assert!(matches!(result, Err(Error::UnregisteredCharacter('c'))));
269    }
270
271    #[test]
272    fn build_fails_if_word_too_short() {
273        let client_map = HashMap::new();
274
275        let result = SpellingBeeEvaluator::new("", &client_map, Duration::from_secs(1));
276        assert!(matches!(result, Err(Error::WordTooShort)));
277    }
278
279    #[test]
280    fn build_fails_if_word_too_long() {
281        let client_map = ('a'..='z').map(|c| (c, "client")).collect();
282        let long_word: String = std::iter::repeat('a').take(1_000).collect();
283
284        let result = SpellingBeeEvaluator::new(&long_word, &client_map, Duration::from_secs(1));
285        assert!(matches!(result, Err(Error::WordTooLong)));
286    }
287}