@crowdedkingdoms/ckx-kit 0.0.0-stage → 0.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE-APACHE +176 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +76 -2
  4. package/checks/compute-toolchain/host-catalog.json +68 -0
  5. package/crates/ckx-sdk/Cargo.toml +17 -0
  6. package/crates/ckx-sdk/LICENSE-APACHE +176 -0
  7. package/crates/ckx-sdk/LICENSE-MIT +21 -0
  8. package/crates/ckx-sdk/README.md +47 -0
  9. package/crates/ckx-sdk/src/abi.rs +115 -0
  10. package/crates/ckx-sdk/src/cron.rs +717 -0
  11. package/crates/ckx-sdk/src/emit.rs +140 -0
  12. package/crates/ckx-sdk/src/lib.rs +588 -0
  13. package/crates/ckx-sdk/src/model.rs +482 -0
  14. package/crates/ckx-sdk/src/node.rs +942 -0
  15. package/crates/ckx-sdk/src/rt.rs +519 -0
  16. package/crates/crowdy-client-sdk/Cargo.toml +15 -0
  17. package/crates/crowdy-client-sdk/LICENSE-APACHE +176 -0
  18. package/crates/crowdy-client-sdk/LICENSE-MIT +21 -0
  19. package/crates/crowdy-client-sdk/README.md +25 -0
  20. package/crates/crowdy-client-sdk/src/lib.rs +698 -0
  21. package/dist/checks/src/build/client-capabilities.js +123 -0
  22. package/dist/checks/src/build/exec-source.js +343 -0
  23. package/dist/checks/src/build/rust-source-guard.js +165 -0
  24. package/dist/checks/src/build/wasm-inspect.js +249 -0
  25. package/dist/cli/src/build.js +99 -0
  26. package/dist/cli/src/crate.js +64 -0
  27. package/dist/cli/src/doctor.js +51 -0
  28. package/dist/cli/src/kit.js +66 -0
  29. package/dist/cli/src/main.js +109 -0
  30. package/dist/cli/src/new.js +74 -0
  31. package/dist/cli/src/tools.js +104 -0
  32. package/kit.pin +9 -0
  33. package/package.json +81 -4
  34. package/rust-toolchain.toml +6 -0
  35. package/starters/ckx.json +9 -0
  36. package/starters/grid-mod/Cargo.toml +13 -0
  37. package/starters/grid-mod/src/lib.rs +82 -0
  38. package/starters/matchmaker/Cargo.toml +13 -0
  39. package/starters/matchmaker/src/lib.rs +362 -0
  40. package/starters/npc-mobs/Cargo.toml +14 -0
  41. package/starters/npc-mobs/src/lib.rs +469 -0
  42. package/starters/session/Cargo.toml +13 -0
  43. package/starters/session/src/lib.rs +395 -0
  44. package/starters/world-tick/Cargo.toml +14 -0
  45. package/starters/world-tick/src/lib.rs +346 -0
  46. package/templates/client/Cargo.toml +16 -0
  47. package/templates/client/src/lib.rs +31 -0
  48. package/templates/hub/Cargo.toml +11 -0
  49. package/templates/hub/src/lib.rs +73 -0
  50. package/templates/spoke/Cargo.toml +11 -0
  51. package/templates/spoke/src/lib.rs +53 -0
  52. package/tools/instrument/Cargo.lock +140 -0
  53. package/tools/instrument/Cargo.toml +11 -0
  54. package/tools/instrument/src/main.rs +165 -0
  55. package/vendor.lock +167 -0
