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