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 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#[derive(Debug)]
169pub struct Achievement<Metadata, Player, State, Trigger> {
170 pub id: Uuid,
172 pub metadata: Metadata,
174 pub audio_file: AudioSource,
176 pub evaluator: Box<dyn DynEvaluator<Player, State, Trigger>>,
178 pub hot_duration: Option<Duration>,
180}
181
182impl<Metadata, Player, State, Trigger> Achievement<Metadata, Player, State, Trigger> {
183 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#[derive(Debug, Clone, Serialize)]
204pub struct PlayerAchievementAwards {
205 pub player_id: Uuid,
207 pub achievement_ids: Vec<Uuid>,
209}
210
211#[derive(Debug, Clone, Serialize)]
213pub struct AchievementAwardBatch {
214 pub awarded_at: DateTime<Utc>,
216 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#[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#[derive(Debug)]
284pub struct AchievementContext<'a, Player, State, Trigger> {
285 pub current_bloop: &'a Bloop<Player>,
287 pub bloop_provider: &'a BloopProvider<Player>,
289 pub state: &'a State,
291 trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
293 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 #[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 #[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 #[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 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 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 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}