bloop_server_framework/evaluator/
streak.rs

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