1use std::borrow::Cow;
30use std::collections::BTreeMap;
31use std::sync::OnceLock;
32
33use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
34use serde::{Deserialize, Serialize};
35
36pub const DAY_TICKS: i64 = 24_000;
38
39const DAY_TIMELINE: &str = include_str!("../data/timeline-day-1.21.11.json");
40const MOON_TIMELINE: &str = include_str!("../data/timeline-moon-1.21.11.json");
41
42const SUN_ANGLE_TRACK: &str = "minecraft:visual/sun_angle";
43const SKY_LIGHT_TRACK: &str = "minecraft:gameplay/sky_light_level";
44const MONSTERS_BURN_TRACK: &str = "minecraft:gameplay/monsters_burn";
45const MOON_PHASE_TRACK: &str = "minecraft:visual/moon_phase";
46
47#[derive(Debug, Deserialize)]
48struct Timeline {
49 period_ticks: i64,
50 tracks: BTreeMap<String, Track>,
51}
52
53#[derive(Debug, Deserialize)]
54struct Track {
55 #[serde(default)]
56 ease: Option<serde_json::Value>,
57 keyframes: Vec<Keyframe>,
58}
59
60#[derive(Debug, Deserialize)]
61struct Keyframe {
62 ticks: i64,
63 value: serde_json::Value,
64}
65
66fn day_timeline() -> &'static Timeline {
67 static T: OnceLock<Timeline> = OnceLock::new();
68 T.get_or_init(|| serde_json::from_str(DAY_TIMELINE).expect("the vendored day timeline parses"))
69}
70
71fn moon_timeline() -> &'static Timeline {
72 static T: OnceLock<Timeline> = OnceLock::new();
73 T.get_or_init(|| {
74 serde_json::from_str(MOON_TIMELINE).expect("the vendored moon timeline parses")
75 })
76}
77
78fn track(t: &'static Timeline, name: &str) -> &'static Track {
79 t.tracks
80 .get(name)
81 .unwrap_or_else(|| panic!("the vendored timeline has no track `{name}`"))
82}
83
84pub fn day_keyframes(name: &str) -> Vec<(i64, serde_json::Value)> {
87 track(day_timeline(), name)
88 .keyframes
89 .iter()
90 .map(|k| (k.ticks, k.value.clone()))
91 .collect()
92}
93
94pub fn day_ease(name: &str) -> Option<serde_json::Value> {
96 track(day_timeline(), name).ease.clone()
97}
98
99fn cubic_bezier(p: [f64; 4], x: f64) -> f64 {
104 let [x1, y1, x2, y2] = p;
105 let bez = |a: f64, b: f64, s: f64| {
106 let u = 1.0 - s;
107 3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
108 };
109 let dbez = |a: f64, b: f64, s: f64| {
110 let u = 1.0 - s;
111 3.0 * u * u * a + 6.0 * u * s * (b - a) + 3.0 * s * s * (1.0 - b)
112 };
113 if x <= 0.0 {
114 return 0.0;
115 }
116 if x >= 1.0 {
117 return 1.0;
118 }
119 let mut s = x;
120 let mut solved = false;
121 for _ in 0..16 {
122 let err = bez(x1, x2, s) - x;
123 if err.abs() < 1e-13 {
124 solved = true;
125 break;
126 }
127 let d = dbez(x1, x2, s);
128 if d.abs() < 1e-9 {
129 break;
130 }
131 s -= err / d;
132 if !(0.0..=1.0).contains(&s) {
133 break;
134 }
135 }
136 if !solved {
137 let (mut lo, mut hi) = (0.0f64, 1.0f64);
138 for _ in 0..200 {
139 s = 0.5 * (lo + hi);
140 if bez(x1, x2, s) < x {
141 lo = s;
142 } else {
143 hi = s;
144 }
145 }
146 }
147 bez(y1, y2, s)
148}
149
150fn sample(t: &'static Timeline, name: &str, tick: f64) -> f64 {
154 let tr = track(t, name);
155 let period = t.period_ticks as f64;
156 let mut at = tick.rem_euclid(period);
157 let kf = &tr.keyframes;
158 let n = kf.len();
159 let num = |k: &Keyframe| k.value.as_f64().expect("a numeric keyframe");
160 let i = match kf.iter().rposition(|k| (k.ticks as f64) <= at) {
161 Some(i) => i,
162 None => {
163 at += period;
164 n - 1
165 }
166 };
167 let a_ticks = kf[i].ticks as f64;
168 let (b, b_ticks) = if i + 1 < n {
169 (&kf[i + 1], kf[i + 1].ticks as f64)
170 } else {
171 (&kf[0], kf[0].ticks as f64 + period)
172 };
173 let (va, vb) = (num(&kf[i]), num(b));
174 if b_ticks <= a_ticks {
175 return va;
176 }
177 let progress = (at - a_ticks) / (b_ticks - a_ticks);
178 let eased = match &tr.ease {
179 None => progress,
180 Some(serde_json::Value::String(s)) if s == "constant" => 0.0,
181 Some(serde_json::Value::Object(o)) if o.contains_key("cubic_bezier") => {
182 let c: Vec<f64> = o["cubic_bezier"]
183 .as_array()
184 .expect("cubic_bezier is an array")
185 .iter()
186 .map(|v| v.as_f64().expect("a bezier control value"))
187 .collect();
188 cubic_bezier([c[0], c[1], c[2], c[3]], progress)
189 }
190 Some(other) => panic!("track `{name}` declares an ease this reader does not know: {other}"),
191 };
192 va + (vb - va) * eased
193}
194
195pub fn sun_angle_degrees(daytime: f64) -> f64 {
199 sample(day_timeline(), SUN_ANGLE_TRACK, daytime)
200}
201
202pub fn sun_altitude_degrees(daytime: f64) -> f64 {
208 altitude_of_angle(sun_angle_degrees(daytime))
209}
210
211pub fn altitude_of_angle(angle: f64) -> f64 {
213 let a = angle.rem_euclid(360.0);
214 if a <= 180.0 { 90.0 - a } else { a - 270.0 }
215}
216
217pub fn sun_is_west(daytime: f64) -> bool {
222 let a = sun_angle_degrees(daytime).rem_euclid(360.0);
223 a > 0.0 && a < 180.0
224}
225
226pub fn sky_light_factor(daytime: i64) -> f64 {
230 sample(day_timeline(), SKY_LIGHT_TRACK, daytime as f64)
231}
232
233pub fn monsters_burn(daytime: i64) -> bool {
237 let tr = track(day_timeline(), MONSTERS_BURN_TRACK);
238 let at = daytime.rem_euclid(day_timeline().period_ticks);
239 let k = tr
240 .keyframes
241 .iter()
242 .rev()
243 .find(|k| k.ticks <= at)
244 .or_else(|| tr.keyframes.last())
245 .expect("monsters_burn has keyframes");
246 k.value.as_bool().expect("monsters_burn is boolean")
247}
248
249pub fn monsters_burn_switches() -> (i64, i64) {
252 let kf = &track(day_timeline(), MONSTERS_BURN_TRACK).keyframes;
253 let at = |v: bool| {
254 kf.iter()
255 .find(|k| k.value.as_bool() == Some(v))
256 .map(|k| k.ticks)
257 .expect("monsters_burn switches both ways")
258 };
259 (at(false), at(true))
260}
261
262pub fn phase_names() -> &'static [String] {
265 static N: OnceLock<Vec<String>> = OnceLock::new();
266 N.get_or_init(|| {
267 track(moon_timeline(), MOON_PHASE_TRACK)
268 .keyframes
269 .iter()
270 .map(|k| {
271 k.value
272 .as_str()
273 .expect("a moon phase keyframe names a phase")
274 .replace('_', "-")
275 })
276 .collect()
277 })
278}
279
280pub fn moon_cycle_days() -> i64 {
282 phase_names().len() as i64
283}
284
285pub fn moon_period_ticks() -> i64 {
288 moon_timeline().period_ticks
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
295pub enum Body {
296 Sun,
297 Moon,
298}
299
300impl Body {
301 pub fn keyword(self) -> &'static str {
303 match self {
304 Body::Sun => "sun",
305 Body::Moon => "moon",
306 }
307 }
308}
309
310#[derive(
313 Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
314)]
315#[serde(rename_all = "kebab-case")]
316pub enum Position {
317 Rising,
319 JustRisen,
321 High,
323 Setting,
325 JustSet,
327 Below,
329}
330
331pub const POSITIONS: [Position; 6] = [
333 Position::Rising,
334 Position::JustRisen,
335 Position::High,
336 Position::Setting,
337 Position::JustSet,
338 Position::Below,
339];
340
341impl Position {
342 pub fn keyword(self) -> &'static str {
344 match self {
345 Position::Rising => "rising",
346 Position::JustRisen => "just-risen",
347 Position::High => "high",
348 Position::Setting => "setting",
349 Position::JustSet => "just-set",
350 Position::Below => "below",
351 }
352 }
353}
354
355pub const SUN_DISC_HALF_CENTIDEG: i32 = 429;
359
360pub const MOON_DISC_HALF_CENTIDEG: i32 = 286;
362
363pub const fn position_tick(body: Body, pos: Position) -> i64 {
367 match (body, pos) {
368 (Body::Sun, Position::Rising) => 23218,
369 (Body::Sun, Position::JustRisen) => 23486,
370 (Body::Sun, Position::High) => 6000,
371 (Body::Sun, Position::Setting) => 12782,
372 (Body::Sun, Position::JustSet) => 13047,
373 (Body::Sun, Position::Below) => 18000,
374 (Body::Moon, Position::Rising) => 12782,
375 (Body::Moon, Position::JustRisen) => 12959,
376 (Body::Moon, Position::High) => 18000,
377 (Body::Moon, Position::Setting) => 23218,
378 (Body::Moon, Position::JustSet) => 23397,
379 (Body::Moon, Position::Below) => 6000,
380 }
381}
382
383pub const fn position_altitude_centideg(body: Body, pos: Position) -> i32 {
386 let half = match body {
387 Body::Sun => SUN_DISC_HALF_CENTIDEG,
388 Body::Moon => MOON_DISC_HALF_CENTIDEG,
389 };
390 match pos {
391 Position::Rising | Position::Setting => 0,
392 Position::JustRisen => half,
393 Position::High => 9000,
394 Position::JustSet => -half,
395 Position::Below => -9000,
396 }
397}
398
399pub fn moon_up(daytime: i64) -> bool {
403 let d = daytime.rem_euclid(DAY_TICKS);
404 (position_tick(Body::Sun, Position::Setting)..=position_tick(Body::Sun, Position::Rising))
405 .contains(&d)
406}
407
408#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
413pub struct MoonPhase(u8);
414
415impl MoonPhase {
416 pub fn of_day(day: i64) -> MoonPhase {
418 MoonPhase(day.rem_euclid(moon_cycle_days()) as u8)
419 }
420
421 pub fn index(self) -> i64 {
423 i64::from(self.0)
424 }
425
426 pub fn name(self) -> &'static str {
428 phase_names()[self.0 as usize].as_str()
429 }
430
431 pub fn parse(s: &str) -> Option<MoonPhase> {
434 phase_names()
435 .iter()
436 .position(|n| n == s)
437 .map(|i| MoonPhase(i as u8))
438 }
439
440 pub fn names_line() -> String {
442 phase_names().join(", ")
443 }
444}
445
446impl Serialize for MoonPhase {
447 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
448 s.serialize_str(self.name())
449 }
450}
451
452impl<'de> Deserialize<'de> for MoonPhase {
453 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<MoonPhase, D::Error> {
454 let s = String::deserialize(de)?;
455 MoonPhase::parse(&s).ok_or_else(|| {
456 serde::de::Error::custom(format!(
457 "unknown moon phase `{s}`: the pinned game names eight, {}",
458 MoonPhase::names_line()
459 ))
460 })
461 }
462}
463
464impl JsonSchema for MoonPhase {
465 fn schema_name() -> Cow<'static, str> {
466 "MoonPhase".into()
467 }
468
469 fn json_schema(_: &mut SchemaGenerator) -> Schema {
470 json_schema!({
471 "type": "string",
472 "description": format!(
473 "One of the moon's eight phases, named as the pinned game's moon timeline \
474 names them, kebab-cased: {}. Data, not an engine vocabulary.",
475 MoonPhase::names_line()
476 ),
477 })
478 }
479}
480
481#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
485#[serde(deny_unknown_fields)]
486pub struct CelestialTime {
487 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub sun: Option<Position>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub moon: Option<Position>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub phase: Option<MoonPhase>,
498}
499
500impl CelestialTime {
501 pub fn stated(self) -> Option<(Body, Position)> {
504 match (self.sun, self.moon) {
505 (Some(p), None) => Some((Body::Sun, p)),
506 (None, Some(p)) => Some((Body::Moon, p)),
507 _ => None,
508 }
509 }
510
511 pub fn daytime(self) -> i64 {
516 match (self.sun, self.moon) {
517 (Some(p), _) => position_tick(Body::Sun, p),
518 (None, Some(p)) => position_tick(Body::Moon, p),
519 (None, None) => position_tick(Body::Sun, Position::High),
520 }
521 }
522
523 pub fn moon_altitude_centideg(self) -> i32 {
525 match self.stated() {
526 Some((Body::Moon, p)) => position_altitude_centideg(Body::Moon, p),
527 Some((Body::Sun, p)) => -position_altitude_centideg(Body::Sun, p),
528 None => -position_altitude_centideg(Body::Sun, Position::High),
529 }
530 }
531
532 pub fn moon_shows(self) -> bool {
534 self.moon_altitude_centideg() >= 0
535 }
536
537 pub fn spelling(self) -> String {
539 serde_json::to_string(&self).expect("a celestial time serialises")
540 }
541}
542
543#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct Clock {
547 pub day: i64,
549 pub daytime: i64,
551}
552
553impl Clock {
554 pub fn absolute(self) -> i64 {
557 self.day * DAY_TICKS + self.daytime
558 }
559
560 pub fn phase(self) -> MoonPhase {
562 MoonPhase::of_day(self.day)
563 }
564
565 pub fn label(self) -> String {
567 format!("(day {}, {})", self.day, self.daytime)
568 }
569}
570
571#[derive(Clone, Copy, Debug, PartialEq, Eq)]
573pub enum CelestialSite {
574 World,
576 Cut,
578 Row,
580 Camera,
582}
583
584pub fn degrees_label(centideg: i32) -> String {
586 let sign = if centideg < 0 { "-" } else { "+" };
587 let a = centideg.unsigned_abs();
588 format!("{sign}{}.{:02}°", a / 100, a % 100)
589}
590
591pub fn shape_findings(
596 t: CelestialTime,
597 site: CelestialSite,
598 world: crate::WorldTime,
599) -> Vec<String> {
600 let spelling = t.spelling();
601 let mut out = Vec::new();
602 let Some(_) = t.stated() else {
603 let which = if t.sun.is_some() { "both" } else { "neither" };
604 out.push(if which == "both" {
605 format!(
606 "`{spelling}` names both `sun` and `moon`. A celestial time is ONE body and where \
607 it stands: the moon stands exactly opposite the sun, so `{{\"moon\": \"rising\"}}` \
608 and `{{\"sun\": \"setting\"}}` are one hour spelled from two sides, and two \
609 bodies are two hours or one written twice. NAME ONE BODY: keep the one the \
610 sentence is about and delete the other"
611 )
612 } else {
613 format!(
614 "`{spelling}` names neither `sun` nor `moon`, so it states no hour. A celestial \
615 time is one body and where it stands. NAME ONE BODY: add `sun` or `moon` with one \
616 of the six positions — rising, just-risen, high, setting, just-set, below"
617 )
618 });
619 return out;
620 };
621 let alt = t.moon_altitude_centideg();
622 if let Some(p) = t.phase
623 && !t.moon_shows()
624 {
625 out.push(format!(
626 "`{spelling}` states the phase `{}` where the moon stands at {} — below the horizon, \
627 where nobody can see it. A judgement about a body nobody can see reaches nothing. \
628 REMOVE `phase` from this time",
629 p.name(),
630 degrees_label(alt),
631 ));
632 }
633 if site == CelestialSite::World && t.phase.is_none() && t.moon_shows() {
634 out.push(format!(
635 "`world.time` `{spelling}` puts the moon at {} — at or above the horizon, where the \
636 party sees it — and states no `phase`. The phase the party sees is a judgement \
637 nobody may leave to a default. STATE `phase`, one of the eight the pinned game \
638 names: {}",
639 degrees_label(alt),
640 MoonPhase::names_line(),
641 ));
642 }
643 let world_phase_known = match world.celestial() {
647 None => true,
648 Some(w) => w.stated().is_some() && (w.phase.is_some() || !w.moon_shows()),
649 };
650 if site != CelestialSite::World
651 && world_phase_known
652 && let Some(p) = t.phase
653 && t.moon_shows()
654 && p == MoonPhase::of_day(world.world_day())
655 {
656 out.push(format!(
657 "`{spelling}` states the phase `{}`, which is the world's: `world.time` `{}` shows \
658 `{}` on day {}. A phase left out here is the world's, so this one is a copy that \
659 goes stale the moment the world's changes. REMOVE `phase` from this time",
660 p.name(),
661 world.keyword(),
662 p.name(),
663 world.world_day(),
664 ));
665 }
666 out
667}
668
669pub(crate) fn check(c: &crate::envelope::Campaign, d: &mut Vec<crate::diagnostic::Diagnostic>) {
674 use crate::diagnostic::{Diagnostic, codes};
675 let world = c.world.content.time;
676 let mut push = |stage: &str, path: String, t: crate::WorldTime, site: CelestialSite| {
677 if let Some(ct) = t.celestial() {
678 for m in shape_findings(ct, site, world) {
679 d.push(Diagnostic::error(
680 codes::CELESTIAL_TIME,
681 stage,
682 path.clone(),
683 m,
684 ));
685 }
686 }
687 };
688 push("world", "/content/time".into(), world, CelestialSite::World);
689 crate::for_each_campaign_effect(c, &mut |path, _site, e| {
690 if let Some(t) = e.set_time() {
691 push("quests", format!("{path}/time"), t, CelestialSite::Cut);
692 }
693 });
694 for (i, tree) in c.dialogue.content.dialogues.iter().enumerate() {
695 for (j, node) in tree.nodes.iter().enumerate() {
696 for (k, opt) in node.options.iter().enumerate() {
697 for (l, e) in opt.effects.iter().enumerate() {
698 if let Some(t) = e.set_time() {
699 push(
700 "dialogue",
701 format!(
702 "/content/dialogues/{i}/nodes/{j}/options/{k}/effects/{l}/time"
703 ),
704 t,
705 CelestialSite::Cut,
706 );
707 }
708 }
709 }
710 }
711 }
712 if let Some(design) = &c.design {
713 for (i, r) in design.content.references.iter().enumerate() {
714 push(
715 "design",
716 format!("/content/references/{i}/time"),
717 r.time,
718 CelestialSite::Row,
719 );
720 }
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 const SUN_SIZE: f64 = 30.0;
735 const MOON_SIZE: f64 = 20.0;
736 const QUAD_HEIGHT: f64 = 100.0;
737 const DISC_FRACTION: f64 = 8.0 / 32.0;
738
739 fn half_disc_deg(size: f64) -> f64 {
740 (size * DISC_FRACTION / QUAD_HEIGHT).atan().to_degrees()
741 }
742
743 fn solve_sun(alt: f64, west: bool) -> f64 {
746 let (mut lo, mut hi) = if west {
749 (6000.0, 18000.0)
750 } else {
751 (18000.0, 30000.0)
752 };
753 for _ in 0..200 {
754 let mid = 0.5 * (lo + hi);
755 let a = sun_altitude_degrees(mid);
756 let above = a > alt;
757 if above == west {
759 lo = mid;
760 } else {
761 hi = mid;
762 }
763 }
764 (0.5 * (lo + hi)).rem_euclid(DAY_TICKS as f64)
765 }
766
767 #[test]
768 fn the_position_table_is_the_curve() {
769 let sun_half = half_disc_deg(SUN_SIZE);
770 let moon_half = half_disc_deg(MOON_SIZE);
771 assert_eq!(
772 (sun_half * 100.0).round() as i32,
773 SUN_DISC_HALF_CENTIDEG,
774 "{sun_half}"
775 );
776 assert_eq!(
777 (moon_half * 100.0).round() as i32,
778 MOON_DISC_HALF_CENTIDEG,
779 "{moon_half}"
780 );
781 let derive = |body: Body, pos: Position| -> i64 {
784 let half = match body {
785 Body::Sun => sun_half,
786 Body::Moon => moon_half,
787 };
788 let (alt, west) = match pos {
790 Position::Rising => (0.0, false),
791 Position::JustRisen => (half, false),
792 Position::Setting => (0.0, true),
793 Position::JustSet => (-half, true),
794 Position::High | Position::Below => (f64::NAN, false),
795 };
796 let (zenith, nadir) = (6000, 18000);
797 match (body, pos) {
798 (Body::Sun, Position::High) | (Body::Moon, Position::Below) => zenith,
799 (Body::Sun, Position::Below) | (Body::Moon, Position::High) => nadir,
800 (Body::Sun, _) => solve_sun(alt, west).round() as i64,
801 (Body::Moon, _) => solve_sun(-alt, !west).round() as i64,
802 }
803 };
804 let mut checked = 0;
805 for body in [Body::Sun, Body::Moon] {
806 for pos in POSITIONS {
807 let want = derive(body, pos);
808 assert_eq!(
809 position_tick(body, pos),
810 want,
811 "{body:?} {pos:?}: the frozen tick differs from the derivation"
812 );
813 checked += 1;
814 }
815 }
816 assert_eq!(checked, 12, "every entry of the table was derived");
817 assert!((sun_altitude_degrees(6000.0) - 90.0).abs() < 1e-9);
819 assert!((sun_altitude_degrees(18000.0) + 90.0).abs() < 1e-9);
820 assert_eq!(
822 position_tick(Body::Moon, Position::Rising),
823 position_tick(Body::Sun, Position::Setting)
824 );
825 }
826
827 #[test]
828 fn the_eight_phases_are_the_pinned_names_in_order() {
829 assert_eq!(
830 phase_names(),
831 [
832 "full-moon",
833 "waning-gibbous",
834 "third-quarter",
835 "waning-crescent",
836 "new-moon",
837 "waxing-crescent",
838 "first-quarter",
839 "waxing-gibbous",
840 ]
841 );
842 let kf: Vec<i64> = track(moon_timeline(), MOON_PHASE_TRACK)
843 .keyframes
844 .iter()
845 .map(|k| k.ticks)
846 .collect();
847 assert_eq!(kf, (0..8).map(|i| i * DAY_TICKS).collect::<Vec<_>>());
848 assert_eq!(moon_period_ticks(), 192_000);
849 assert_eq!(MoonPhase::of_day(4).name(), "new-moon");
850 assert_eq!(MoonPhase::of_day(8).name(), "full-moon");
851 }
852
853 #[test]
854 fn the_burn_and_sky_light_keyframes_are_the_pinned_ones() {
855 let burn: Vec<(i64, bool)> = day_keyframes(MONSTERS_BURN_TRACK)
856 .into_iter()
857 .map(|(t, v)| (t, v.as_bool().unwrap()))
858 .collect();
859 assert_eq!(burn, [(12542, false), (23460, true)]);
860 let sky: Vec<(i64, f64)> = day_keyframes(SKY_LIGHT_TRACK)
861 .into_iter()
862 .map(|(t, v)| (t, v.as_f64().unwrap()))
863 .collect();
864 assert_eq!(
865 sky,
866 [
867 (133, 1.0),
868 (11867, 1.0),
869 (13670, 0.26666668),
870 (22330, 0.26666668)
871 ]
872 );
873 assert_eq!(day_ease(SKY_LIGHT_TRACK), None, "linear");
874 let level = |t: i64| (15.0 * sky_light_factor(t)).round() as i64;
876 assert_eq!(level(6000), 15);
877 assert_eq!(level(12000), 14);
878 assert_eq!(level(13000), 8);
879 assert_eq!(level(23000), 8);
880 assert_eq!(level(18000), 4);
881 assert!(monsters_burn(12541) && !monsters_burn(12542));
882 assert!(!monsters_burn(23459) && monsters_burn(23460));
883 assert!(monsters_burn(0));
884 }
885
886 #[test]
887 fn the_sun_track_is_the_eased_one() {
888 assert_eq!(
889 day_ease(SUN_ANGLE_TRACK),
890 Some(serde_json::json!({"cubic_bezier": [0.362, 0.241, 0.638, 0.759]}))
891 );
892 assert!((sun_angle_degrees(6000.0) - 0.0).abs() < 1e-9);
893 assert!((sun_angle_degrees(18000.0) - 180.0).abs() < 1e-9);
894 let alt = |t: f64| sun_altitude_degrees(t);
896 assert!((alt(1000.0) - 27.55).abs() < 0.01, "{}", alt(1000.0));
897 assert!((alt(12000.0) - 12.37).abs() < 0.01, "{}", alt(12000.0));
898 assert!((alt(13000.0) + 3.52).abs() < 0.01, "{}", alt(13000.0));
899 assert!((alt(23000.0) + 3.52).abs() < 0.01, "{}", alt(23000.0));
900 assert!(sun_is_west(12000.0) && !sun_is_west(23000.0));
901 assert!(!sun_is_west(6000.0) && !sun_is_west(18000.0));
902 }
903
904 #[test]
905 fn a_phase_outside_the_eight_is_a_parse_error() {
906 assert!(serde_json::from_str::<MoonPhase>("\"new-moon\"").is_ok());
907 let e = serde_json::from_str::<MoonPhase>("\"blood-moon\"").unwrap_err();
908 assert!(e.to_string().contains("the pinned game names eight"), "{e}");
909 }
910}