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