1use crate::bloop::{Bloop, BloopProvider, bloops_for_player, bloops_since};
8use crate::evaluator::EvalResult;
9use crate::evaluator::boxed::{DynEvaluator, IntoDynEvaluator};
10use crate::player::{PlayerInfo, PlayerMutator, PlayerRegistry};
11use crate::trigger::TriggerRegistry;
12use chrono::{DateTime, Utc};
13use serde::Serialize;
14use std::collections::{HashMap, HashSet};
15use std::fmt::Debug;
16use std::path::PathBuf;
17use std::sync::{Arc, Mutex};
18use std::time::Duration;
19use thiserror::Error;
20use tokio::sync::MutexGuard;
21use uuid::Uuid;
22
23#[derive(Debug, Error)]
25pub enum BuilderError {
26 #[error("missing field: {0}")]
28 MissingField(&'static str),
29}
30
31#[derive(Debug, Default)]
33pub struct AchievementBuilder<Player, State, Trigger, Metadata> {
34 id: Option<Uuid>,
35 evaluator: Option<Box<dyn DynEvaluator<Player, State, Trigger>>>,
36 metadata: Metadata,
37 audio_path: Option<PathBuf>,
38 hot_duration: Option<Duration>,
39}
40
41impl AchievementBuilder<(), (), (), ()> {
42 pub fn new() -> Self {
43 Self {
44 id: None,
45 evaluator: None,
46 metadata: (),
47 audio_path: None,
48 hot_duration: None,
49 }
50 }
51}
52
53impl<Player, State, Trigger, Metadata> AchievementBuilder<Player, State, Trigger, Metadata> {
54 pub fn id(mut self, id: Uuid) -> Self {
56 self.id = Some(id);
57 self
58 }
59
60 pub fn evaluator<P, S, T>(
62 self,
63 evaluator: impl IntoDynEvaluator<P, S, T>,
64 ) -> AchievementBuilder<P, S, T, Metadata> {
65 AchievementBuilder {
66 id: self.id,
67 evaluator: Some(evaluator.into_dyn_evaluator()),
68 metadata: self.metadata,
69 audio_path: self.audio_path,
70 hot_duration: self.hot_duration,
71 }
72 }
73
74 pub fn metadata<T>(self, metadata: T) -> AchievementBuilder<Player, State, Trigger, T> {
76 AchievementBuilder {
77 id: self.id,
78 evaluator: self.evaluator,
79 metadata,
80 audio_path: self.audio_path,
81 hot_duration: self.hot_duration,
82 }
83 }
84
85 pub fn audio_path<P: Into<PathBuf>>(mut self, audio_path: P) -> Self {
87 self.audio_path = Some(audio_path.into());
88 self
89 }
90
91 pub fn hot_duration(mut self, hot_duration: Duration) -> Self {
94 self.hot_duration = Some(hot_duration);
95 self
96 }
97
98 pub fn build(self) -> Result<Achievement<Metadata, Player, State, Trigger>, BuilderError> {
105 let id = self.id.ok_or(BuilderError::MissingField("id"))?;
106 let evaluator = self
107 .evaluator
108 .ok_or(BuilderError::MissingField("evaluator"))?;
109
110 Ok(Achievement {
111 id,
112 evaluator,
113 metadata: self.metadata,
114 audio_path: self.audio_path,
115 hot_duration: self.hot_duration,
116 })
117 }
118}
119
120#[derive(Debug)]
122pub struct Achievement<Metadata, Player, State, Trigger> {
123 pub id: Uuid,
125 pub metadata: Metadata,
127 pub audio_path: Option<PathBuf>,
129 pub evaluator: Box<dyn DynEvaluator<Player, State, Trigger>>,
131 pub hot_duration: Option<Duration>,
133}
134
135impl<Metadata, Player, State, Trigger> Achievement<Metadata, Player, State, Trigger> {
136 pub fn evaluate(&self, ctx: &AchievementContext<Player, State, Trigger>) {
141 match self.evaluator.evaluate(ctx) {
142 EvalResult::AwardSelf => {
143 ctx.award_achievement(self.id, ctx.current_bloop.player_id);
144 }
145 EvalResult::AwardMultiple(player_ids) => {
146 for player_id in player_ids {
147 ctx.award_achievement(self.id, player_id);
148 }
149 }
150 EvalResult::NoAward => {}
151 }
152 }
153}
154
155#[derive(Debug, Clone, Serialize)]
157pub struct PlayerAchievementAwards {
158 pub player_id: Uuid,
160 pub achievement_ids: Vec<Uuid>,
162}
163
164#[derive(Debug, Clone, Serialize)]
166pub struct AchievementAwardBatch {
167 pub awarded_at: DateTime<Utc>,
169 pub players: Vec<PlayerAchievementAwards>,
171}
172
173impl From<AwardedTracker> for AchievementAwardBatch {
174 fn from(tracker: AwardedTracker) -> Self {
175 Self {
176 awarded_at: tracker.awarded_at,
177 players: tracker
178 .awarded
179 .into_iter()
180 .map(|(player_id, achievements)| PlayerAchievementAwards {
181 player_id,
182 achievement_ids: achievements.into_iter().collect(),
183 })
184 .collect(),
185 }
186 }
187}
188
189#[derive(Debug)]
191pub(crate) struct AwardedTracker {
192 awarded_at: DateTime<Utc>,
193 awarded: HashMap<Uuid, HashSet<Uuid>>,
194}
195
196impl AwardedTracker {
197 fn new<Player>(current_bloop: &Bloop<Player>) -> Self {
198 Self {
199 awarded_at: current_bloop.recorded_at,
200 awarded: HashMap::new(),
201 }
202 }
203
204 pub fn add(&mut self, achievement_id: Uuid, player_id: Uuid) {
205 self.awarded
206 .entry(player_id)
207 .or_default()
208 .insert(achievement_id);
209 }
210
211 pub fn for_player(&self, player_id: Uuid) -> Option<&HashSet<Uuid>> {
212 self.awarded.get(&player_id)
213 }
214
215 pub fn for_player_mut(&mut self, player_id: Uuid) -> &mut HashSet<Uuid> {
216 self.awarded.entry(player_id).or_default()
217 }
218
219 pub fn remove_duplicates<Player: PlayerInfo + PlayerMutator>(
220 &mut self,
221 player_registry: MutexGuard<PlayerRegistry<Player>>,
222 ) {
223 self.awarded.retain(|player_id, achievements| {
224 let Some(player) = player_registry.read_by_id(*player_id) else {
225 return false;
226 };
227
228 let awarded = player.awarded_achievements();
229 achievements.retain(|achievement_id| !awarded.contains_key(achievement_id));
230 !achievements.is_empty()
231 });
232 }
233}
234
235#[derive(Debug)]
237pub struct AchievementContext<'a, Player, Metadata, Trigger> {
238 pub current_bloop: &'a Bloop<Player>,
240 pub bloop_provider: &'a BloopProvider<Player>,
242 pub metadata: &'a Metadata,
244 trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
246 awarded_tracker: Mutex<AwardedTracker>,
248}
249
250impl<'a, Player, Metadata, Trigger> AchievementContext<'a, Player, Metadata, Trigger> {
251 pub fn new(
252 current_bloop: &'a Bloop<Player>,
253 bloop_provider: &'a BloopProvider<Player>,
254 metadata: &'a Metadata,
255 trigger_registry: &'a mut TriggerRegistry<Trigger>,
256 ) -> Self {
257 AchievementContext {
258 current_bloop,
259 bloop_provider,
260 metadata,
261 trigger_registry: Mutex::new(trigger_registry),
262 awarded_tracker: Mutex::new(AwardedTracker::new(current_bloop)),
263 }
264 }
265
266 pub fn global_bloops(&self) -> impl Iterator<Item = &Arc<Bloop<Player>>> {
267 self.bloop_provider.global().iter()
268 }
269
270 #[inline]
272 pub fn client_bloops(&self) -> impl Iterator<Item = &Arc<Bloop<Player>>> {
273 self.bloop_provider
274 .for_client(&self.current_bloop.client_id)
275 .iter()
276 }
277
278 #[inline]
280 pub fn filter_current_player(&self) -> impl Fn(&&Arc<Bloop<Player>>) -> bool {
281 bloops_for_player(self.current_bloop.player_id)
282 }
283
284 #[inline]
286 pub fn filter_within_window(
287 &self,
288 duration: Duration,
289 ) -> impl Fn(&&Arc<Bloop<Player>>) -> bool {
290 let since = self.current_bloop.recorded_at - duration;
291 bloops_since(since)
292 }
293
294 pub fn award_achievement(&self, achievement_id: Uuid, player_id: Uuid) {
296 self.awarded_tracker
297 .lock()
298 .unwrap()
299 .add(achievement_id, player_id);
300 }
301
302 pub(crate) fn take_awarded_tracker(self) -> AwardedTracker {
304 self.awarded_tracker.into_inner().unwrap()
305 }
306}
307
308impl<'a, Player, Metadata, Trigger: PartialEq> AchievementContext<'a, Player, Metadata, Trigger> {
309 pub fn has_trigger(&self, trigger: Trigger) -> bool {
311 let mut guard = self.trigger_registry.lock().unwrap();
312 let registry: &mut TriggerRegistry<_> = *guard;
313
314 registry.check_active_trigger(
315 trigger,
316 &self.current_bloop.client_id,
317 self.current_bloop.recorded_at,
318 )
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::achievement::{AchievementBuilder, AchievementContext, BuilderError};
326 use crate::bloop::Bloop;
327 use crate::evaluator::Evaluator;
328 use crate::nfc_uid::NfcUid;
329 use crate::test_utils::{MockPlayer, MockPlayerBuilder, TestCtxBuilder};
330 use crate::trigger::{TriggerOccurrence, TriggerRegistry, TriggerSpec};
331 use chrono::Utc;
332 use uuid::Uuid;
333
334 #[derive(Debug)]
335 struct DummyEvaluator(EvalResult);
336
337 impl Evaluator<MockPlayer, (), ()> for DummyEvaluator {
338 fn evaluate(&self, _ctx: &AchievementContext<MockPlayer, (), ()>) -> impl Into<EvalResult> {
339 self.0.clone()
340 }
341 }
342
343 #[test]
344 fn missing_id_fails_to_build() {
345 let builder = AchievementBuilder::<(), (), (), ()>::new();
346 let err = builder.build().unwrap_err();
347 assert!(matches!(err, BuilderError::MissingField("id")));
348 }
349
350 #[test]
351 fn missing_evaluator_fails_to_build() {
352 let builder = AchievementBuilder::<(), (), (), ()>::new().id(Uuid::new_v4());
353 let err = builder.build().unwrap_err();
354 assert!(matches!(err, BuilderError::MissingField("evaluator")));
355 }
356
357 #[test]
358 fn builds_successfully_with_all_required_fields() {
359 let id = Uuid::new_v4();
360 let evaluator = DummyEvaluator(EvalResult::AwardSelf);
361
362 let achievement = AchievementBuilder::new()
363 .id(id)
364 .evaluator(evaluator)
365 .build()
366 .expect("should build");
367
368 assert_eq!(achievement.id, id);
369 }
370
371 #[test]
372 fn evaluate_awards_self_when_evaluator_returns_award_self() {
373 let evaluator = DummyEvaluator(EvalResult::AwardSelf);
374
375 let (player, player_id) = MockPlayerBuilder::new().build();
376 let bloop = Bloop::new(player, "client", Utc::now());
377
378 let id = Uuid::new_v4();
379 let achievement = AchievementBuilder::new()
380 .id(id)
381 .evaluator(evaluator)
382 .build()
383 .unwrap();
384
385 let mut ctx_builder = TestCtxBuilder::new(bloop);
386 let ctx = ctx_builder.build();
387 achievement.evaluate(&ctx);
388
389 let awarded = ctx.take_awarded_tracker();
390 assert!(awarded.for_player(player_id).unwrap().contains(&id));
391 }
392
393 #[test]
394 fn evaluate_awards_multiple_players_when_evaluator_returns_award_multiple() {
395 let evaluator = DummyEvaluator(EvalResult::AwardMultiple(vec![
396 Uuid::new_v4(),
397 Uuid::new_v4(),
398 ]));
399
400 let (player, _) = MockPlayerBuilder::new().build();
401 let bloop = Bloop::new(player, "client", Utc::now());
402
403 let mut ctx_builder = TestCtxBuilder::new(bloop);
404 let ctx = ctx_builder.build();
405
406 let id = Uuid::new_v4();
407 let achievement = AchievementBuilder::new()
408 .id(id)
409 .evaluator(evaluator)
410 .build()
411 .unwrap();
412
413 achievement.evaluate(&ctx);
414
415 let awarded = ctx.take_awarded_tracker();
416 assert_eq!(
417 awarded.awarded.values().map(|set| set.len()).sum::<usize>(),
418 2
419 );
420 }
421
422 #[test]
423 fn award_achievement_records_award_correctly() {
424 let (player, player_id) = MockPlayerBuilder::new().build();
425 let bloop = Bloop::new(player, "client", Utc::now());
426
427 let mut ctx_builder = TestCtxBuilder::new(bloop);
428 let ctx = ctx_builder.build();
429
430 let achievement_id = Uuid::new_v4();
431 ctx.award_achievement(achievement_id, ctx.current_bloop.player_id);
432
433 let awarded = ctx.take_awarded_tracker();
434 let awards = awarded.for_player(player_id).unwrap();
435 assert!(awards.contains(&achievement_id));
436 }
437
438 #[test]
439 fn has_trigger_returns_true_when_trigger_active() {
440 #[derive(Copy, Clone, PartialEq, Debug)]
441 enum DummyTrigger {
442 Active,
443 Inactive,
444 }
445
446 let (player, _) = MockPlayerBuilder::new().build();
447 let bloop = Bloop::new(player, "client", Utc::now());
448
449 let nfc_uid = NfcUid::default();
450 let mut triggers = HashMap::new();
451 triggers.insert(
452 nfc_uid,
453 TriggerSpec {
454 trigger: DummyTrigger::Active,
455 occurrence: TriggerOccurrence::Once,
456 global: false,
457 },
458 );
459 let mut registry = TriggerRegistry::new(triggers);
460 registry.try_activate_trigger(nfc_uid, "client");
461
462 let mut ctx_builder = TestCtxBuilder::new(bloop).trigger_registry(registry);
463 let ctx = ctx_builder.build();
464
465 assert!(ctx.has_trigger(DummyTrigger::Active));
466 assert!(!ctx.has_trigger(DummyTrigger::Inactive));
467 }
468}