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