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)]
204#[serde(rename_all = "camelCase")]
205pub struct PlayerAchievementAwards {
206 pub player_id: Uuid,
208 pub achievement_ids: Vec<Uuid>,
210}
211
212#[derive(Debug, Clone, Serialize)]
214#[serde(rename_all = "camelCase")]
215pub struct AchievementAwardBatch {
216 pub awarded_at: DateTime<Utc>,
218 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#[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#[derive(Debug)]
286pub struct AchievementContext<'a, Player, State, Trigger> {
287 pub current_bloop: &'a Bloop<Player>,
289 pub bloop_provider: &'a BloopProvider<Player>,
291 pub state: &'a State,
293 trigger_registry: Mutex<&'a mut TriggerRegistry<Trigger>>,
295 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 #[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 #[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 #[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 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 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 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}