@@ -0,0 +1,717 @@
1
+ //! Cron schedules for hub timers: the legacy automations' `cron` schedule kind.
2
+ //!
3
+ //! Parse an expression once, then arm the next occurrence with [`Ctx::timer_cron`] (or
4
+ //! [`Cron::next_after`] and [`Ctx::timer_after`]) when the hub starts and again in
5
+ //! [`Hub::on_timer`](crate::Hub::on_timer). A run that came due while the hub was stopped fires
6
+ //! once when it is back, and the next one is counted from then: missed runs are skipped, not
7
+ //! made up, as legacy's dispatcher skipped them.
8
+ //!
9
+ //! ```ignore
10
+ //! const RESTOCK: &str = "0 */6 * * *";
11
+ //!
12
+ //! fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
13
+ //! ctx.timer_cron("restock", &Cron::parse(RESTOCK)?)?;
14
+ //! Ok(Self::default())
15
+ //! }
16
+ //!
17
+ //! fn on_timer(&mut self, ctx: &Ctx, timer: &str) -> Result<()> {
18
+ //! if timer == "restock" {
19
+ //! self.restock();
20
+ //! ctx.timer_cron("restock", &Cron::parse(RESTOCK)?)?;
21
+ //! }
22
+ //! Ok(())
23
+ //! }
24
+ //! ```
25
+ //!
26
+ //! # Syntax
27
+ //!
28
+ //! What legacy automations accepted (they used cron-parser 4.9), matched the same way:
29
+ //!
30
+ //! - Five fields, `minute hour day-of-month month day-of-week`, or six with `second` first.
31
+ //! Fewer than five leave the leading fields at their defaults (second `0`, the rest `*`), so
32
+ //! the last field is always the day of the week.
33
+ //! - `*`, `?` (day of month and day of week only; the same as `*`), lists `a,b`, ranges `a-b`,
34
+ //! steps `*/n`, `a-b/n` and `a/n` (from `a` to the field's end).
35
+ //! - Ranges: second and minute 0-59, hour 0-23, day of month 1-31, month 1-12, day of week 0-7
36
+ //! (0 and 7 are Sunday). Month and day names (`JAN`-`DEC`, `SUN`-`SAT`), any case.
37
+ //! - `L` in the day of month: its last day. `nL` in the day of week: the month's last such
38
+ //! weekday (`5L`, the last Friday). `n#k` as the whole day-of-week field: the k-th such
39
+ //! weekday of the month (`1#2`, the second Monday; k 1-5).
40
+ //! - `@yearly`, `@monthly`, `@weekly`, `@daily` and `@hourly`.
41
+ //! - When both the day of month and the day of week are restricted, a day matches either one:
42
+ //! `0 0 13 * 5` runs on the 13th and on every Friday. A field counts as unrestricted when it
43
+ //! lists every value, as legacy decided it: `*`, `?`, `1-7` or `0-7` in the day of week (but
44
+ //! not `0-6`), and in the day of month any list as long as the month has days (`1-30` is
45
+ //! unrestricted in April and restricted in May). With one month named, days past its end are
46
+ //! dropped, and a first day past it is refused (`* * 30 2 *`).
47
+ //!
48
+ //! Times are UTC unless [`Cron::with_utc_offset`] names a fixed offset. Legacy computed in the
49
+ //! API host's zone, UTC on every tier, and had no per-schedule zone; there are no named time
50
+ //! zones or daylight saving here.
51
+ //!
52
+ //! Stricter than legacy: an empty expression, a step on `L` (`L/2`), `15L` in the day of month,
53
+ //! a range with two dashes and `L` alone in the day of week are refused. Legacy took the first
54
+ //! four without complaint and failed the last at its first run.
55
+
56
+ use std::fmt;
57
+ use std::str::FromStr;
58
+
59
+ use crate::{Ctx, Error, Result};
60
+
61
+ /// A parsed cron expression. See the [module docs](self) for the syntax.
62
+ #[derive(Debug, Clone, PartialEq, Eq)]
63
+ pub struct Cron {
64
+ source: String,
65
+ seconds: u64,
66
+ minutes: u64,
67
+ hours: u32,
68
+ /// Days of the month, bits 1-31.
69
+ days: u32,
70
+ /// `L` in the day of month.
71
+ last_day: bool,
72
+ /// Entries in the day-of-month field as legacy counted them, which decides whether it is
73
+ /// unrestricted.
74
+ day_entries: usize,
75
+ /// Months, bits 1-12.
76
+ months: u16,
77
+ /// Days of the week, bits 0-6 (Sunday 0).
78
+ weekdays: u8,
79
+ /// `nL`: the last such weekday of the month.
80
+ last_weekdays: u8,
81
+ weekday_entries: usize,
82
+ /// `n#k`: only the k-th week of the month (days 7k-6 to 7k); 0 when not given.
83
+ nth: u8,
84
+ /// Seconds east of UTC.
85
+ offset_s: i64,
86
+ }
87
+
88
+ /// Why an expression was refused.
89
+ #[derive(Debug, Clone, PartialEq, Eq)]
90
+ pub struct CronError(pub String);
91
+
92
+ impl fmt::Display for CronError {
93
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94
+ f.write_str(&self.0)
95
+ }
96
+ }
97
+
98
+ impl std::error::Error for CronError {}
99
+
100
+ impl From<CronError> for Error {
101
+ fn from(e: CronError) -> Self {
102
+ Error(e.0)
103
+ }
104
+ }
105
+
106
+ impl fmt::Display for Cron {
107
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108
+ f.write_str(&self.source)
109
+ }
110
+ }
111
+
112
+ impl FromStr for Cron {
113
+ type Err = CronError;
114
+
115
+ fn from_str(s: &str) -> Result<Self, CronError> {
116
+ Cron::parse(s)
117
+ }
118
+ }
119
+
120
+ /// Days in each month as legacy counted them for the unrestricted-day test: February has 29.
121
+ const LEGACY_MONTH_DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
122
+ /// The first occurrence is looked for this far ahead: the Gregorian calendar repeats every 400
123
+ /// years, so an expression that has not matched by then never will.
124
+ const SEARCH_MONTHS: u32 = 400 * 12 + 1;
125
+ const MAX_OFFSET_MINUTES: i32 = 18 * 60;
126
+
127
+ const PREDEFINED: [(&str, &str); 5] =
128
+ [("@yearly", "0 0 1 1 *"), ("@monthly", "0 0 1 * *"), ("@weekly", "0 0 * * 0"), ("@daily", "0 0 * * *"), ("@hourly", "0 * * * *")];
129
+ const MONTH_NAMES: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
130
+ const DAY_NAMES: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
131
+
132
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
133
+ enum Field {
134
+ Second,
135
+ Minute,
136
+ Hour,
137
+ Day,
138
+ Month,
139
+ Weekday,
140
+ }
141
+
142
+ impl Field {
143
+ const ALL: [Field; 6] = [Field::Second, Field::Minute, Field::Hour, Field::Day, Field::Month, Field::Weekday];
144
+
145
+ fn range(self) -> (u32, u32) {
146
+ match self {
147
+ Field::Second | Field::Minute => (0, 59),
148
+ Field::Hour => (0, 23),
149
+ Field::Day => (1, 31),
150
+ Field::Month => (1, 12),
151
+ Field::Weekday => (0, 7),
152
+ }
153
+ }
154
+
155
+ fn name(self) -> &'static str {
156
+ match self {
157
+ Field::Second => "second",
158
+ Field::Minute => "minute",
159
+ Field::Hour => "hour",
160
+ Field::Day => "day of month",
161
+ Field::Month => "month",
162
+ Field::Weekday => "day of week",
163
+ }
164
+ }
165
+ }
166
+
167
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
168
+ enum Value {
169
+ N(u32),
170
+ /// `L` in the day of month.
171
+ Last,
172
+ /// `nL` in the day of week.
173
+ LastOf(u32),
174
+ }
175
+
176
+ fn refuse(msg: impl Into<String>) -> CronError {
177
+ CronError(msg.into())
178
+ }
179
+
180
+ fn number(field: Field, s: &str) -> Result<u32, CronError> {
181
+ if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || s.len() > 9 {
182
+ return Err(refuse(format!("{}: `{s}` is not a number", field.name())));
183
+ }
184
+ let v: u32 = s.parse().map_err(|_| refuse(format!("{}: `{s}` is not a number", field.name())))?;
185
+ let (min, max) = field.range();
186
+ if v < min || v > max {
187
+ return Err(refuse(format!("{}: {v} is outside {min}-{max}", field.name())));
188
+ }
189
+ Ok(v)
190
+ }
191
+
192
+ /// Replaces month or day names with their numbers, as legacy did: any run of three letters.
193
+ fn replace_names(field: Field, raw: &str) -> Result<String, CronError> {
194
+ let names: &[&str] = match field {
195
+ Field::Month => &MONTH_NAMES,
196
+ Field::Weekday => &DAY_NAMES,
197
+ _ => return Ok(raw.to_owned()),
198
+ };
199
+ let base = if field == Field::Month { 1 } else { 0 };
200
+ let mut out = String::with_capacity(raw.len());
201
+ let bytes = raw.as_bytes();
202
+ let mut i = 0;
203
+ while i < bytes.len() {
204
+ if i + 3 <= bytes.len() && bytes[i..i + 3].iter().all(u8::is_ascii_alphabetic) {
205
+ let word = raw[i..i + 3].to_ascii_lowercase();
206
+ let n = names.iter().position(|n| *n == word).ok_or_else(|| refuse(format!("{}: unknown name `{word}`", field.name())))?;
207
+ out.push_str(&(n + base).to_string());
208
+ i += 3;
209
+ } else {
210
+ out.push(bytes[i] as char);
211
+ i += 1;
212
+ }
213
+ }
214
+ Ok(out)
215
+ }
216
+
217
+ /// One field's values in legacy's order and multiplicity: the entry count matters.
218
+ fn parse_field(field: Field, raw: &str) -> Result<Vec<Value>, CronError> {
219
+ let text = replace_names(field, raw)?;
220
+ let allowed = |c: char| match field {
221
+ Field::Day | Field::Weekday => c.is_ascii_digit() || ",*?L/-".contains(c),
222
+ _ => c.is_ascii_digit() || ",*/-".contains(c),
223
+ };
224
+ if let Some(c) = text.chars().find(|c| !allowed(*c)) {
225
+ return Err(refuse(format!("{}: `{c}` is not allowed in `{raw}`", field.name())));
226
+ }
227
+ let (min, max) = field.range();
228
+ let text = if text.contains('*') {
229
+ text.replace('*', &format!("{min}-{max}"))
230
+ } else if text.contains('?') {
231
+ text.replace('?', &format!("{min}-{max}"))
232
+ } else {
233
+ text
234
+ };
235
+ let mut out = Vec::new();
236
+ for item in text.split(',') {
237
+ if item.is_empty() {
238
+ return Err(refuse(format!("{}: an empty item in `{raw}`", field.name())));
239
+ }
240
+ parse_item(field, item, &mut out)?;
241
+ }
242
+ Ok(out)
243
+ }
244
+
245
+ fn parse_item(field: Field, item: &str, out: &mut Vec<Value>) -> Result<(), CronError> {
246
+ let (_, max) = field.range();
247
+ let (base, step) = match item.split_once('/') {
248
+ None => (item, None),
249
+ Some((base, step)) => {
250
+ if step.contains('/') {
251
+ return Err(refuse(format!("{}: `{item}` has two steps", field.name())));
252
+ }
253
+ let step: u32 = match step.parse() {
254
+ Ok(n) if n > 0 && step.bytes().all(|b| b.is_ascii_digit()) => n,
255
+ _ => return Err(refuse(format!("{}: `{step}` is not a step", field.name()))),
256
+ };
257
+ (base, Some(step))
258
+ }
259
+ };
260
+ if let Some(step) = step {
261
+ // `a/n` runs from a to the field's end.
262
+ let (lo, hi) = match base.split_once('-') {
263
+ Some((lo, hi)) => (lo, hi),
264
+ None if !base.is_empty() && base.bytes().all(|b| b.is_ascii_digit()) => (base, ""),
265
+ None => return Err(refuse(format!("{}: a step needs a number or a range, not `{base}`", field.name()))),
266
+ };
267
+ let hi = if hi.is_empty() && !base.contains('-') { max.to_string() } else { hi.to_owned() };
268
+ return range(field, lo, &hi, step, out);
269
+ }
270
+ if let Some((lo, hi)) = base.split_once('-') {
271
+ return range(field, lo, hi, 1, out);
272
+ }
273
+ match (field, base) {
274
+ (Field::Day, "L") => out.push(Value::Last),
275
+ (Field::Weekday, "L") => return Err(refuse("day of week: `L` needs a weekday, as in `5L`")),
276
+ (Field::Weekday, s) if s.len() == 2 && s.ends_with('L') => out.push(Value::LastOf(number(field, &s[..1])? % 7)),
277
+ (_, s) if s.contains('L') => return Err(refuse(format!("{}: `{s}` is not supported", field.name()))),
278
+ (Field::Weekday, s) => out.push(Value::N(number(field, s)? % 7)),
279
+ (_, s) => out.push(Value::N(number(field, s)?)),
280
+ }
281
+ Ok(())
282
+ }
283
+
284
+ /// `lo-hi` every `step`. As legacy did, a day-of-week range ending at 7 (or 0) also lists 0.
285
+ fn range(field: Field, lo: &str, hi: &str, step: u32, out: &mut Vec<Value>) -> Result<(), CronError> {
286
+ if hi.contains('-') {
287
+ return Err(refuse(format!("{}: `{lo}-{hi}` is not a range", field.name())));
288
+ }
289
+ let (lo, hi) = (number(field, lo)?, number(field, hi)?);
290
+ if lo > hi {
291
+ return Err(refuse(format!("{}: the range {lo}-{hi} runs backwards", field.name())));
292
+ }
293
+ let mut values = Vec::new();
294
+ if field == Field::Weekday && hi.is_multiple_of(7) {
295
+ values.push(0);
296
+ }
297
+ let mut count = step;
298
+ for v in lo..=hi {
299
+ if !values.contains(&v) && count.is_multiple_of(step) {
300
+ count = 1;
301
+ values.push(v);
302
+ } else {
303
+ count += 1;
304
+ }
305
+ }
306
+ out.extend(values.into_iter().map(Value::N));
307
+ Ok(())
308
+ }
309
+
310
+ fn mask(values: &[Value]) -> u64 {
311
+ values.iter().fold(0, |m, v| match v {
312
+ Value::N(n) => m | 1 << n,
313
+ _ => m,
314
+ })
315
+ }
316
+
317
+ impl Cron {
318
+ /// Parses an expression; see the [module docs](self) for what is accepted.
319
+ pub fn parse(expr: &str) -> Result<Self, CronError> {
320
+ let trimmed = expr.trim();
321
+ let text = PREDEFINED.iter().find(|(name, _)| *name == trimmed).map_or(trimmed, |(_, e)| e);
322
+ let atoms: Vec<&str> = text.split_whitespace().collect();
323
+ if atoms.is_empty() {
324
+ return Err(refuse("the expression is empty"));
325
+ }
326
+ if atoms.len() > 6 {
327
+ return Err(refuse(format!("{} fields; a cron expression has five, or six with seconds", atoms.len())));
328
+ }
329
+ let skip = 6 - atoms.len();
330
+ let mut nth = 0u8;
331
+ let mut fields: Vec<Vec<Value>> = Vec::with_capacity(6);
332
+ for (i, field) in Field::ALL.into_iter().enumerate() {
333
+ let raw = if i < skip { if i == 0 { "0" } else { "*" } } else { atoms[i - skip] };
334
+ let raw = match (field, raw.split_once('#')) {
335
+ (Field::Weekday, Some((day, k))) => {
336
+ if raw.contains(',') || raw.contains('/') || raw.contains('-') || k.contains('#') {
337
+ return Err(refuse(format!("day of week: `#` stands alone, as in `1#2`, not `{raw}`")));
338
+ }
339
+ nth = match k.parse::<u8>() {
340
+ Ok(n @ 1..=5) if k.bytes().all(|b| b.is_ascii_digit()) => n,
341
+ _ => return Err(refuse(format!("day of week: `#{k}` is not a week of the month (1-5)"))),
342
+ };
343
+ day
344
+ }
345
+ _ => raw,
346
+ };
347
+ fields.push(parse_field(field, raw)?);
348
+ }
349
+ let mut days = fields[3].clone();
350
+ let months = &fields[4];
351
+ if let [Value::N(m)] = months[..] {
352
+ // One month named: its missing days go, and a first day past its end is refused.
353
+ let last = LEGACY_MONTH_DAYS[m as usize - 1];
354
+ if let Some(first) = days.iter().filter_map(|v| if let Value::N(d) = v { Some(*d) } else { None }).min()
355
+ && first > last
356
+ {
357
+ return Err(refuse(format!("day of month: month {m} has no day {first}")));
358
+ }
359
+ days.retain(|v| !matches!(v, Value::N(d) if *d > last));
360
+ }
361
+ Ok(Self {
362
+ source: expr.trim().to_owned(),
363
+ seconds: mask(&fields[0]),
364
+ minutes: mask(&fields[1]),
365
+ hours: mask(&fields[2]) as u32,
366
+ days: mask(&days) as u32,
367
+ last_day: days.contains(&Value::Last),
368
+ day_entries: days.len(),
369
+ months: mask(months) as u16,
370
+ weekdays: (mask(&fields[5]) & 0x7f) as u8,
371
+ last_weekdays: fields[5].iter().fold(0, |m, v| if let Value::LastOf(d) = v { m | 1 << d } else { m }),
372
+ weekday_entries: fields[5].len(),
373
+ nth,
374
+ offset_s: 0,
375
+ })
376
+ }
377
+
378
+ /// The same schedule in a fixed offset from UTC, in minutes east (`-300` for UTC-5), within
379
+ /// ±18 hours. There is no daylight saving: a zone that has it needs a new offset when its
380
+ /// clocks change.
381
+ pub fn with_utc_offset(mut self, minutes: i32) -> Result<Self, CronError> {
382
+ if minutes.abs() > MAX_OFFSET_MINUTES {
383
+ return Err(refuse(format!("a UTC offset is within ±{MAX_OFFSET_MINUTES} minutes, not {minutes}")));
384
+ }
385
+ self.offset_s = minutes as i64 * 60;
386
+ Ok(self)
387
+ }
388
+
389
+ /// The first time it fires strictly after `after_ms` (Unix milliseconds), on a whole second;
390
+ /// `None` when it never does.
391
+ pub fn next_after(&self, after_ms: u64) -> Option<u64> {
392
+ let start = (after_ms / 1000) as i64 + 1 + self.offset_s;
393
+ let (mut year, mut month, mut day) = civil(start.div_euclid(86_400));
394
+ let second_of_day = start.rem_euclid(86_400) as u32;
395
+ let mut from = Some((second_of_day / 3600, second_of_day / 60 % 60, second_of_day % 60));
396
+ for _ in 0..SEARCH_MONTHS {
397
+ if self.months & 1 << month != 0 {
398
+ let len = month_days(year, month);
399
+ for d in day..=len {
400
+ if self.day_matches(year, month, d, len)
401
+ && let Some((h, m, s)) = self.time_from(from.unwrap_or((0, 0, 0)))
402
+ {
403
+ let local = days_from_civil(year, month, d) * 86_400 + (h * 3600 + m * 60 + s) as i64;
404
+ return u64::try_from(local - self.offset_s).ok().map(|t| t * 1000);
405
+ }
406
+ from = None;
407
+ }
408
+ }
409
+ from = None;
410
+ day = 1;
411
+ (year, month) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
412
+ }
413
+ None
414
+ }
415
+
416
+ fn day_matches(&self, year: i64, month: u32, day: u32, len: u32) -> bool {
417
+ let weekday = (days_from_civil(year, month, day) + 4).rem_euclid(7) as u32;
418
+ let day_match = self.days & 1 << day != 0 || (self.last_day && day == len);
419
+ let weekday_match = self.weekdays & 1 << weekday != 0 || (self.last_weekdays & 1 << weekday != 0 && day + 7 > len);
420
+ let any_day = self.day_entries >= LEGACY_MONTH_DAYS[month as usize - 1] as usize;
421
+ let any_weekday = self.weekday_entries == 8;
422
+ if !day_match && (!weekday_match || any_weekday) {
423
+ return false;
424
+ }
425
+ if any_day && !any_weekday && !weekday_match {
426
+ return false;
427
+ }
428
+ self.nth == 0 || day.div_ceil(7) == self.nth as u32
429
+ }
430
+
431
+ /// The first matching time of day at or after `(hour, minute, second)`.
432
+ fn time_from(&self, (h0, m0, s0): (u32, u32, u32)) -> Option<(u32, u32, u32)> {
433
+ for h in (h0..24).filter(|h| self.hours & 1 << h != 0) {
434
+ let m_from = if h == h0 { m0 } else { 0 };
435
+ for m in (m_from..60).filter(|m| self.minutes & 1 << m != 0) {
436
+ let s_from = if h == h0 && m == m0 { s0 } else { 0 };
437
+ let left = self.seconds & (u64::MAX << s_from);
438
+ if left != 0 {
439
+ return Some((h, m, left.trailing_zeros()));
440
+ }
441
+ }
442
+ }
443
+ None
444
+ }
445
+ }
446
+
447
+ impl Ctx {
448
+ /// Runs [`Hub::on_timer`](crate::Hub::on_timer) with `name` at the next time `cron` fires,
449
+ /// replacing any timer of that name. Call it again from `on_timer` to keep the schedule.
450
+ pub fn timer_cron(&self, name: &str, cron: &Cron) -> Result<()> {
451
+ let now = self.now_ms();
452
+ let next = cron.next_after(now).ok_or_else(|| Error(format!("`{cron}` never fires")))?;
453
+ self.timer_after(name, next - now)
454
+ }
455
+ }
456
+
457
+ fn is_leap(year: i64) -> bool {
458
+ (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
459
+ }
460
+
461
+ fn month_days(year: i64, month: u32) -> u32 {
462
+ if month == 2 && !is_leap(year) { 28 } else { LEGACY_MONTH_DAYS[month as usize - 1] }
463
+ }
464
+
465
+ /// Days since 1970-01-01 of a proleptic Gregorian date (Howard Hinnant's `days_from_civil`).
466
+ fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
467
+ let y = if month <= 2 { year - 1 } else { year };
468
+ let era = y.div_euclid(400);
469
+ let yoe = y - era * 400;
470
+ let mp = (month as i64 + 9) % 12;
471
+ let doy = (153 * mp + 2) / 5 + day as i64 - 1;
472
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
473
+ era * 146_097 + doe - 719_468
474
+ }
475
+
476
+ /// The date `days` after 1970-01-01.
477
+ fn civil(days: i64) -> (i64, u32, u32) {
478
+ let z = days + 719_468;
479
+ let era = z.div_euclid(146_097);
480
+ let doe = z - era * 146_097;
481
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
482
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
483
+ let mp = (5 * doy + 2) / 153;
484
+ let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
485
+ let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
486
+ (yoe + era * 400 + i64::from(month <= 2), month, day)
487
+ }
488
+
489
+ #[cfg(test)]
490
+ mod tests {
491
+ use super::*;
492
+
493
+ /// Unix milliseconds of `YYYY-MM-DDTHH:MM:SS.mmmZ`.
494
+ fn ms(iso: &str) -> u64 {
495
+ let n = |r: std::ops::Range<usize>| iso[r].parse::<u32>().unwrap();
496
+ let days = days_from_civil(n(0..4) as i64, n(5..7), n(8..10));
497
+ let secs = days * 86_400 + (n(11..13) * 3600 + n(14..16) * 60 + n(17..19)) as i64;
498
+ secs as u64 * 1000 + n(20..23) as u64
499
+ }
500
+
501
+ fn iso(ms: u64) -> String {
502
+ let secs = (ms / 1000) as i64;
503
+ let (y, mo, d) = civil(secs.div_euclid(86_400));
504
+ let s = secs.rem_euclid(86_400);
505
+ format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", s / 3600, s / 60 % 60, s % 60, ms % 1000)
506
+ }
507
+
508
+ // Generated from legacy's cron-parser 4.9.0 (cks-game-api's `computeNextRunAt`) with TZ=UTC:
509
+ // each expression, the instant it is asked from, and its next occurrences in order.
510
+ const GOLDEN: &[(&str, &str, &[&str])] = &[
511
+ ("* * * * *", "2026-09-26T12:00:00.000Z", &["2026-09-26T12:01:00.000Z", "2026-09-26T12:02:00.000Z", "2026-09-26T12:03:00.000Z"]),
512
+ ("* * * * *", "2026-09-26T12:00:30.500Z", &["2026-09-26T12:01:00.000Z", "2026-09-26T12:02:00.000Z"]),
513
+ (
514
+ "*/15 * * * *",
515
+ "2026-09-26T12:07:00.000Z",
516
+ &["2026-09-26T12:15:00.000Z", "2026-09-26T12:30:00.000Z", "2026-09-26T12:45:00.000Z", "2026-09-26T13:00:00.000Z"],
517
+ ),
518
+ (
519
+ "5/15 * * * *",
520
+ "2026-09-26T12:00:00.000Z",
521
+ &[
522
+ "2026-09-26T12:05:00.000Z",
523
+ "2026-09-26T12:20:00.000Z",
524
+ "2026-09-26T12:35:00.000Z",
525
+ "2026-09-26T12:50:00.000Z",
526
+ "2026-09-26T13:05:00.000Z",
527
+ ],
528
+ ),
529
+ (
530
+ "*/10 * * * * *",
531
+ "2026-09-26T12:00:05.000Z",
532
+ &["2026-09-26T12:00:10.000Z", "2026-09-26T12:00:20.000Z", "2026-09-26T12:00:30.000Z"],
533
+ ),
534
+ ("15 10 * * * *", "2026-09-26T12:10:15.000Z", &["2026-09-26T13:10:15.000Z", "2026-09-26T14:10:15.000Z"]),
535
+ ("0 0 1 * *", "2026-01-31T23:59:59.000Z", &["2026-02-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z", "2026-04-01T00:00:00.000Z"]),
536
+ (
537
+ "0 0 31 * *",
538
+ "2026-01-31T00:00:00.000Z",
539
+ &["2026-03-31T00:00:00.000Z", "2026-05-31T00:00:00.000Z", "2026-07-31T00:00:00.000Z", "2026-08-31T00:00:00.000Z"],
540
+ ),
541
+ (
542
+ "0 0 L * *",
543
+ "2026-01-15T00:00:00.000Z",
544
+ &["2026-01-31T00:00:00.000Z", "2026-02-28T00:00:00.000Z", "2026-03-31T00:00:00.000Z", "2026-04-30T00:00:00.000Z"],
545
+ ),
546
+ ("0 0 L * *", "2028-01-31T00:00:00.001Z", &["2028-02-29T00:00:00.000Z", "2028-03-31T00:00:00.000Z"]),
547
+ ("0 0 29 2 *", "2026-03-01T00:00:00.000Z", &["2028-02-29T00:00:00.000Z", "2032-02-29T00:00:00.000Z"]),
548
+ ("0 0 29 2 *", "2096-03-01T00:00:00.000Z", &["2104-02-29T00:00:00.000Z", "2108-02-29T00:00:00.000Z"]),
549
+ (
550
+ "0 0 13 * 5",
551
+ "2026-09-26T00:00:00.000Z",
552
+ &[
553
+ "2026-10-02T00:00:00.000Z",
554
+ "2026-10-09T00:00:00.000Z",
555
+ "2026-10-13T00:00:00.000Z",
556
+ "2026-10-16T00:00:00.000Z",
557
+ "2026-10-23T00:00:00.000Z",
558
+ ],
559
+ ),
560
+ ("0 0 13 * *", "2026-09-26T00:00:00.000Z", &["2026-10-13T00:00:00.000Z", "2026-11-13T00:00:00.000Z"]),
561
+ ("0 0 * * 1", "2026-09-26T00:00:00.000Z", &["2026-09-28T00:00:00.000Z", "2026-10-05T00:00:00.000Z"]),
562
+ ("0 0 1 * 0-6", "2026-09-26T00:00:00.000Z", &["2026-09-27T00:00:00.000Z", "2026-09-28T00:00:00.000Z", "2026-09-29T00:00:00.000Z"]),
563
+ ("0 0 1 * 1-7", "2026-09-26T00:00:00.000Z", &["2026-10-01T00:00:00.000Z", "2026-11-01T00:00:00.000Z"]),
564
+ ("0 0 1 * 0,1,2,3,4,5,6,7", "2026-09-26T00:00:00.000Z", &["2026-10-01T00:00:00.000Z", "2026-11-01T00:00:00.000Z"]),
565
+ ("0 0 * * 5L", "2026-09-26T00:00:00.000Z", &["2026-10-30T00:00:00.000Z", "2026-11-27T00:00:00.000Z", "2026-12-25T00:00:00.000Z"]),
566
+ (
567
+ "0 0 * * 1,5L",
568
+ "2026-09-26T00:00:00.000Z",
569
+ &["2026-09-28T00:00:00.000Z", "2026-10-05T00:00:00.000Z", "2026-10-12T00:00:00.000Z", "2026-10-19T00:00:00.000Z"],
570
+ ),
571
+ ("0 0 * * 1#2", "2026-09-26T00:00:00.000Z", &["2026-10-12T00:00:00.000Z", "2026-11-09T00:00:00.000Z", "2026-12-14T00:00:00.000Z"]),
572
+ ("0 0 15 * 1#2", "2026-09-26T00:00:00.000Z", &["2026-10-12T00:00:00.000Z", "2026-11-09T00:00:00.000Z"]),
573
+ ("0 0 * * *#2", "2026-09-26T00:00:00.000Z", &["2026-10-08T00:00:00.000Z", "2026-10-09T00:00:00.000Z", "2026-10-10T00:00:00.000Z"]),
574
+ (
575
+ "0 9 * JAN-MAR mon-fri",
576
+ "2026-09-26T00:00:00.000Z",
577
+ &["2027-01-01T09:00:00.000Z", "2027-01-04T09:00:00.000Z", "2027-01-05T09:00:00.000Z"],
578
+ ),
579
+ ("0 0 * * 7", "2026-09-26T00:00:00.000Z", &["2026-09-27T00:00:00.000Z", "2026-10-04T00:00:00.000Z"]),
580
+ ("0 0 * * SUN", "2026-09-26T00:00:00.000Z", &["2026-09-27T00:00:00.000Z", "2026-10-04T00:00:00.000Z"]),
581
+ (
582
+ "0 0 * * 2-7/2",
583
+ "2026-09-26T00:00:00.000Z",
584
+ &["2026-09-27T00:00:00.000Z", "2026-09-29T00:00:00.000Z", "2026-10-01T00:00:00.000Z", "2026-10-03T00:00:00.000Z"],
585
+ ),
586
+ (
587
+ "30 4 1,15 * 5",
588
+ "2026-09-26T00:00:00.000Z",
589
+ &["2026-10-01T04:30:00.000Z", "2026-10-02T04:30:00.000Z", "2026-10-09T04:30:00.000Z", "2026-10-15T04:30:00.000Z"],
590
+ ),
591
+ ("0 12 * *", "2026-09-26T00:00:00.000Z", &["2026-10-12T00:00:00.000Z", "2026-10-12T00:01:00.000Z", "2026-10-12T00:02:00.000Z"]),
592
+ ("0 0 * 2 *", "2027-02-27T00:00:00.000Z", &["2027-02-28T00:00:00.000Z", "2028-02-01T00:00:00.000Z", "2028-02-02T00:00:00.000Z"]),
593
+ ("0 0 29-31 2 *", "2026-01-01T00:00:00.000Z", &["2028-02-29T00:00:00.000Z"]),
594
+ (
595
+ "0 0 1-30 * mon",
596
+ "2026-03-28T00:00:00.000Z",
597
+ &["2026-03-29T00:00:00.000Z", "2026-03-30T00:00:00.000Z", "2026-04-06T00:00:00.000Z", "2026-04-13T00:00:00.000Z"],
598
+ ),
599
+ ("0 0 12 ? * mon", "2026-09-26T00:00:00.000Z", &["2026-09-28T12:00:00.000Z", "2026-10-05T12:00:00.000Z"]),
600
+ ("0 0 0 1 1 *", "2026-09-26T00:00:00.000Z", &["2027-01-01T00:00:00.000Z", "2028-01-01T00:00:00.000Z"]),
601
+ ("@yearly", "2026-09-26T12:34:56.000Z", &["2027-01-01T00:00:00.000Z"]),
602
+ ("@monthly", "2026-09-26T12:34:56.000Z", &["2026-10-01T00:00:00.000Z"]),
603
+ ("@weekly", "2026-09-26T12:34:56.000Z", &["2026-09-27T00:00:00.000Z"]),
604
+ ("@daily", "2026-09-26T12:34:56.000Z", &["2026-09-27T00:00:00.000Z"]),
605
+ ("@hourly", "2026-09-26T12:34:56.000Z", &["2026-09-26T13:00:00.000Z"]),
606
+ (
607
+ "0 0-23/6 * * *",
608
+ "2026-09-26T13:00:00.000Z",
609
+ &["2026-09-26T18:00:00.000Z", "2026-09-27T00:00:00.000Z", "2026-09-27T06:00:00.000Z"],
610
+ ),
611
+ (
612
+ "10-20/5,45 3 * * *",
613
+ "2026-09-26T00:00:00.000Z",
614
+ &["2026-09-26T03:10:00.000Z", "2026-09-26T03:15:00.000Z", "2026-09-26T03:20:00.000Z", "2026-09-26T03:45:00.000Z"],
615
+ ),
616
+ ("0 0 1 */3 *", "2026-09-26T00:00:00.000Z", &["2026-10-01T00:00:00.000Z", "2027-01-01T00:00:00.000Z", "2027-04-01T00:00:00.000Z"]),
617
+ ];
618
+
619
+ /// Refused by legacy, at parse or at its first run.
620
+ const REFUSED: &[&str] = &[
621
+ "* * * * * * *",
622
+ "60 * * * *",
623
+ "* 24 * * *",
624
+ "* * 0 * *",
625
+ "* * 32 * *",
626
+ "* * * 13 *",
627
+ "* * * * 8",
628
+ "* * 30 2 *",
629
+ "* * * foo *",
630
+ "1, * * * *",
631
+ "*/0 * * * *",
632
+ "5-1 * * * *",
633
+ "? * * * *",
634
+ "* * * * L",
635
+ "* * * * 1#6",
636
+ "* * * * 1,2#2",
637
+ "* * * * 1-2#2",
638
+ "@annually",
639
+ "* * * * 1#0",
640
+ ];
641
+
642
+ #[test]
643
+ fn next_occurrences_match_legacy() {
644
+ for (expr, from, want) in GOLDEN {
645
+ let cron = Cron::parse(expr).unwrap_or_else(|e| panic!("{expr}: {e}"));
646
+ let mut at = ms(from);
647
+ for w in *want {
648
+ let next = cron.next_after(at).unwrap_or_else(|| panic!("{expr} from {}: never", iso(at)));
649
+ assert_eq!(iso(next), *w, "{expr} after {}", iso(at));
650
+ at = next;
651
+ }
652
+ }
653
+ }
654
+
655
+ #[test]
656
+ fn refuses_what_legacy_refused() {
657
+ for expr in REFUSED {
658
+ assert!(Cron::parse(expr).is_err(), "{expr} was accepted");
659
+ }
660
+ }
661
+
662
+ #[test]
663
+ fn refuses_what_legacy_took_by_accident() {
664
+ for expr in ["", " ", "0 0 L/2 * *", "0 0 15L * *", "0 0 1-2-3 * *", "* * * * * /5", "1/2/3 * * * *", "* * * * 10L"] {
665
+ assert!(Cron::parse(expr).is_err(), "{expr:?} was accepted");
666
+ }
667
+ }
668
+
669
+ #[test]
670
+ fn strictly_after_on_whole_seconds() {
671
+ let every_second = Cron::parse("* * * * * *").unwrap();
672
+ assert_eq!(every_second.next_after(ms("2026-09-26T12:00:00.000Z")), Some(ms("2026-09-26T12:00:01.000Z")));
673
+ assert_eq!(every_second.next_after(ms("2026-09-26T12:00:00.999Z")), Some(ms("2026-09-26T12:00:01.000Z")));
674
+ let noon = Cron::parse("0 12 * * *").unwrap();
675
+ assert_eq!(noon.next_after(ms("2026-09-26T11:59:59.999Z")), Some(ms("2026-09-26T12:00:00.000Z")));
676
+ assert_eq!(noon.next_after(ms("2026-09-26T12:00:00.000Z")), Some(ms("2026-09-27T12:00:00.000Z")));
677
+ assert_eq!(noon.next_after(ms("2026-12-31T12:00:00.000Z")), Some(ms("2027-01-01T12:00:00.000Z")));
678
+ }
679
+
680
+ #[test]
681
+ fn a_fixed_offset_moves_the_wall_clock() {
682
+ let nine = Cron::parse("0 9 * * mon-fri").unwrap().with_utc_offset(-300).unwrap();
683
+ // Friday 13:59 UTC is 08:59 at UTC-5; the next weekday 09:00 there is 14:00 UTC.
684
+ assert_eq!(nine.next_after(ms("2026-09-25T13:59:00.000Z")), Some(ms("2026-09-25T14:00:00.000Z")));
685
+ // After it, Saturday and Sunday are skipped in local time.
686
+ assert_eq!(nine.next_after(ms("2026-09-25T14:00:00.000Z")), Some(ms("2026-09-28T14:00:00.000Z")));
687
+ // 23:30 at UTC+5:30 on the 31st is 18:00 UTC; the local date decides the day.
688
+ let month_end = Cron::parse("30 23 L * *").unwrap().with_utc_offset(330).unwrap();
689
+ assert_eq!(month_end.next_after(ms("2026-09-01T00:00:00.000Z")), Some(ms("2026-09-30T18:00:00.000Z")));
690
+ assert!(Cron::parse("* * * * *").unwrap().with_utc_offset(18 * 60 + 1).is_err());
691
+ }
692
+
693
+ #[test]
694
+ fn a_schedule_that_never_fires_says_so() {
695
+ // Day 31 in months that have none, with the month named twice so legacy's check skips it.
696
+ let never = Cron::parse("0 0 31 2,2 *").unwrap();
697
+ assert_eq!(never.next_after(ms("2026-09-26T00:00:00.000Z")), None);
698
+ }
699
+
700
+ #[test]
701
+ fn parses_from_str_and_shows_its_source() {
702
+ let c: Cron = " 0 */6 * * * ".parse().unwrap();
703
+ assert_eq!(c.to_string(), "0 */6 * * *");
704
+ assert_eq!(c.next_after(ms("2026-09-26T05:00:00.000Z")), Some(ms("2026-09-26T06:00:00.000Z")));
705
+ }
706
+
707
+ #[test]
708
+ fn calendar_round_trips() {
709
+ for days in [-800_000i64, -1, 0, 1, 10_957, 20_000, 47_541, 800_000] {
710
+ let (y, m, d) = civil(days);
711
+ assert_eq!(days_from_civil(y, m, d), days);
712
+ }
713
+ assert_eq!(civil(0), (1970, 1, 1));
714
+ assert_eq!(civil(days_from_civil(2100, 3, 1) - 1), (2100, 2, 28));
715
+ assert_eq!(civil(days_from_civil(2000, 3, 1) - 1), (2000, 2, 29));
716
+ }
717
+ }