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    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.get_or_init(|| {
147            let relative = self.relative.as_ref()?;
148
149            let path = base_path.join(relative);
150            let Ok(file_content) = fs::read(path.clone()) else {
151                warn!("Audio file missing: {:?}", path);
152                return None;
153            };
154
155            let digest = md5::compute(file_content);
156
157            Some(AudioFile {
158                path,
159                hash: digest.into(),
160            })
161        })
162    }
163}
164
165/// Represents a configured achievement with evaluation logic.
166#[derive(Debug)]
167pub struct Achievement<Metadata, Player, State, Trigger> {
168    /// Unique ID of the achievement.
169    pub id: Uuid,
170    /// Metadata definition of the achievement.
171    pub metadata: Metadata,
172    /// Audio file to play when achievement is awarded.
173    pub audio_file: AudioSource,
174    /// Evaluation logic for determining if the achievement should be awarded.
175    pub evaluator: Box<dyn DynEvaluator<Player, State, Trigger>>,
176    /// Optional duration for which the achievement remains "hot".
177    pub hot_duration: Option<Duration>,
178}
179
180impl<Metadata, Player, State, Trigger> Achievement<Metadata, Player, State, Trigger> {
181    /// Evaluates the achievement against the provided context.
182    ///
183    /// Awards the achievement to one or multiple players depending on the
184    /// evaluation result.
185    pub fn evaluate(&self, ctx: &AchievementContext<Player, State, Trigger>) {
186        match self.evaluator.evaluate(ctx) {
187            EvalResult::AwardSelf => {
188                ctx.award_achievement(self.id, ctx.current_bloop.player_id);
189            }
190            EvalResult::AwardMultiple(player_ids) => {
191                for player_id in player_ids {
192                    ctx.award_achievement(self.id, player_id);
193                }
194            }
195            EvalResult::NoAward => {}
196        }
197    }
198}
199
200/// Represents the achievements awarded to a single player at a specific time.
201#[derive(Debug, Clone, Serialize)]
202pub struct PlayerAchievementAwards {
203    /// The unique ID of the player who received the achievements.
204    pub player_id: Uuid,
205    /// The list of achievement IDs awarded to the player.
206    pub achievement_ids: Vec<Uuid>,
207}
208
209/// Represents a batch of achievements awarded to multiple players at a given timestamp.
210#[derive(Debug, Clone, Serialize)]
211pub struct AchievementAwardBatch {
212    /// The timestamp when the achievements were awarded.
213    pub awarded_at: DateTime<Utc>,
214    /// The list of players and their awarded achievements.
215    pub players: Vec<PlayerAchievementAwards>,
216}
217
218impl From<AwardedTracker> for AchievementAwardBatch {
219    fn from(tracker: AwardedTracker) -> Self {
220        Self {
221            awarded_at: tracker.awarded_at,
222            players: tracker
223                .awarded
224                .into_iter()
225                .map(|(player_id, achievements)| PlayerAchievementAwards {
226                    player_id,
227                    achievement_ids: achievements.into_iter().collect(),
228                })
229                .collect(),
230        }
231    }
232}
233
234/// Tracks achievements awarded to players during an evaluation cycle.
235#[derive(Debug)]
236pub(crate) struct AwardedTracker {
237    awarded_at: DateTime<Utc>,
238    awarded: HashMap<Uuid, HashSet<Uuid>>,
239}
240
241impl AwardedTracker {
242    fn new<Player>(current_bloop: &Bloop<Player>) -> Self {
243        Self {
244            awarded_at: current_bloop.recorded_at,
245            awarded: HashMap::new(),
246        }
247    }
248
249    pub fn add(&mut self, achievement_id: Uuid, player_id: Uuid) {
250        self.awarded
251            .entry(player_id)
252            .or_default()
253            .insert(achievement_id);
254    }
255
256    pub fn for_player(&self, player_id: Uuid) -> Option<&HashSet<Uuid>> {
257        self.awarded.get(&player_id)
258    }
259
260    pub fn for_player_mut(&mut self, player_id: Uuid) -> &mut HashSet<Uuid> {
261        self.awarded.entry(player_id).or_default()
262    }
263
264    pub fn remove_duplicates<Player: PlayerInfo + PlayerMutator>(
265        &mut self,
266        player_registry: MutexGuard<PlayerRegistry<Player>>,
267    ) {
268        self.awarded.retain(|player_id, achievements| {
269            let Some(player) = player_registry.read_by_id(*player_id) else {
270                return false;
271            };
272
273            let awarded = player.awarded_achievements();
274            achievements.retain(|achievement_id| !awarded.contains_key(achievement_id));
275            !achievements.is_empty()
276        });
277    }
278}
279
280/// Context used when evaluating and awarding achievements for a specific bloop.
281#[derive(Debug)]
282pub struct AchievementContext<'a, Player, State, Trigger> {
283    /// The bloop currently being evaluated.
284    pub current_bloop: &'a Bloop<Player>,
285    /// Provides access to bloop data for the clients and players.
286    pub bloop_provider: &'a BloopProvider<Player>,
287    /// Additional evaluation-specific state.
288    pub state: &'a State,
289    /// Registry of all active triggers.
290    trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
291    /// Tracks achievements awarded during this evaluation.
292    awarded_tracker: Mutex<AwardedTracker>,
293}
294
295impl<'a, Player, State, Trigger> AchievementContext<'a, Player, State, Trigger> {
296    pub fn new(
297        current_bloop: &'a Bloop<Player>,
298        bloop_provider: &'a BloopProvider<Player>,
299        state: &'a State,
300        trigger_registry: &'a mut TriggerRegistry<Trigger>,
301    ) -> Self {
302        AchievementContext {
303            current_bloop,
304            bloop_provider,
305            state,
306            trigger_registry: Mutex::new(trigger_registry),
307            awarded_tracker: Mutex::new(AwardedTracker::new(current_bloop)),
308        }
309    }
310
311    pub fn global_bloops(&self) -> impl Iterator<Item = &Arc<Bloop<Player>>> {
312        self.bloop_provider.global().iter()
313    }
314
315    /// Returns the bloop collection for the current bloop's client.
316    #[inline]
317    pub fn client_bloops(&self) -> impl Iterator<Item = &Arc<Bloop<Player>>> {
318        self.bloop_provider
319            .for_client(&self.current_bloop.client_id)
320            .iter()
321    }
322
323    /// Returns a filter for bloop collections for the current bloop's player.
324    #[inline]
325    pub fn filter_current_player(&self) -> impl Fn(&&Arc<Bloop<Player>>) -> bool {
326        bloops_for_player(self.current_bloop.player_id)
327    }
328
329    /// Returns a filter for bloop collections for a given duration.
330    #[inline]
331    pub fn filter_within_window(
332        &self,
333        duration: Duration,
334    ) -> impl Fn(&&Arc<Bloop<Player>>) -> bool {
335        let since = self.current_bloop.recorded_at - duration;
336        bloops_since(since)
337    }
338
339    /// Records that a given achievement should be awarded to a player.
340    pub fn award_achievement(&self, achievement_id: Uuid, player_id: Uuid) {
341        self.awarded_tracker
342            .lock()
343            .unwrap()
344            .add(achievement_id, player_id);
345    }
346
347    /// Consumes the context and returns all awarded achievements.
348    pub(crate) fn take_awarded_tracker(self) -> AwardedTracker {
349        self.awarded_tracker.into_inner().unwrap()
350    }
351}
352
353impl<'a, Player, Metadata, Trigger: PartialEq> AchievementContext<'a, Player, Metadata, Trigger> {
354    /// Returns whether a given trigger is active right now.
355    pub fn has_trigger(&self, trigger: Trigger) -> bool {
356        let mut guard = self.trigger_registry.lock().unwrap();
357        let registry: &mut TriggerRegistry<_> = *guard;
358
359        registry.check_active_trigger(
360            trigger,
361            &self.current_bloop.client_id,
362            self.current_bloop.recorded_at,
363        )
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::achievement::{AchievementBuilder, AchievementContext, BuilderError};
371    use crate::bloop::Bloop;
372    use crate::evaluator::Evaluator;
373    use crate::nfc_uid::NfcUid;
374    use crate::test_utils::{MockPlayer, MockPlayerBuilder, TestCtxBuilder};
375    use crate::trigger::{TriggerOccurrence, TriggerRegistry, TriggerSpec};
376    use chrono::Utc;
377    use uuid::Uuid;
378
379    #[derive(Debug)]
380    struct DummyEvaluator(EvalResult);
381
382    impl Evaluator<MockPlayer, (), ()> for DummyEvaluator {
383        fn evaluate(&self, _ctx: &AchievementContext<MockPlayer, (), ()>) -> impl Into<EvalResult> {
384            self.0.clone()
385        }
386    }
387
388    #[test]
389    fn missing_id_fails_to_build() {
390        let builder = AchievementBuilder::<(), (), (), ()>::new();
391        let err = builder.build().unwrap_err();
392        assert!(matches!(err, BuilderError::MissingField("id")));
393    }
394
395    #[test]
396    fn missing_evaluator_fails_to_build() {
397        let builder = AchievementBuilder::<(), (), (), ()>::new().id(Uuid::new_v4());
398        let err = builder.build().unwrap_err();
399        assert!(matches!(err, BuilderError::MissingField("evaluator")));
400    }
401
402    #[test]
403    fn builds_successfully_with_all_required_fields() {
404        let id = Uuid::new_v4();
405        let evaluator = DummyEvaluator(EvalResult::AwardSelf);
406
407        let achievement = AchievementBuilder::new()
408            .id(id)
409            .evaluator(evaluator)
410            .build()
411            .expect("should build");
412
413        assert_eq!(achievement.id, id);
414    }
415
416    #[test]
417    fn evaluate_awards_self_when_evaluator_returns_award_self() {
418        let evaluator = DummyEvaluator(EvalResult::AwardSelf);
419
420        let (player, player_id) = MockPlayerBuilder::new().build();
421        let bloop = Bloop::new(player, "client", Utc::now());
422
423        let id = Uuid::new_v4();
424        let achievement = AchievementBuilder::new()
425            .id(id)
426            .evaluator(evaluator)
427            .build()
428            .unwrap();
429
430        let mut ctx_builder = TestCtxBuilder::new(bloop);
431        let ctx = ctx_builder.build();
432        achievement.evaluate(&ctx);
433
434        let awarded = ctx.take_awarded_tracker();
435        assert!(awarded.for_player(player_id).unwrap().contains(&id));
436    }
437
438    #[test]
439    fn evaluate_awards_multiple_players_when_evaluator_returns_award_multiple() {
440        let evaluator = DummyEvaluator(EvalResult::AwardMultiple(vec![
441            Uuid::new_v4(),
442            Uuid::new_v4(),
443        ]));
444
445        let (player, _) = MockPlayerBuilder::new().build();
446        let bloop = Bloop::new(player, "client", Utc::now());
447
448        let mut ctx_builder = TestCtxBuilder::new(bloop);
449        let ctx = ctx_builder.build();
450
451        let id = Uuid::new_v4();
452        let achievement = AchievementBuilder::new()
453            .id(id)
454            .evaluator(evaluator)
455            .build()
456            .unwrap();
457
458        achievement.evaluate(&ctx);
459
460        let awarded = ctx.take_awarded_tracker();
461        assert_eq!(
462            awarded.awarded.values().map(|set| set.len()).sum::<usize>(),
463            2
464        );
465    }
466
467    #[test]
468    fn award_achievement_records_award_correctly() {
469        let (player, player_id) = MockPlayerBuilder::new().build();
470        let bloop = Bloop::new(player, "client", Utc::now());
471
472        let mut ctx_builder = TestCtxBuilder::new(bloop);
473        let ctx = ctx_builder.build();
474
475        let achievement_id = Uuid::new_v4();
476        ctx.award_achievement(achievement_id, ctx.current_bloop.player_id);
477
478        let awarded = ctx.take_awarded_tracker();
479        let awards = awarded.for_player(player_id).unwrap();
480        assert!(awards.contains(&achievement_id));
481    }
482
483    #[test]
484    fn has_trigger_returns_true_when_trigger_active() {
485        #[derive(Copy, Clone, PartialEq, Debug)]
486        enum DummyTrigger {
487            Active,
488            Inactive,
489        }
490
491        let (player, _) = MockPlayerBuilder::new().build();
492        let bloop = Bloop::new(player, "client", Utc::now());
493
494        let nfc_uid = NfcUid::default();
495        let mut triggers = HashMap::new();
496        triggers.insert(
497            nfc_uid,
498            TriggerSpec {
499                trigger: DummyTrigger::Active,
500                occurrence: TriggerOccurrence::Once,
501                global: false,
502            },
503        );
504        let mut registry = TriggerRegistry::new(triggers);
505        registry.try_activate_trigger(nfc_uid, "client");
506
507        let mut ctx_builder = TestCtxBuilder::new(bloop).trigger_registry(registry);
508        let ctx = ctx_builder.build();
509
510        assert!(ctx.has_trigger(DummyTrigger::Active));
511        assert!(!ctx.has_trigger(DummyTrigger::Inactive));
512    }
513}