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::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 fails_on_wrong_order() {
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, "client3", 10),
195        ];
196
197        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client2", 0)).bloops(bloops);
198
199        let evaluator =
200            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(15)).unwrap();
201        assert_eq!(
202            evaluator.evaluate(&builder.build()).into(),
203            EvalResult::NoAward
204        );
205    }
206
207    #[test]
208    fn fails_if_current_id_mismatch() {
209        let word = "abc";
210        let client_map = char_map! {
211            'a' => "client1",
212            'b' => "client2",
213            'c' => "client3"
214        };
215
216        let (player, _) = MockPlayer::builder().build();
217        let bloops = vec![
218            make_bloop(&player, "client1", 20),
219            make_bloop(&player, "client2", 10),
220        ];
221
222        let mut builder =
223            TestCtxBuilder::new(make_bloop(&player, "wrong_client", 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_too_slow() {
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 player = player.clone();
244        let bloops = vec![
245            make_bloop(&player, "client1", 100),
246            make_bloop(&player, "client2", 50),
247        ];
248
249        let mut builder = TestCtxBuilder::new(make_bloop(&player, "client3", 0)).bloops(bloops);
250
251        let evaluator =
252            SpellingBeeEvaluator::new(word, &client_map, Duration::from_secs(10)).unwrap();
253        assert_eq!(
254            evaluator.evaluate(&builder.build()).into(),
255            EvalResult::NoAward
256        );
257    }
258
259    #[test]
260    fn build_fails_if_unregistered_char() {
261        let client_map = char_map! {
262            'a' => "client1",
263            'b' => "client2"
264        };
265
266        let result = SpellingBeeEvaluator::new("abc", &client_map, Duration::from_secs(5));
267        assert!(matches!(result, Err(Error::UnregisteredCharacter('c'))));
268    }
269
270    #[test]
271    fn build_fails_if_word_too_short() {
272        let client_map = HashMap::new();
273
274        let result = SpellingBeeEvaluator::new("", &client_map, Duration::from_secs(1));
275        assert!(matches!(result, Err(Error::WordTooShort)));
276    }
277
278    #[test]
279    fn build_fails_if_word_too_long() {
280        let client_map = ('a'..='z').map(|c| (c, "client")).collect();
281        let long_word: String = std::iter::repeat('a').take(1_000).collect();
282
283        let result = SpellingBeeEvaluator::new(&long_word, &client_map, Duration::from_secs(1));
284        assert!(matches!(result, Err(Error::WordTooLong)));
285    }
286}