bloop_server_framework/evaluator/
registration_number.rs

1use crate::achievement::AchievementContext;
2use crate::evaluator::{EvalResult, Evaluator};
3use cached::proc_macro::cached;
4use num_integer::Roots;
5use std::fmt;
6use std::fmt::Debug;
7use std::time::Duration;
8
9/// Trait to provide a registration number for a player.
10pub trait RegistrationNumberProvider {
11    fn registration_number(&self) -> usize;
12}
13
14/// Checks if a number is prime.
15pub fn is_prime(number: usize) -> bool {
16    internal_is_prime(number)
17}
18
19#[cached(size = 10_000)]
20#[inline]
21fn internal_is_prime(number: usize) -> bool {
22    if number == 2 || number == 3 {
23        return true;
24    }
25
26    if number <= 1 || number.is_multiple_of(2) || number.is_multiple_of(3) {
27        return false;
28    }
29
30    let mut i = 5;
31
32    while i * i <= number {
33        if number.is_multiple_of(i) || number.is_multiple_of(i + 2) {
34            return false;
35        }
36
37        i += 6;
38    }
39
40    true
41}
42
43/// Checks if a number is a perfect square.
44pub fn is_perfect_square(number: usize) -> bool {
45    internal_is_perfect_square(number)
46}
47
48#[cached(size = 10_000)]
49#[inline]
50fn internal_is_perfect_square(number: usize) -> bool {
51    number.sqrt().pow(2) == number
52}
53
54/// Checks if a number is a Fibonacci number.
55pub fn is_fibonacci(number: usize) -> bool {
56    internal_is_fibonacci(number)
57}
58
59#[cached(size = 10_000)]
60#[inline]
61fn internal_is_fibonacci(number: usize) -> bool {
62    is_perfect_square(5 * number * number + 4) || is_perfect_square(5 * number * number - 4)
63}
64
65/// Evaluates if recent bloops meet a numeric property predicate.
66///
67/// Checks if at least `min_required` recent bloops within `max_window` satisfy the given predicate
68/// on player registration numbers.
69pub struct NumberPredicateEvaluator<F>
70where
71    F: Fn(usize) -> bool + Send + Sync + 'static,
72{
73    /// Closure or function to test a player's registration number.
74    predicate: F,
75
76    /// Minimum number of bloops that must pass the test.
77    min_required: usize,
78
79    /// Time window in which to evaluate recent bloops, counting backward from the current one.
80    max_window: Duration,
81}
82
83impl<F> NumberPredicateEvaluator<F>
84where
85    F: Fn(usize) -> bool + Send + Sync + 'static,
86{
87    /// Creates a new [`NumberPredicateEvaluator`] with the given predicate, count, and window.
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use std::time::Duration;
93    /// use bloop_server_framework::evaluator::registration_number::{
94    ///     is_prime,
95    ///     NumberPredicateEvaluator,
96    /// };
97    ///
98    /// let evaluator = NumberPredicateEvaluator::new(is_prime, 3, Duration::from_secs(60));
99    /// ```
100    pub fn new(predicate: F, min_required: usize, max_window: Duration) -> Self {
101        Self {
102            predicate,
103            min_required,
104            max_window,
105        }
106    }
107}
108
109impl<Player, Metadata, Trigger, F> Evaluator<Player, Metadata, Trigger>
110    for NumberPredicateEvaluator<F>
111where
112    Player: RegistrationNumberProvider,
113    F: Fn(usize) -> bool + Send + Sync + 'static,
114{
115    fn evaluate(
116        &self,
117        ctx: &AchievementContext<Player, Metadata, Trigger>,
118    ) -> impl Into<EvalResult> {
119        let bloops = ctx
120            .client_bloops()
121            .filter(ctx.filter_within_window(self.max_window))
122            .take(self.min_required)
123            .collect::<Vec<_>>();
124
125        bloops
126            .iter()
127            .all(|bloop| (self.predicate)(bloop.player().registration_number()))
128            && bloops.len() >= self.min_required
129    }
130}
131
132impl<F> Debug for NumberPredicateEvaluator<F>
133where
134    F: Fn(usize) -> bool + Send + Sync + 'static,
135{
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.debug_struct("NumberPropertyEvaluator")
138            .field("predicate", &"<closure>")
139            .field("min_required", &self.min_required)
140            .field("max_window", &self.max_window)
141            .finish()
142    }
143}
144
145pub fn digit_sum(number: usize) -> usize {
146    let mut sum = 0;
147    let mut value = number;
148
149    while value > 0 {
150        sum += value % 10;
151        value /= 10;
152    }
153
154    sum
155}
156
157/// Evaluates whether a number projection of recent players matches that of the
158/// current player.
159///
160/// The [`ProjectionMatchEvaluator`] takes a projection function that
161/// transforms a player's registration number into a comparable value (e.g.,
162/// digit sum, modulo, etc.). It then checks whether at least `min_required`
163/// recent players (within a given time window) have the *same* projected value
164/// as the current player.
165///
166/// This is useful for recognizing players with "numerical affinity", such as
167/// those with the same digit sum, same last digit, or belonging to the same
168/// modulo class.
169///
170/// # Notes
171///
172/// - The projection function must return a value that implements [`PartialEq`].
173/// - Only bloops from the same client (i.e., `client_bloops()`) are considered.
174/// - The comparison starts from the most recent bloop and checks up to
175///   `min_required`.
176/// - The current bloop is used as the reference point for the projected value.
177pub struct ProjectionMatchEvaluator<F, V>
178where
179    F: Fn(usize) -> V + Send + Sync + 'static,
180    V: PartialEq,
181{
182    /// Closure or function to project a player's registration number.
183    projector: F,
184
185    /// Minimum number of bloops that must pass the test.
186    min_required: usize,
187
188    /// Time window in which to evaluate recent bloops, counting backward from the
189    /// current one.
190    max_window: Duration,
191}
192
193impl<F, V> ProjectionMatchEvaluator<F, V>
194where
195    F: Fn(usize) -> V + Send + Sync + 'static,
196    V: PartialEq,
197{
198    /// Creates a new [`ProjectionMatchEvaluator`] with the given projector, count,
199    /// and window.
200    ///
201    /// # Examples
202    ///
203    /// Basic usage with a digit sum comparison:
204    ///
205    /// ```
206    /// use std::time::Duration;
207    /// use bloop_server_framework::evaluator::registration_number::{
208    ///     digit_sum,
209    ///     ProjectionMatchEvaluator,
210    /// };
211    ///
212    /// let evaluator = ProjectionMatchEvaluator::new(
213    ///     digit_sum,
214    ///     3,
215    ///     Duration::from_secs(60),
216    /// );
217    /// ```
218    ///
219    /// Custom projection based on the last digit:
220    ///
221    /// ```
222    /// use std::time::Duration;
223    /// use bloop_server_framework::evaluator::{
224    ///     registration_number::ProjectionMatchEvaluator
225    /// };
226    ///
227    /// let evaluator = ProjectionMatchEvaluator::new(
228    ///     |n| n % 10,
229    ///     2,
230    ///     Duration::from_secs(30),
231    /// );
232    /// ```
233    pub fn new(projector: F, min_required: usize, max_window: Duration) -> Self {
234        Self {
235            projector,
236            min_required,
237            max_window,
238        }
239    }
240}
241
242impl<F, V> Debug for ProjectionMatchEvaluator<F, V>
243where
244    F: Fn(usize) -> V + Send + Sync + 'static,
245    V: PartialEq,
246{
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.debug_struct("ProjectionMatchEvaluator")
249            .field("projector", &"<closure>")
250            .field("min_required", &self.min_required)
251            .field("max_window", &self.max_window)
252            .finish()
253    }
254}
255
256impl<Player, Metadata, Trigger, F, V> Evaluator<Player, Metadata, Trigger>
257    for ProjectionMatchEvaluator<F, V>
258where
259    Player: RegistrationNumberProvider,
260    F: Fn(usize) -> V + Send + Sync + 'static,
261    V: PartialEq,
262{
263    fn evaluate(
264        &self,
265        ctx: &AchievementContext<Player, Metadata, Trigger>,
266    ) -> impl Into<EvalResult> {
267        let reference = (self.projector)(ctx.current_bloop.player().registration_number());
268
269        let bloops = ctx
270            .client_bloops()
271            .filter(ctx.filter_within_window(self.max_window))
272            .take(self.min_required)
273            .collect::<Vec<_>>();
274
275        bloops
276            .iter()
277            .all(|bloop| (self.projector)(bloop.player().registration_number()) == reference)
278            && bloops.len() >= self.min_required
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::bloop::Bloop;
286    use crate::evaluator::{EvalResult, Evaluator};
287    use crate::test_utils::{MockPlayer, TestCtxBuilder};
288    use chrono::{DateTime, Utc};
289    use std::time::{Duration, SystemTime};
290
291    fn make_bloop(number: usize, seconds_ago: u64) -> Bloop<MockPlayer> {
292        let now = SystemTime::now() - Duration::from_secs(seconds_ago);
293        let timestamp: DateTime<Utc> = now.into();
294
295        let (player, _) = MockPlayer::builder().registration_number(number).build();
296        Bloop::new(player, "test", timestamp)
297    }
298
299    #[test]
300    fn predicate_eval_returns_true_when_enough_bloops_match_predicate() {
301        let evaluator = NumberPredicateEvaluator::new(|n| n % 2 == 0, 3, Duration::from_secs(60));
302
303        let current = make_bloop(8, 0);
304        let past = vec![
305            make_bloop(2, 10),
306            make_bloop(4, 20),
307            make_bloop(6, 30),
308            make_bloop(3, 40),
309        ];
310
311        let mut builder = TestCtxBuilder::new(current).bloops(past);
312        assert_eq!(
313            evaluator.evaluate(&builder.build()).into(),
314            EvalResult::AwardSelf
315        );
316    }
317
318    #[test]
319    fn predicate_eval_returns_false_when_not_enough_bloops_match_predicate() {
320        let evaluator = NumberPredicateEvaluator::new(|n| n % 2 == 0, 3, Duration::from_secs(60));
321
322        let current = make_bloop(1, 0);
323        let past = vec![make_bloop(2, 10), make_bloop(3, 20), make_bloop(5, 30)];
324
325        let mut builder = TestCtxBuilder::new(current).bloops(past);
326        assert_eq!(
327            evaluator.evaluate(&builder.build()).into(),
328            EvalResult::NoAward
329        );
330    }
331
332    #[test]
333    fn predicate_eval_ignores_bloops_outside_time_window() {
334        let evaluator = NumberPredicateEvaluator::new(|n| n % 2 == 0, 2, Duration::from_secs(20));
335
336        let current = make_bloop(8, 0);
337        let past = vec![make_bloop(2, 10), make_bloop(4, 25), make_bloop(6, 30)];
338
339        let mut builder = TestCtxBuilder::new(current).bloops(past);
340        assert_eq!(
341            evaluator.evaluate(&builder.build()).into(),
342            EvalResult::NoAward
343        );
344    }
345
346    #[test]
347    fn predicate_eval_takes_only_min_required_recent_matches() {
348        let evaluator = NumberPredicateEvaluator::new(|n| n < 10, 2, Duration::from_secs(60));
349
350        let current = make_bloop(1, 0);
351        let past = vec![make_bloop(9, 10), make_bloop(8, 15), make_bloop(100, 20)];
352
353        let mut builder = TestCtxBuilder::new(current).bloops(past);
354        assert_eq!(
355            evaluator.evaluate(&builder.build()).into(),
356            EvalResult::AwardSelf
357        );
358    }
359
360    #[test]
361    fn projection_eval_returns_true_when_enough_bloops_match_projection() {
362        let evaluator = ProjectionMatchEvaluator::new(|n| n % 2, 3, Duration::from_secs(60));
363
364        let current = make_bloop(4, 0);
365        let past = vec![
366            make_bloop(2, 10),
367            make_bloop(6, 20),
368            make_bloop(8, 30),
369            make_bloop(3, 40),
370        ];
371
372        let mut builder = TestCtxBuilder::new(current).bloops(past);
373        assert_eq!(
374            evaluator.evaluate(&builder.build()).into(),
375            EvalResult::AwardSelf
376        );
377    }
378
379    #[test]
380    fn projection_eval_returns_false_when_not_enough_bloops_match_projection() {
381        let evaluator = ProjectionMatchEvaluator::new(|n| n % 2, 4, Duration::from_secs(60));
382
383        let current = make_bloop(4, 0);
384        let past = vec![make_bloop(2, 10), make_bloop(6, 20), make_bloop(3, 30)];
385
386        let mut builder = TestCtxBuilder::new(current).bloops(past);
387        assert_eq!(
388            evaluator.evaluate(&builder.build()).into(),
389            EvalResult::NoAward
390        );
391    }
392
393    #[test]
394    fn projection_eval_ignores_bloops_outside_time_window() {
395        let evaluator = ProjectionMatchEvaluator::new(|n| n % 2, 2, Duration::from_secs(20));
396
397        let current = make_bloop(4, 0);
398        let past = vec![make_bloop(2, 10), make_bloop(6, 25), make_bloop(8, 30)];
399
400        let mut builder = TestCtxBuilder::new(current).bloops(past);
401        assert_eq!(
402            evaluator.evaluate(&builder.build()).into(),
403            EvalResult::NoAward
404        );
405    }
406
407    #[test]
408    fn projection_eval_takes_only_min_required_recent_matches() {
409        let evaluator = ProjectionMatchEvaluator::new(|n| n, 2, Duration::from_secs(60));
410
411        let current = make_bloop(4, 0);
412        let past = vec![make_bloop(4, 10), make_bloop(4, 15), make_bloop(4, 20)];
413
414        let mut builder = TestCtxBuilder::new(current).bloops(past);
415        assert_eq!(
416            evaluator.evaluate(&builder.build()).into(),
417            EvalResult::AwardSelf
418        );
419    }
420
421    #[test]
422    fn returns_true_for_perfect_squares() {
423        for n in [
424            0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 1024, 4096, 10000,
425        ] {
426            assert!(
427                internal_is_perfect_square_no_cache(n),
428                "{n} should be a perfect square"
429            );
430        }
431    }
432
433    #[test]
434    fn returns_false_for_non_squares() {
435        for n in [
436            2, 3, 5, 6, 8, 10, 26, 50, 63, 65, 99, 101, 123, 10001, 12345,
437        ] {
438            assert!(
439                !internal_is_perfect_square_no_cache(n),
440                "{n} should not be a perfect square"
441            );
442        }
443    }
444
445    #[test]
446    fn returns_true_for_primes() {
447        for n in [
448            2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 101, 997,
449        ] {
450            assert!(internal_is_prime_no_cache(n), "{n} should be prime");
451        }
452    }
453
454    #[test]
455    fn returns_false_for_non_primes() {
456        for n in [
457            0, 1, 4, 6, 8, 9, 10, 12, 15, 21, 25, 27, 100, 1024, 4096, 10000,
458        ] {
459            assert!(!internal_is_prime_no_cache(n), "{n} should not be prime");
460        }
461    }
462
463    #[test]
464    fn returns_true_for_fibonacci_numbers() {
465        for n in [
466            0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597,
467        ] {
468            assert!(
469                internal_is_fibonacci_no_cache(n),
470                "{n} should be a Fibonacci number"
471            );
472        }
473    }
474
475    #[test]
476    fn returns_false_for_non_fibonacci_numbers() {
477        for n in [
478            4, 6, 7, 9, 10, 11, 12, 14, 15, 22, 100, 200, 1000, 1234, 2024,
479        ] {
480            assert!(
481                !internal_is_fibonacci_no_cache(n),
482                "{n} should not be a Fibonacci number"
483            );
484        }
485    }
486}