Skip to main content

bloop_server_framework/evaluator/
streak.rs

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