bloop_server_framework/
achievement.rs

1//! Provides types and logic for defining, evaluating, and awarding
2//! achievements.
3//!
4//! This module includes builder types, achievement evaluation contexts,
5//! award tracking, and associated errors.
6
7use crate::bloop::{Bloop, BloopProvider, bloops_for_player, bloops_since};
8use crate::evaluator::EvalResult;
9use crate::evaluator::boxed::{DynEvaluator, IntoDynEvaluator};
10use crate::message::DataHash;
11use crate::player::{PlayerInfo, PlayerMutator, PlayerRegistry};
12use crate::trigger::TriggerRegistry;
13use chrono::{DateTime, Utc};
14use serde::Serialize;
15use std::collections::{HashMap, HashSet};
16use std::fmt::Debug;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex, OnceLock};
20use std::time::Duration;
21use thiserror::Error;
22use tokio::sync::MutexGuard;
23use tracing::warn;
24use uuid::Uuid;
25
26/// Error type for errors encountered when building an achievement.
27#[derive(Debug, Error)]
28pub enum BuilderError {
29    /// Indicates a required field was missing during build.
30    #[error("missing field: {0}")]
31    MissingField(&'static str),
32}
33
34/// Builder for constructing [`Achievement`] instances with typed parameters.
35#[derive(Debug, Default)]
36pub struct AchievementBuilder<Player, State, Trigger, Metadata> {
37    id: Option<Uuid>,
38    evaluator: Option<Box<dyn DynEvaluator<Player, State, Trigger>>>,
39    metadata: Metadata,
40    audio_path: Option<PathBuf>,
41    hot_duration: Option<Duration>,
42}
43
44impl AchievementBuilder<(), (), (), ()> {
45    pub fn new() -> Self {
46        Self {
47            id: None,
48            evaluator: None,
49            metadata: (),
50            audio_path: None,
51            hot_duration: None,
52        }
53    }
54}
55
56impl<Player, State, Trigger, Metadata> AchievementBuilder<Player, State, Trigger, Metadata> {
57    /// Sets the unique ID for the achievement.
58    pub fn id(mut self, id: Uuid) -> Self {
59        self.id = Some(id);
60        self
61    }
62
63    /// Sets the evaluator that determines when the achievement is awarded.
64    pub fn evaluator<P, S, T>(
65        self,
66        evaluator: impl IntoDynEvaluator<P, S, T>,
67    ) -> AchievementBuilder<P, S, T, Metadata> {
68        AchievementBuilder {
69            id: self.id,
70            evaluator: Some(evaluator.into_dyn_evaluator()),
71            metadata: self.metadata,
72            audio_path: self.audio_path,
73            hot_duration: self.hot_duration,
74        }
75    }
76
77    /// Sets the metadata associated with the achievement.
78    pub fn metadata<T>(self, metadata: T) -> AchievementBuilder<Player, State, Trigger, T> {
79        AchievementBuilder {
80            id: self.id,
81            evaluator: self.evaluator,
82            metadata,
83            audio_path: self.audio_path,
84            hot_duration: self.hot_duration,
85        }
86    }
87
88    /// Optionally sets a path to audio to play when the achievement is awarded.
89    pub fn audio_path<P: Into<PathBuf>>(mut self, audio_path: P) -> Self {
90        self.audio_path = Some(audio_path.into());
91        self
92    }
93
94    /// Optionally sets a duration for which the achievement remains "hot" after
95    /// being awarded.
96    pub fn hot_duration(mut self, hot_duration: Duration) -> Self {
97        self.hot_duration = Some(hot_duration);
98        self
99    }
100
101    /// Attempts to build an achievement, returning an error if required fields are
102    /// missing.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`BuilderError::MissingField`] if `id` or `evaluator` was not set.
107    pub fn build(self) -> Result<Achievement<Metadata, Player, State, Trigger>, BuilderError> {
108        let id = self.id.ok_or(BuilderError::MissingField("id"))?;
109        let evaluator = self
110            .evaluator
111            .ok_or(BuilderError::MissingField("evaluator"))?;
112
113        Ok(Achievement {
114            id,
115            evaluator,
116            metadata: self.metadata,
117            audio_file: self.audio_path.into(),
118            hot_duration: self.hot_duration,
119        })
120    }
121}
122
123#[derive(Debug)]
124pub struct AudioFile {
125    pub path: PathBuf,
126    pub hash: DataHash,
127}
128
129#[derive(Debug)]
130pub struct AudioSource {
131    pub relative: Option<PathBuf>,
132    resolved: OnceLock<Option<AudioFile>>,
133}
134
135impl From<Option<PathBuf>> for AudioSource {
136    fn from(path: Option<PathBuf>) -> Self {
137        AudioSource {
138            relative: path,
139            resolved: OnceLock::new(),
140        }
141    }
142}
143
144impl AudioSource {
145    pub fn resolve(&self, base_path: &Path) -> Option<&AudioFile> {
146        self.resolved
147            .get_or_init(|| {
148                let relative = self.relative.as_ref()?;
149
150                let path = base_path.join(relative);
151                let Ok(file_content) = fs::read(path.clone()) else {
152                    warn!("Audio file missing: {:?}", path);
153                    return None;
154                };
155
156                let digest = md5::compute(file_content);
157
158                Some(AudioFile {
159                    path,
160                    hash: digest.into(),
161                })
162            })
163            .as_ref()
164    }
165}
166
167/// Represents a configured achievement with evaluation logic.
168#[derive(Debug)]
169pub struct Achievement<Metadata, Player, State, Trigger> {
170    /// Unique ID of the achievement.
171    pub id: Uuid,
172    /// Metadata definition of the achievement.
173    pub metadata: Metadata,
174    /// Audio file to play when achievement is awarded.
175    pub audio_file: AudioSource,
176    /// Evaluation logic for determining if the achievement should be awarded.
177    pub evaluator: Box<dyn DynEvaluator<Player, State, Trigger>>,
178    /// Optional duration for which the achievement remains "hot".
179    pub hot_duration: Option<Duration>,
180}
181
182impl<Metadata, Player, State, Trigger> Achievement<Metadata, Player, State, Trigger> {
183    /// Evaluates the achievement against the provided context.
184    ///
185    /// Awards the achievement to one or multiple players depending on the
186    /// evaluation result.
187    pub fn evaluate(&self, ctx: &AchievementContext<Player, State, Trigger>) {
188        match self.evaluator.evaluate(ctx) {
189            EvalResult::AwardSelf => {
190                ctx.award_achievement(self.id, ctx.current_bloop.player_id);
191            }
192            EvalResult::AwardMultiple(player_ids) => {
193                for player_id in player_ids {
194                    ctx.award_achievement(self.id, player_id);
195                }
196            }
197            EvalResult::NoAward => {}
198        }
199    }
200}
201
202/// Represents the achievements awarded to a single player at a specific time.
203#[derive(Debug, Clone, Serialize)]
204pub struct PlayerAchievementAwards {
205    /// The unique ID of the player who received the achievements.
206    pub player_id: Uuid,
207    /// The list of achievement IDs awarded to the player.
208    pub achievement_ids: Vec<Uuid>,
209}
210
211/// Represents a batch of achievements awarded to multiple players at a given timestamp.
212#[derive(Debug, Clone, Serialize)]
213pub struct AchievementAwardBatch {
214    /// The timestamp when the achievements were awarded.
215    pub awarded_at: DateTime<Utc>,
216    /// The list of players and their awarded achievements.
217    pub players: Vec<PlayerAchievementAwards>,
218}
219
220impl From<AwardedTracker> for AchievementAwardBatch {
221    fn from(tracker: AwardedTracker) -> Self {
222        Self {
223            awarded_at: tracker.awarded_at,
224            players: tracker
225                .awarded
226                .into_iter()
227                .map(|(player_id, achievements)| PlayerAchievementAwards {
228                    player_id,
229                    achievement_ids: achievements.into_iter().collect(),
230                })
231                .collect(),
232        }
233    }
234}
235
236/// Tracks achievements awarded to players during an evaluation cycle.
237#[derive(Debug)]
238pub(crate) struct AwardedTracker {
239    awarded_at: DateTime<Utc>,
240    awarded: HashMap<Uuid, HashSet<Uuid>>,
241}
242
243impl AwardedTracker {
244    fn new<Player>(current_bloop: &Bloop<Player>) -> Self {
245        Self {
246            awarded_at: current_bloop.recorded_at,
247            awarded: HashMap::new(),
248        }
249    }
250
251    pub fn add(&mut self, achievement_id: Uuid, player_id: Uuid) {
252        self.awarded
253            .entry(player_id)
254            .or_default()
255            .insert(achievement_id);
256    }
257
258    pub fn for_player(&self, player_id: Uuid) -> Option<&HashSet<Uuid>> {
259        self.awarded.get(&player_id)
260    }
261
262    pub fn for_player_mut(&mut self, player_id: Uuid) -> &mut HashSet<Uuid> {
263        self.awarded.entry(player_id).or_default()
264    }
265
266    pub fn remove_duplicates<Player: PlayerInfo + PlayerMutator>(
267        &mut self,
268        player_registry: MutexGuard<PlayerRegistry<Player>>,
269    ) {
270        self.awarded.retain(|player_id, achievements| {
271            let Some(player) = player_registry.read_by_id(*player_id) else {
272                return false;
273            };
274
275            let awarded = player.awarded_achievements();
276            achievements.retain(|achievement_id| !awarded.contains_key(achievement_id));
277            !achievements.is_empty()
278        });
279    }
280}
281
282/// Context used when evaluating and awarding achievements for a specific bloop.
283#[derive(Debug)]
284pub struct AchievementContext<'a, Player, State, Trigger> {
285    /// The bloop currently being evaluated.
286    pub current_bloop: &'a Bloop<Player>,
287    /// Provides access to bloop data for the clients and players.
288    pub bloop_provider: &'a BloopProvider<Player>,
289    /// Additional evaluation-specific state.
290    pub state: &'a State,
291    /// Registry of all active triggers.
292    trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
293    /// Tracks achievements awarded during this evaluation.
294    awarded_tracker: Mutex<AwardedTracker>,
295}
296
297impl<'a, Player, State, Trigger> AchievementContext<'a, Player, State, Trigger> {
298    pub fn new(
299        current_bloop: &'a Bloop<Player>,
300        bloop_provider: &'a BloopProvider<Player>,
301        state: &'a State,
302        trigger_registry: &'a mut TriggerRegistry<Trigger>,
303    ) -> Self {
304        AchievementContext {
305            current_bloop,
306            bloop_provider,
307            state,
308            trigger_registry: Mutex::new(trigger_registry),
309            awarded_tracker: Mutex::new(AwardedTracker::new(current_bloop)),
310        }
311    }
312
313    pub fn global_bloops(&self) -> impl Iterator<Item = &Arc<Bloop<Player>>> {
314        self.bloop_provider.global().iter()
315    }
316
317    /// Returns the bloop collection for the current bloop's client.
318    #[inline]
319    pub fn client_bloops(&self) -> impl Iterator<Item = &Arc<Bloop<Player>>> {
320        self.bloop_provider
321            .for_client(&self.current_bloop.client_id)
322            .iter()
323    }
324
325    /// Returns a filter for bloop collections for the current bloop's player.
326    #[inline]
327    pub fn filter_current_player(&self) -> impl Fn(&&Arc<Bloop<Player>>) -> bool {
328        bloops_for_player(self.current_bloop.player_id)
329    }
330
331    /// Returns a filter for bloop collections for a given duration.
332    #[inline]
333    pub fn filter_within_window(
334        &self,
335        duration: Duration,
336    ) -> impl Fn(&&Arc<Bloop<Player>>) -> bool {
337        let since = self.current_bloop.recorded_at - duration;
338        bloops_since(since)
339    }
340
341    /// Records that a given achievement should be awarded to a player.
342    pub fn award_achievement(&self, achievement_id: Uuid, player_id: Uuid) {
343        self.awarded_tracker
344            .lock()
345            .unwrap()
346            .add(achievement_id, player_id);
347    }
348
349    /// Consumes the context and returns all awarded achievements.
350    pub(crate) fn take_awarded_tracker(self) -> AwardedTracker {
351        self.awarded_tracker.into_inner().unwrap()
352    }
353}
354
355impl<'a, Player, Metadata, Trigger: PartialEq> AchievementContext<'a, Player, Metadata, Trigger> {
356    /// Returns whether a given trigger is active right now.
357    pub fn has_trigger(&self, trigger: Trigger) -> bool {
358        let mut guard = self.trigger_registry.lock().unwrap();
359        let registry: &mut TriggerRegistry<_> = *guard;
360
361        registry.check_active_trigger(
362            trigger,
363            &self.current_bloop.client_id,
364            self.current_bloop.recorded_at,
365        )
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::achievement::{AchievementBuilder, AchievementContext, BuilderError};
373    use crate::bloop::Bloop;
374    use crate::evaluator::Evaluator;
375    use crate::nfc_uid::NfcUid;
376    use crate::test_utils::{MockPlayer, MockPlayerBuilder, TestCtxBuilder};
377    use crate::trigger::{TriggerOccurrence, TriggerRegistry, TriggerSpec};
378    use chrono::Utc;
379    use uuid::Uuid;
380
381    #[derive(Debug)]
382    struct DummyEvaluator(EvalResult);
383
384    impl Evaluator<MockPlayer, (), ()> for DummyEvaluator {
385        fn evaluate(&self, _ctx: &AchievementContext<MockPlayer, (), ()>) -> impl Into<EvalResult> {
386            self.0.clone()
387        }
388    }
389
390    #[test]
391    fn missing_id_fails_to_build() {
392        let builder = AchievementBuilder::<(), (), (), ()>::new();
393        let err = builder.build().unwrap_err();
394        assert!(matches!(err, BuilderError::MissingField("id")));
395    }
396
397    #[test]
398    fn missing_evaluator_fails_to_build() {
399        let builder = AchievementBuilder::<(), (), (), ()>::new().id(Uuid::new_v4());
400        let err = builder.build().unwrap_err();
401        assert!(matches!(err, BuilderError::MissingField("evaluator")));
402    }
403
404    #[test]
405    fn builds_successfully_with_all_required_fields() {
406        let id = Uuid::new_v4();
407        let evaluator = DummyEvaluator(EvalResult::AwardSelf);
408
409        let achievement = AchievementBuilder::new()
410            .id(id)
411            .evaluator(evaluator)
412            .build()
413            .expect("should build");
414
415        assert_eq!(achievement.id, id);
416    }
417
418    #[test]
419    fn evaluate_awards_self_when_evaluator_returns_award_self() {
420        let evaluator = DummyEvaluator(EvalResult::AwardSelf);
421
422        let (player, player_id) = MockPlayerBuilder::new().build();
423        let bloop = Bloop::new(player, "client", Utc::now());
424
425        let id = Uuid::new_v4();
426        let achievement = AchievementBuilder::new()
427            .id(id)
428            .evaluator(evaluator)
429            .build()
430            .unwrap();
431
432        let mut ctx_builder = TestCtxBuilder::new(bloop);
433        let ctx = ctx_builder.build();
434        achievement.evaluate(&ctx);
435
436        let awarded = ctx.take_awarded_tracker();
437        assert!(awarded.for_player(player_id).unwrap().contains(&id));
438    }
439
440    #[test]
441    fn evaluate_awards_multiple_players_when_evaluator_returns_award_multiple() {
442        let evaluator = DummyEvaluator(EvalResult::AwardMultiple(vec![
443            Uuid::new_v4(),
444            Uuid::new_v4(),
445        ]));
446
447        let (player, _) = MockPlayerBuilder::new().build();
448        let bloop = Bloop::new(player, "client", Utc::now());
449
450        let mut ctx_builder = TestCtxBuilder::new(bloop);
451        let ctx = ctx_builder.build();
452
453        let id = Uuid::new_v4();
454        let achievement = AchievementBuilder::new()
455            .id(id)
456            .evaluator(evaluator)
457            .build()
458            .unwrap();
459
460        achievement.evaluate(&ctx);
461
462        let awarded = ctx.take_awarded_tracker();
463        assert_eq!(
464            awarded.awarded.values().map(|set| set.len()).sum::<usize>(),
465            2
466        );
467    }
468
469    #[test]
470    fn award_achievement_records_award_correctly() {
471        let (player, player_id) = MockPlayerBuilder::new().build();
472        let bloop = Bloop::new(player, "client", Utc::now());
473
474        let mut ctx_builder = TestCtxBuilder::new(bloop);
475        let ctx = ctx_builder.build();
476
477        let achievement_id = Uuid::new_v4();
478        ctx.award_achievement(achievement_id, ctx.current_bloop.player_id);
479
480        let awarded = ctx.take_awarded_tracker();
481        let awards = awarded.for_player(player_id).unwrap();
482        assert!(awards.contains(&achievement_id));
483    }
484
485    #[test]
486    fn has_trigger_returns_true_when_trigger_active() {
487        #[derive(Copy, Clone, PartialEq, Debug)]
488        enum DummyTrigger {
489            Active,
490            Inactive,
491        }
492
493        let (player, _) = MockPlayerBuilder::new().build();
494        let bloop = Bloop::new(player, "client", Utc::now());
495
496        let nfc_uid = NfcUid::default();
497        let mut triggers = HashMap::new();
498        triggers.insert(
499            nfc_uid,
500            TriggerSpec {
501                trigger: DummyTrigger::Active,
502                occurrence: TriggerOccurrence::Once,
503                global: false,
504            },
505        );
506        let mut registry = TriggerRegistry::new(triggers);
507        registry.try_activate_trigger(nfc_uid, "client");
508
509        let mut ctx_builder = TestCtxBuilder::new(bloop).trigger_registry(registry);
510        let ctx = ctx_builder.build();
511
512        assert!(ctx.has_trigger(DummyTrigger::Active));
513        assert!(!ctx.has_trigger(DummyTrigger::Inactive));
514    }
515}