bloop_server_framework/evaluator/
time.rs1use crate::achievement::AchievementContext;
2use crate::evaluator::{EvalResult, Evaluator};
3use chrono::{LocalResult, NaiveTime, Timelike};
4use std::fmt::Debug;
5use std::time::Duration;
6use thiserror::Error;
7
8#[derive(Debug)]
29pub struct TimeEvaluator {
30 hour: Option<u32>,
31 minute: Option<u32>,
32 timezone: chrono_tz::Tz,
33 leeway: Duration,
34}
35
36#[derive(Debug, Error)]
37pub enum Error {
38 #[error("hour must be between 0 and 23")]
39 InvalidHour(u32),
40 #[error("minute must be between 0 and 59")]
41 InvalidMinute(u32),
42}
43
44type Result<T> = std::result::Result<T, Error>;
45
46impl TimeEvaluator {
47 pub fn new(
63 hour: Option<u32>,
64 minute: Option<u32>,
65 timezone: chrono_tz::Tz,
66 leeway: Option<Duration>,
67 ) -> Result<Self> {
68 if let Some(hour) = hour {
69 if hour > 23 {
70 return Err(Error::InvalidHour(hour));
71 }
72 }
73
74 if let Some(minute) = minute {
75 if minute > 59 {
76 return Err(Error::InvalidMinute(minute));
77 }
78 }
79
80 Ok(Self {
81 hour,
82 minute,
83 timezone,
84 leeway: leeway.unwrap_or_default(),
85 })
86 }
87}
88
89impl<Player, Metadata, Trigger> Evaluator<Player, Metadata, Trigger> for TimeEvaluator {
90 fn evaluate(
91 &self,
92 ctx: &AchievementContext<Player, Metadata, Trigger>,
93 ) -> impl Into<EvalResult> {
94 let now = ctx.current_bloop.recorded_at.with_timezone(&self.timezone);
95
96 let target_time = now.with_time(
97 NaiveTime::from_hms_opt(
98 self.hour.unwrap_or(now.hour()),
99 self.minute.unwrap_or(now.minute()),
100 0,
101 )
102 .unwrap(),
103 );
104
105 let target_time = match target_time {
106 LocalResult::Single(target_time) => target_time,
107 LocalResult::Ambiguous(earliest, _) => earliest,
108 LocalResult::None => return false,
109 };
110
111 now >= target_time && now <= target_time + self.leeway
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::bloop::Bloop;
119 use crate::evaluator::{EvalResult, Evaluator};
120 use crate::test_utils::{MockPlayer, TestCtxBuilder};
121 use chrono::{DateTime, TimeZone, Utc};
122 use chrono_tz::Europe::Berlin;
123 use std::time::Duration;
124
125 fn make_ctx_builder_for_time(dt: DateTime<Utc>) -> TestCtxBuilder<MockPlayer, (), ()> {
126 let (player, _) = MockPlayer::builder().build();
127 let bloop = Bloop::new(player.clone(), "client1", dt);
128 TestCtxBuilder::new(bloop)
129 }
130
131 #[test]
132 fn matches_exact_hour_minute() {
133 let time = Berlin
134 .with_ymd_and_hms(2024, 10, 1, 14, 30, 0)
135 .unwrap()
136 .with_timezone(&Utc);
137 let mut builder = make_ctx_builder_for_time(time);
138 let evaluator = TimeEvaluator::new(Some(14), Some(30), Berlin, None).unwrap();
139 assert_eq!(
140 evaluator.evaluate(&builder.build()).into(),
141 EvalResult::AwardSelf
142 );
143 }
144
145 #[test]
146 fn within_leeway() {
147 let time = Berlin
148 .with_ymd_and_hms(2024, 10, 1, 14, 30, 30)
149 .unwrap()
150 .with_timezone(&Utc);
151 let mut builder = make_ctx_builder_for_time(time);
152 let evaluator =
153 TimeEvaluator::new(Some(14), Some(30), Berlin, Some(Duration::from_secs(60))).unwrap();
154 assert_eq!(
155 evaluator.evaluate(&builder.build()).into(),
156 EvalResult::AwardSelf
157 );
158 }
159
160 #[test]
161 fn outside_leeway_fails() {
162 let time = Berlin
163 .with_ymd_and_hms(2024, 10, 1, 14, 32, 0)
164 .unwrap()
165 .with_timezone(&Utc);
166 let mut builder = make_ctx_builder_for_time(time);
167 let evaluator =
168 TimeEvaluator::new(Some(14), Some(30), Berlin, Some(Duration::from_secs(60))).unwrap();
169 assert_eq!(
170 evaluator.evaluate(&builder.build()).into(),
171 EvalResult::NoAward
172 );
173 }
174
175 #[test]
176 fn with_only_hour() {
177 let time = Berlin
178 .with_ymd_and_hms(2024, 10, 1, 9, 15, 0)
179 .unwrap()
180 .with_timezone(&Utc);
181 let mut builder = make_ctx_builder_for_time(time);
182 let evaluator =
183 TimeEvaluator::new(Some(9), None, Berlin, Some(Duration::from_secs(1800))).unwrap();
184 assert_eq!(
185 evaluator.evaluate(&builder.build()).into(),
186 EvalResult::AwardSelf
187 );
188 }
189
190 #[test]
191 fn with_only_minute() {
192 let time = Berlin
193 .with_ymd_and_hms(2024, 10, 1, 22, 45, 0)
194 .unwrap()
195 .with_timezone(&Utc);
196 let mut builder = make_ctx_builder_for_time(time);
197 let evaluator =
198 TimeEvaluator::new(None, Some(45), Berlin, Some(Duration::from_secs(60))).unwrap();
199 assert_eq!(
200 evaluator.evaluate(&builder.build()).into(),
201 EvalResult::AwardSelf
202 );
203 }
204
205 #[test]
206 fn dst_gap_returns_false() {
207 let time = Utc.with_ymd_and_hms(2024, 10, 27, 1, 30, 0).unwrap();
208 let mut builder = make_ctx_builder_for_time(time);
209 let evaluator =
210 TimeEvaluator::new(Some(2), Some(30), Berlin, Some(Duration::from_secs(60))).unwrap();
211 assert_eq!(
212 evaluator.evaluate(&builder.build()).into(),
213 EvalResult::NoAward
214 );
215 }
216
217 #[test]
218 fn handles_ambiguous_time() {
219 let time = Utc.with_ymd_and_hms(2024, 10, 27, 0, 30, 0).unwrap();
220 let mut builder = make_ctx_builder_for_time(time);
221 let evaluator =
222 TimeEvaluator::new(Some(2), Some(30), Berlin, Some(Duration::from_secs(60))).unwrap();
223 assert_eq!(
224 evaluator.evaluate(&builder.build()).into(),
225 EvalResult::AwardSelf
226 );
227 }
228}