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::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/// Error type for errors encountered when building an achievement.
24#[derive(Debug, Error)]
25pub enum BuilderError {
26    /// Indicates a required field was missing during build.
27    #[error("missing field: {0}")]
28    MissingField(&'static str),
29}
30
31/// Builder for constructing [`Achievement`] instances with typed parameters.
32#[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    /// Sets the unique ID for the achievement.
55    pub fn id(mut self, id: Uuid) -> Self {
56        self.id = Some(id);
57        self
58    }
59
60    /// Sets the evaluator that determines when the achievement is awarded.
61    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    /// Sets the metadata associated with the achievement.
75    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    /// Optionally sets a path to audio to play when the achievement is awarded.
86    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    /// Optionally sets a duration for which the achievement remains "hot" after
92    /// being awarded.
93    pub fn hot_duration(mut self, hot_duration: Duration) -> Self {
94        self.hot_duration = Some(hot_duration);
95        self
96    }
97
98    /// Attempts to build an achievement, returning an error if required fields are
99    /// missing.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`BuilderError::MissingField`] if `id` or `evaluator` was not set.
104    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/// Represents a configured achievement with evaluation logic.
121#[derive(Debug)]
122pub struct Achievement<Metadata, Player, State, Trigger> {
123    /// Unique ID of the achievement.
124    pub id: Uuid,
125    /// Metadata definition of the achievement.
126    pub metadata: Metadata,
127    /// Path to audio played when achievement is awarded.
128    pub audio_path: Option<PathBuf>,
129    /// Evaluation logic for determining if the achievement should be awarded.
130    pub evaluator: Box<dyn DynEvaluator<Player, State, Trigger>>,
131    /// Optional duration for which the achievement remains "hot".
132    pub hot_duration: Option<Duration>,
133}
134
135impl<Metadata, Player, State, Trigger> Achievement<Metadata, Player, State, Trigger> {
136    /// Evaluates the achievement against the provided context.
137    ///
138    /// Awards the achievement to one or multiple players depending on the
139    /// evaluation result.
140    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/// Represents the achievements awarded to a single player at a specific time.
156#[derive(Debug, Clone, Serialize)]
157pub struct PlayerAchievementAwards {
158    /// The unique ID of the player who received the achievements.
159    pub player_id: Uuid,
160    /// The list of achievement IDs awarded to the player.
161    pub achievement_ids: Vec<Uuid>,
162}
163
164/// Represents a batch of achievements awarded to multiple players at a given timestamp.
165#[derive(Debug, Clone, Serialize)]
166pub struct AchievementAwardBatch {
167    /// The timestamp when the achievements were awarded.
168    pub awarded_at: DateTime<Utc>,
169    /// The list of players and their awarded achievements.
170    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/// Tracks achievements awarded to players during an evaluation cycle.
190#[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/// Context used when evaluating and awarding achievements for a specific bloop.
236#[derive(Debug)]
237pub struct AchievementContext<'a, Player, Metadata, Trigger> {
238    /// The bloop currently being evaluated.
239    pub current_bloop: &'a Bloop<Player>,
240    /// Provides access to bloop data for the clients and players.
241    pub bloop_provider: &'a BloopProvider<Player>,
242    /// Additional evaluation-specific metadata.
243    pub metadata: &'a Metadata,
244    /// Registry of all active triggers.
245    trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
246    /// Tracks achievements awarded during this evaluation.
247    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    /// Returns the bloop collection for the current bloop's client.
271    #[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    /// Returns a filter for bloop collections for the current bloop's player.
279    #[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    /// Returns a filter for bloop collections for a given duration.
285    #[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    /// Records that a given achievement should be awarded to a player.
295    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    /// Consumes the context and returns all awarded achievements.
303    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    /// Returns whether a given trigger is active right now.
310    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}