1use 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#[derive(Debug, Error)]
28pub enum BuilderError {
29 #[error("missing field: {0}")]
31 MissingField(&'static str),
32}
33
34#[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 pub fn id(mut self, id: Uuid) -> Self {
59 self.id = Some(id);
60 self
61 }
62
63 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 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 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 pub fn hot_duration(mut self, hot_duration: Duration) -> Self {
97 self.hot_duration = Some(hot_duration);
98 self
99 }
100
101 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#[derive(Debug)]
167pub struct Achievement<Metadata, Player, State, Trigger> {
168 pub id: Uuid,
170 pub metadata: Metadata,
172 pub audio_file: AudioSource,
174 pub evaluator: Box<dyn DynEvaluator<Player, State, Trigger>>,
176 pub hot_duration: Option<Duration>,
178}
179
180impl<Metadata, Player, State, Trigger> Achievement<Metadata, Player, State, Trigger> {
181 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#[derive(Debug, Clone, Serialize)]
202pub struct PlayerAchievementAwards {
203 pub player_id: Uuid,
205 pub achievement_ids: Vec<Uuid>,
207}
208
209#[derive(Debug, Clone, Serialize)]
211pub struct AchievementAwardBatch {
212 pub awarded_at: DateTime<Utc>,
214 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#[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#[derive(Debug)]
282pub struct AchievementContext<'a, Player, Metadata, Trigger> {
283 pub current_bloop: &'a Bloop<Player>,
285 pub bloop_provider: &'a BloopProvider<Player>,
287 pub metadata: &'a Metadata,
289 trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
291 awarded_tracker: Mutex<AwardedTracker>,
293}
294
295impl<'a, Player, Metadata, Trigger> AchievementContext<'a, Player, Metadata, Trigger> {
296 pub fn new(
297 current_bloop: &'a Bloop<Player>,
298 bloop_provider: &'a BloopProvider<Player>,
299 metadata: &'a Metadata,
300 trigger_registry: &'a mut TriggerRegistry<Trigger>,
301 ) -> Self {
302 AchievementContext {
303 current_bloop,
304 bloop_provider,
305 metadata,
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 #[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 #[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 #[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 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 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 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}