Skip to main content

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