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(&Bloop<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: &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(&Bloop<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(&Bloop<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>
142    DistinctValuesEvaluator<Player, State, Trigger, V, C, DC, E>
143where
144    DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
145    E: Fn(&Bloop<Player>, &C) -> ExtractResult<V> + Send + Sync + 'static,
146    V: Eq + Hash + 'static,
147{
148    pub fn builder() -> DistinctValuesEvaluatorBuilder<NoValue, NoValue> {
149        DistinctValuesEvaluatorBuilder::new()
150    }
151}
152
153impl<Player, State, Trigger, V, C, DC, E> Evaluator<Player, State, Trigger>
154    for DistinctValuesEvaluator<Player, State, Trigger, V, C, DC, E>
155where
156    DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
157    E: Fn(&Bloop<Player>, &C) -> ExtractResult<V> + Send + Sync + 'static,
158    V: Eq + Hash + 'static,
159{
160    fn evaluate(&self, ctx: &AchievementContext<Player, State, Trigger>) -> impl Into<EvalResult> {
161        let derived_ctx = (self.derive_ctx)(ctx);
162        let mut seen_values = HashSet::with_capacity(self.min_required);
163        let mut player_ids = Vec::with_capacity(self.min_required + 1);
164        player_ids.push(ctx.current_bloop.player_id);
165
166        let bloops = ctx
167            .client_bloops()
168            .filter(ctx.filter_within_window(self.max_window))
169            .take(self.min_required);
170
171        for bloop in bloops {
172            if player_ids.contains(&bloop.player_id) {
173                return EvalResult::NoAward;
174            }
175
176            player_ids.push(bloop.player_id);
177
178            let extract_result = (self.extract)(bloop, &derived_ctx);
179
180            match extract_result {
181                ExtractResult::Single(value) => {
182                    seen_values.insert(value);
183                }
184                ExtractResult::Multiple(values) => seen_values.extend(values),
185                ExtractResult::Abort => return EvalResult::NoAward,
186            };
187
188            if seen_values.len() >= self.min_required {
189                break;
190            }
191        }
192
193        if seen_values.len() < self.min_required {
194            return EvalResult::NoAward;
195        }
196
197        match self.award_mode {
198            AwardMode::Current => EvalResult::AwardSelf,
199            AwardMode::All => EvalResult::AwardMultiple(player_ids),
200        }
201    }
202}
203
204impl<Player, State, Trigger, V, C, DC, E> Debug
205    for DistinctValuesEvaluator<Player, State, Trigger, V, C, DC, E>
206where
207    DC: Fn(&AchievementContext<Player, State, Trigger>) -> C + Send + Sync + 'static,
208    E: Fn(&Bloop<Player>, &C) -> ExtractResult<V> + Send + Sync + 'static,
209    V: Eq + Hash + 'static,
210{
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        f.debug_struct("DistinctValuesEvaluator")
213            .field("derive_ctx", &"<closure>")
214            .field("extract", &"<closure>")
215            .field("min_required", &self.min_required)
216            .field("max_window", &self.max_window)
217            .field("award_mode", &self.award_mode)
218            .finish()
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::bloop::Bloop;
226    use crate::evaluator::{EvalResult, Evaluator};
227    use crate::test_utils::{MockPlayer, TestCtxBuilder};
228    use chrono::{DateTime, Utc};
229    use std::time::SystemTime;
230    use std::vec;
231
232    fn make_bloop(client_id: &str, name: &str, seconds_ago: u64) -> Bloop<MockPlayer> {
233        let now = SystemTime::now() - Duration::from_secs(seconds_ago);
234        let timestamp: DateTime<Utc> = now.into();
235
236        let (player, _) = MockPlayer::builder().name(name).build();
237
238        Bloop::new(player, client_id, timestamp)
239    }
240
241    #[test]
242    fn award_when_required_distinct_values_are_met() {
243        let evaluator = DistinctValuesEvaluatorBuilder::new()
244            .min_required(3)
245            .max_window(Duration::from_secs(60))
246            .build(|bloop: &Bloop<MockPlayer>| ExtractResult::Single(bloop.player().name.clone()));
247
248        let current = make_bloop("client1", "alice", 0);
249        let past = vec![
250            make_bloop("client1", "bob", 10),
251            make_bloop("client1", "carol", 20),
252            make_bloop("client1", "dave", 30),
253        ];
254
255        let mut ctx_builder = TestCtxBuilder::new(current).bloops(past);
256        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
257
258        assert_eq!(res, EvalResult::AwardSelf);
259    }
260
261    #[test]
262    fn award_all_mode_returns_all_player_ids() {
263        let evaluator = DistinctValuesEvaluatorBuilder::new()
264            .min_required(3)
265            .max_window(Duration::from_secs(60))
266            .award_all()
267            .build(|bloop: &Bloop<MockPlayer>| ExtractResult::Single(bloop.player().name.clone()));
268
269        let current = make_bloop("client1", "alice", 0);
270        let b1 = make_bloop("client1", "bob", 10);
271        let b2 = make_bloop("client1", "carol", 20);
272        let b3 = make_bloop("client1", "dave", 30);
273        let b4 = make_bloop("client1", "joe", 40);
274
275        let expected_ids = vec![current.player_id, b1.player_id, b2.player_id, b3.player_id];
276
277        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1, b2, b3, b4]);
278        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
279
280        match res {
281            EvalResult::AwardMultiple(ids) => {
282                assert_eq!(ids, expected_ids);
283            }
284            other => panic!("expected AwardMultiple, got {:?}", other),
285        };
286    }
287
288    #[test]
289    fn abort_from_extractor_leads_to_no_award() {
290        let evaluator = DistinctValuesEvaluatorBuilder::new()
291            .min_required(1)
292            .max_window(Duration::from_secs(60))
293            .build(|_bloop: &Bloop<MockPlayer>| ExtractResult::<()>::Abort);
294
295        let current = make_bloop("client1", "alice", 0);
296        let b1 = make_bloop("client1", "bob", 10);
297
298        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1]);
299        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
300
301        assert_eq!(res, EvalResult::NoAward);
302    }
303
304    #[test]
305    fn duplicate_player_in_recent_bloops_causes_no_award() {
306        let evaluator = DistinctValuesEvaluatorBuilder::new()
307            .min_required(2)
308            .max_window(Duration::from_secs(60))
309            .build(|bloop: &Bloop<MockPlayer>| ExtractResult::Single(bloop.player().name.clone()));
310
311        let current = make_bloop("client1", "alice", 0);
312
313        let b1 = make_bloop("client1", "bob", 10);
314        let mut b2 = make_bloop("client1", "carol", 10);
315        b2.player_id = b1.player_id;
316
317        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1, b2]);
318        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
319
320        assert_eq!(res, EvalResult::NoAward);
321    }
322
323    #[test]
324    fn ignores_bloops_outside_time_window() {
325        let evaluator = DistinctValuesEvaluatorBuilder::new()
326            .min_required(2)
327            .max_window(Duration::from_secs(20))
328            .build(|bloop: &Bloop<MockPlayer>| ExtractResult::Single(bloop.player().name.clone()));
329
330        let current = make_bloop("client1", "alice", 0);
331        let b1 = make_bloop("client1", "bob", 10);
332        let b2 = make_bloop("client1", "carol", 30);
333
334        let mut ctx_builder = TestCtxBuilder::new(current).bloops(vec![b1, b2]);
335        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
336
337        assert_eq!(res, EvalResult::NoAward);
338    }
339
340    #[test]
341    fn build_with_derived_ctx_supplies_context_to_extractor() {
342        let evaluator = DistinctValuesEvaluatorBuilder::new()
343            .min_required(2)
344            .max_window(Duration::from_secs(60))
345            .build_with_derived_ctx(
346                |_ctx| vec!["bob", "carol"],
347                |bloop: &Bloop<MockPlayer>, allowed: &Vec<&str>| {
348                    if allowed.contains(&bloop.player().name.as_str()) {
349                        ExtractResult::Single(bloop.player().name.clone())
350                    } else {
351                        ExtractResult::Abort
352                    }
353                },
354            );
355
356        let current = make_bloop("client1", "alice", 0);
357        let past = vec![
358            make_bloop("client1", "bob", 10),
359            make_bloop("client1", "carol", 20),
360        ];
361
362        let mut ctx_builder = TestCtxBuilder::new(current).bloops(past);
363        let res: EvalResult = evaluator.evaluate(&ctx_builder.build()).into();
364
365        assert_eq!(res, EvalResult::AwardSelf);
366    }
367}