bloop_server_framework/evaluator/
distinct_values.rs

1use crate::achievement::AchievementContext;
2use crate::bloop::Bloop;
3use crate::builder::{NoValue, Value};
4use crate::evaluator::{AwardMode, EvalResult, Evaluator};
5use std::collections::HashSet;
6use std::fmt;
7use std::fmt::Debug;
8use std::hash::Hash;
9use std::marker::PhantomData;
10use std::time::Duration;
11
12/// Result of extracting zero, one or more values from a bloop.
13#[derive(Debug)]
14pub enum ExtractResult<V> {
15    Single(V),
16    Multiple(Vec<V>),
17    Abort,
18}
19
20/// Builder for [`DistinctValuesEvaluator`].
21#[derive(Debug, Default)]
22pub struct DistinctValuesEvaluatorBuilder<R, W> {
23    min_required: R,
24    max_window: W,
25    award_mode: AwardMode,
26}
27
28impl DistinctValuesEvaluatorBuilder<NoValue, NoValue> {
29    pub fn new() -> Self {
30        Self::default()
31    }
32}
33
34impl<W, R> DistinctValuesEvaluatorBuilder<R, W> {
35    /// Award all involved players instead of just the current player.
36    pub fn award_all(self) -> Self {
37        Self {
38            award_mode: AwardMode::All,
39            ..self
40        }
41    }
42}
43
44impl<W> DistinctValuesEvaluatorBuilder<NoValue, W> {
45    /// Set the minimum number of distinct values required to award the achievement.
46    pub fn min_required(
47        self,
48        min_required: usize,
49    ) -> DistinctValuesEvaluatorBuilder<Value<usize>, W> {
50        DistinctValuesEvaluatorBuilder {
51            min_required: Value(min_required),
52            max_window: self.max_window,
53            award_mode: self.award_mode,
54        }
55    }
56}
57
58impl<R> DistinctValuesEvaluatorBuilder<R, NoValue> {
59    /// Set the maximum time window in which the bloops must have occurred.
60    pub fn max_window(
61        self,
62        max_window: Duration,
63    ) -> DistinctValuesEvaluatorBuilder<R, Value<Duration>> {
64        DistinctValuesEvaluatorBuilder {
65            min_required: self.min_required,
66            max_window: Value(max_window),
67            award_mode: self.award_mode,
68        }
69    }
70}
71
72impl DistinctValuesEvaluatorBuilder<Value<usize>, Value<Duration>> {
73    /// Build the evaluator.
74    pub fn build<Player, State, Trigger, V, E>(
75        self,
76        extract: E,
77    ) -> impl Evaluator<Player, State, Trigger> + Debug
78    where
79        Player: 'static,
80        State: 'static,
81        Trigger: 'static,
82        E: Fn(&Player) -> ExtractResult<V> + Send + Sync + 'static,
83        V: Eq + Hash + 'static,
84    {
85        fn derive_ctx<Player, State, Trigger>(_ctx: &AchievementContext<Player, State, Trigger>) {}
86        let extract_wrapper = move |bloop: &Player, _: &()| extract(bloop);
87
88        DistinctValuesEvaluator {
89            min_required: self.min_required.0,
90            max_window: self.max_window.0,
91            award_mode: self.award_mode,
92            derive_ctx,
93            extract: extract_wrapper,
94            _marker: PhantomData,
95        }
96    }
97
98    /// Build the evaluator with additionally derived context.
99    ///
100    /// The derived context can be used to initially retrieve values from the
101    /// achievement context and have them available in the extractor.
102    pub fn build_with_derived_ctx<Player, State, Trigger, V, C, DC, E>(
103        self,
104        derive_ctx: DC,
105        extract: E,
106    ) -> impl Evaluator<Player, State, Trigger> + Debug
107    where
108        DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
109        E: Fn(&Player, &C) -> ExtractResult<V> + Send + Sync + 'static,
110        V: Eq + Hash + 'static,
111    {
112        DistinctValuesEvaluator {
113            min_required: self.min_required.0,
114            max_window: self.max_window.0,
115            award_mode: self.award_mode,
116            derive_ctx,
117            extract,
118            _marker: PhantomData,
119        }
120    }
121}
122
123/// Evaluator that collects distinct `V` values from recent client bloops.
124///
125/// This evaluator awards the current or all participating players when the
126/// collected values reach the `min_required` count.
127pub struct DistinctValuesEvaluator<Player, State, Trigger, V, C, DC, E>
128where
129    DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
130    E: Fn(&Player, &C) -> ExtractResult<V> + Send + Sync + 'static,
131    V: Eq + Hash + 'static,
132{
133    min_required: usize,
134    max_window: Duration,
135    award_mode: AwardMode,
136    derive_ctx: DC,
137    extract: E,
138    _marker: PhantomData<(Player, State, Trigger, V)>,
139}
140
141impl<Player, State, Trigger, V, C, DC, E> Evaluator<Player, State, Trigger>
142    for DistinctValuesEvaluator<Player, State, Trigger, V, C, DC, E>
143where
144    DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
145    E: Fn(&Player, &C) -> ExtractResult<V> + Send + Sync + 'static,
146    V: Eq + Hash + 'static,
147{
148    fn evaluate(&self, ctx: &AchievementContext<Player, State, Trigger>) -> impl Into<EvalResult> {
149        let derived_ctx = (self.derive_ctx)(ctx);
150        let mut seen_values = HashSet::with_capacity(self.min_required);
151        let mut player_ids = Vec::with_capacity(self.min_required + 1);
152        player_ids.push(ctx.current_bloop.player_id);
153
154        let bloops = ctx
155            .client_bloops()
156            .filter(ctx.filter_within_window(self.max_window))
157            .take(self.min_required);
158
159        for bloop in bloops {
160            if player_ids.contains(&bloop.player_id) {
161                return EvalResult::NoAward;
162            }
163
164            player_ids.push(bloop.player_id);
165
166            let extract_result = (self.extract)(&bloop.player(), &derived_ctx);
167
168            match extract_result {
169                ExtractResult::Single(value) => {
170                    seen_values.insert(value);
171                }
172                ExtractResult::Multiple(values) => seen_values.extend(values),
173                ExtractResult::Abort => return EvalResult::NoAward,
174            };
175
176            if seen_values.len() >= self.min_required {
177                break;
178            }
179        }
180
181        if seen_values.len() < self.min_required {
182            return EvalResult::NoAward;
183        }
184
185        match self.award_mode {
186            AwardMode::Current => EvalResult::AwardSelf,
187            AwardMode::All => EvalResult::AwardMultiple(player_ids),
188        }
189    }
190}
191
192impl<Player, State, Trigger, V, C, DC, E> Debug
193    for DistinctValuesEvaluator<Player, State, Trigger, V, C, DC, E>
194where
195    DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
196    E: Fn(&Player, &C) -> ExtractResult<V> + Send + Sync + 'static,
197    V: Eq + Hash + 'static,
198{
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        f.debug_struct("DistinctValuesEvaluator")
201            .field("derive_ctx", &"<closure>")
202            .field("extract", &"<closure>")
203            .field("min_required", &self.min_required)
204            .field("max_window", &self.max_window)
205            .field("award_mode", &self.award_mode)
206            .finish()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::bloop::Bloop;
214    use crate::evaluator::{EvalResult, Evaluator};
215    use crate::test_utils::{MockPlayer, TestCtxBuilder};
216    use chrono::{DateTime, Utc};
217    use std::time::SystemTime;
218    use std::vec;
219
220    fn make_bloop(client_id: &str, name: &str, seconds_ago: u64) -> Bloop<MockPlayer> {
221        let now = SystemTime::now() - Duration::from_secs(seconds_ago);
222        let timestamp: DateTime<Utc> = now.into();
223
224        let (player, _) = MockPlayer::builder().name(name).build();
225
226        Bloop::new(player, client_id, timestamp)
227    }
228
229    #[test]
230    fn award_when_required_distinct_values_are_met() {
231        let evaluator = DistinctValuesEvaluatorBuilder::new()
232            .min_required(3)
233            .max_window(Duration::from_secs(60))
234            .build(|player: &MockPlayer| ExtractResult::Single(player.name.clone()));
235
236        let current = make_bloop("client1", "alice", 0);
237        let past = vec![
238            make_bloop("client1", "bob", 10),
239            make_bloop("client1", "carol", 20),
240            make_bloop("client1", "dave", 30),
241        ];
242
243        let mut ctx_builder = TestCtxBuilder::new(current).bloops(past);
244        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
245
246        assert_eq!(res, EvalResult::AwardSelf);
247    }
248
249    #[test]
250    fn award_all_mode_returns_all_player_ids() {
251        let evaluator = DistinctValuesEvaluatorBuilder::new()
252            .min_required(3)
253            .max_window(Duration::from_secs(60))
254            .award_all()
255            .build(|player: &MockPlayer| ExtractResult::Single(player.name.clone()));
256
257        let current = make_bloop("client1", "alice", 0);
258        let b1 = make_bloop("client1", "bob", 10);
259        let b2 = make_bloop("client1", "carol", 20);
260        let b3 = make_bloop("client1", "dave", 30);
261        let b4 = make_bloop("client1", "joe", 40);
262
263        let expected_ids = vec![current.player_id, b1.player_id, b2.player_id, b3.player_id];
264
265        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1, b2, b3, b4]);
266        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
267
268        match res {
269            EvalResult::AwardMultiple(ids) => {
270                assert_eq!(ids, expected_ids);
271            }
272            other => panic!("expected AwardMultiple, got {:?}", other),
273        };
274    }
275
276    #[test]
277    fn abort_from_extractor_leads_to_no_award() {
278        let evaluator = DistinctValuesEvaluatorBuilder::new()
279            .min_required(1)
280            .max_window(Duration::from_secs(60))
281            .build(|_player: &MockPlayer| ExtractResult::<()>::Abort);
282
283        let current = make_bloop("client1", "alice", 0);
284        let b1 = make_bloop("client1", "bob", 10);
285
286        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1]);
287        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
288
289        assert_eq!(res, EvalResult::NoAward);
290    }
291
292    #[test]
293    fn duplicate_player_in_recent_bloops_causes_no_award() {
294        let evaluator = DistinctValuesEvaluatorBuilder::new()
295            .min_required(2)
296            .max_window(Duration::from_secs(60))
297            .build(|player: &MockPlayer| ExtractResult::Single(player.name.clone()));
298
299        let current = make_bloop("client1", "alice", 0);
300
301        let b1 = make_bloop("client1", "bob", 10);
302        let mut b2 = make_bloop("client1", "carol", 10);
303        b2.player_id = b1.player_id;
304
305        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1, b2]);
306        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
307
308        assert_eq!(res, EvalResult::NoAward);
309    }
310
311    #[test]
312    fn ignores_bloops_outside_time_window() {
313        let evaluator = DistinctValuesEvaluatorBuilder::new()
314            .min_required(2)
315            .max_window(Duration::from_secs(20))
316            .build(|player: &MockPlayer| ExtractResult::Single(player.name.clone()));
317
318        let current = make_bloop("client1", "alice", 0);
319        let b1 = make_bloop("client1", "bob", 10);
320        let b2 = make_bloop("client1", "carol", 30);
321
322        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1, b2]);
323        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
324
325        assert_eq!(res, EvalResult::NoAward);
326    }
327
328    #[test]
329    fn build_with_derived_ctx_supplies_context_to_extractor() {
330        let evaluator = DistinctValuesEvaluatorBuilder::new()
331            .min_required(2)
332            .max_window(Duration::from_secs(60))
333            .build_with_derived_ctx(
334                |_ctx| vec!["bob", "carol"],
335                |player: &MockPlayer, allowed: &Vec<&str>| {
336                    if allowed.contains(&player.name.as_str()) {
337                        ExtractResult::Single(player.name.clone())
338                    } else {
339                        ExtractResult::Abort
340                    }
341                },
342            );
343
344        let current = make_bloop("client1", "alice", 0);
345        let past = vec![
346            make_bloop("client1", "bob", 10),
347            make_bloop("client1", "carol", 20),
348        ];
349
350        let mut ctx_builder = TestCtxBuilder::new(current).bloops(past);
351        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
352
353        assert_eq!(res, EvalResult::AwardSelf);
354    }
355}