bloop_server_framework/evaluator/
distinct_values.rs

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