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#[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 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 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 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 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 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
111pub 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}