bloop_server_framework/evaluator/
spelling_bee.rs1use 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_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#[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 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 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 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}