Skip to main content

delvewright_dsl/
celestial.rs

1//! **The clock, read from the pinned game** (spec-0081): the moon's eight
2//! phases, the sun's eased path, the sky-light and monster-burn keyframes, and
3//! the twelve ticks a body's position names.
4//!
5//! The game's clock is one number, `dayTime`. `time set` writes it absolutely;
6//! `time query daytime` reads it modulo 24000 and `time query day` divided by
7//! it; the moon's phase is that quotient modulo 8. [`Clock`] is that number
8//! split the way the two read-backs split it.
9//!
10//! ## One authority
11//!
12//! `data/timeline-day-1.21.11.json` and `data/timeline-moon-1.21.11.json` are
13//! `data/minecraft/timeline/day.json` and `moon.json` of the pinned server jar,
14//! byte for byte plus one trailing newline (`tools/maintenance/extract-timelines.py`
15//! checks them against the jar; provenance in `crates/delvec/data/PROVENANCE.md`).
16//! Every value below that the game owns is read from them and from nothing
17//! typed here: the phase names and their order, the `sun_angle` keyframes and
18//! their cubic-bezier ease, the `sky_light_level` ramp and the `monsters_burn`
19//! window.
20//!
21//! ## The position table is derived, then frozen
22//!
23//! [`position_tick`] is twelve integer constants, because the emission path
24//! carries no trigonometry (ADR-0006). `the_position_table_is_the_curve`
25//! re-derives every one from the vendored track, the client's two sky-quad
26//! constants and the disc fraction of the celestial textures, and fails when a
27//! constant differs from its derivation by a tick.
28
29use std::borrow::Cow;
30use std::collections::BTreeMap;
31use std::sync::OnceLock;
32
33use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
34use serde::{Deserialize, Serialize};
35
36/// Ticks in one day: `time query daytime` is `dayTime` modulo this.
37pub 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
84/// A track's keyframes as `(ticks, value)`, in file order — what a test reads
85/// the game's numbers back through.
86pub 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
94/// The ease a track declares, as the file spells it (`None` = linear).
95pub fn day_ease(name: &str) -> Option<serde_json::Value> {
96    track(day_timeline(), name).ease.clone()
97}
98
99/// CSS-convention cubic bezier through (0,0), (x1,y1), (x2,y2), (1,1): solve
100/// the x-curve for the parameter, sample the y-curve. Newton–Raphson from the
101/// progress itself, with bisection as the floor — arithmetic only, no
102/// trigonometry, so the value is a function of the IEEE operations alone.
103fn 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
150/// Sample a numeric track at a (fractional) tick: the segment from the last
151/// keyframe at or before the tick to the next one, wrapping over the period,
152/// eased by the track's ease (absent = linear, `constant` = step).
153fn 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
195/// The `visual/sun_angle` of the pinned day timeline at a `daytime` tick, in
196/// degrees: 0 at noon, growing westward, 180 at midnight. The moon's angle is
197/// this plus 180 (`moon_angle` is the same track at 540 → 180).
198pub fn sun_angle_degrees(daytime: f64) -> f64 {
199    sample(day_timeline(), SUN_ANGLE_TRACK, daytime)
200}
201
202/// The sun's altitude above the horizon, in degrees, at a `daytime` tick: the
203/// body travels a great circle through the zenith, so an angle `α` from the
204/// zenith is an altitude of `90 − α` on the western half of the arc and
205/// `α − 270` on the eastern half. Piecewise linear in the angle — no
206/// trigonometry.
207pub fn sun_altitude_degrees(daytime: f64) -> f64 {
208    altitude_of_angle(sun_angle_degrees(daytime))
209}
210
211/// Altitude in degrees of a body standing `angle` degrees from the zenith.
212pub 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
217/// Whether the sun at this `daytime` stands on the western half of its arc —
218/// past noon and before midnight. At noon and midnight exactly the side means
219/// nothing and this answers `false` (east), so a value is a function of the
220/// tick alone.
221pub 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
226/// The `gameplay/sky_light_level` multiplier of the pinned day timeline at a
227/// `daytime` tick: 1.0 on the day plateau, 4/15 on the night plateau, linear
228/// ramps between.
229pub fn sky_light_factor(daytime: i64) -> f64 {
230    sample(day_timeline(), SKY_LIGHT_TRACK, daytime as f64)
231}
232
233/// Whether the `gameplay/monsters_burn` track of the pinned day timeline is on
234/// at a `daytime` tick (a step track: the value of the last keyframe at or
235/// before the tick, wrapping).
236pub 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
249/// The two ticks the `monsters_burn` track switches at: `(off, on)` — 12542 and
250/// 23460 in the pinned file.
251pub 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
262/// The moon's eight phases, kebab-cased, in the pinned `moon.json`'s order:
263/// index `i` is the phase of day `i` modulo 8.
264pub 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
280/// The moon cycle in days: the number of phase keyframes.
281pub fn moon_cycle_days() -> i64 {
282    phase_names().len() as i64
283}
284
285/// The `time_check` period over which a predicate reads the phase: the moon
286/// timeline's own period.
287pub fn moon_period_ticks() -> i64 {
288    moon_timeline().period_ticks
289}
290
291/// **A body in the sky**: the sun or the moon. The moon stands exactly opposite
292/// the sun (`moon_angle` is `sun_angle` + 180), so the two are one position seen
293/// from two sides.
294#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
295pub enum Body {
296    Sun,
297    Moon,
298}
299
300impl Body {
301    /// The word a document writes for this body.
302    pub fn keyword(self) -> &'static str {
303        match self {
304            Body::Sun => "sun",
305            Body::Moon => "moon",
306        }
307    }
308}
309
310/// **Where a body stands on its arc** — one point of it, defined by the body's
311/// drawn disc and the horizon (spec-0081 §3.2).
312#[derive(
313    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
314)]
315#[serde(rename_all = "kebab-case")]
316pub enum Position {
317    /// The disc's centre on the eastern horizon.
318    Rising,
319    /// The disc stands wholly clear of the eastern horizon.
320    JustRisen,
321    /// The body at the zenith.
322    High,
323    /// The disc's centre on the western horizon.
324    Setting,
325    /// The disc has wholly gone below the western horizon.
326    JustSet,
327    /// The body at the nadir — the other body is `high`.
328    Below,
329}
330
331/// Every position, in declaration order.
332pub 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    /// The word a document writes for this position.
343    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
355/// Half the angle the sun's DRAWN disc spans, in hundredths of a degree:
356/// `atan(30 × 8/32 / 100)` = 4.29° — the client's `SUN_SIZE` 30 at height 100,
357/// of which the texture's full-brightness disc is 8 of 32 pixels.
358pub const SUN_DISC_HALF_CENTIDEG: i32 = 429;
359
360/// Half the moon's drawn disc: `atan(20 × 8/32 / 100)` = 2.86° (`MOON_SIZE` 20).
361pub const MOON_DISC_HALF_CENTIDEG: i32 = 286;
362
363/// The `daytime` tick at which `body` stands at `pos` — the integer nearest the
364/// pinned track's solution. Derived once and frozen; the test
365/// `the_position_table_is_the_curve` re-derives every entry.
366pub 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
383/// The named body's altitude at `pos`, in hundredths of a degree, by the
384/// position's definition.
385pub 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
399/// Whether the moon is at or above the horizon at a `daytime` tick: from the
400/// sun's setting to its rising, both inclusive (the moon's centre is on the
401/// horizon at those two ticks).
402pub 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/// **One of the moon's eight phases**, held as its index (0 = `full-moon`, the
409/// phase of day 0). Written in a document as the pinned game's own name,
410/// kebab-cased; the names are DATA, read from the vendored `moon.json`, never an
411/// enum this engine authors (spec-0039: vanilla registry values are data).
412#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
413pub struct MoonPhase(u8);
414
415impl MoonPhase {
416    /// The phase of day `day` (modulo the cycle).
417    pub fn of_day(day: i64) -> MoonPhase {
418        MoonPhase(day.rem_euclid(moon_cycle_days()) as u8)
419    }
420
421    /// The index into the cycle: the day, modulo 8, this phase shows on.
422    pub fn index(self) -> i64 {
423        i64::from(self.0)
424    }
425
426    /// The pinned game's name, kebab-cased (`new-moon`).
427    pub fn name(self) -> &'static str {
428        phase_names()[self.0 as usize].as_str()
429    }
430
431    /// The phase a document names, or `None` for a word the pinned game does
432    /// not use.
433    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    /// The eight names, `a, b, …`, for a message.
441    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/// **A sky stated in a designer's words**: one body, where it stands, and —
482/// where the moon shows — its phase (spec-0081 §3.1). Exactly one of `sun` and
483/// `moon` (`DW0931`).
484#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
485#[serde(deny_unknown_fields)]
486pub struct CelestialTime {
487    /// Where the sun stands. Exclusive with `moon`.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub sun: Option<Position>,
490    /// Where the moon stands. Exclusive with `sun`.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub moon: Option<Position>,
493    /// The moon's phase. Required on `world.time` whenever the moon is at or
494    /// above the horizon at the stated position, refused wherever it is below.
495    /// Absent on a `set-time`, a design row or a camera sky = the world's.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub phase: Option<MoonPhase>,
498}
499
500impl CelestialTime {
501    /// The one body this statement names and where it stands, or `None` when it
502    /// names neither or both (`DW0931`).
503    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    /// The `daytime` tick this statement names. A statement naming neither or
512    /// both bodies is refused by `DW0931` before anything reads its hour; it
513    /// reads as its `sun` if it has one, else its `moon`, else noon, so the
514    /// function is total.
515    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    /// The moon's altitude at the stated position, in hundredths of a degree.
524    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    /// Whether the moon is at or above the horizon at the stated position.
533    pub fn moon_shows(self) -> bool {
534        self.moon_altitude_centideg() >= 0
535    }
536
537    /// The canonical JSON spelling — `{"moon":"high","phase":"new-moon"}`.
538    pub fn spelling(self) -> String {
539        serde_json::to_string(&self).expect("a celestial time serialises")
540    }
541}
542
543/// **The clock one time value sets**: `dayTime` split the way the game's two
544/// read-backs split it. Two time values are equal when their clocks are.
545#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct Clock {
547    /// `time query day`: `dayTime / 24000`.
548    pub day: i64,
549    /// `time query daytime`: `dayTime % 24000`.
550    pub daytime: i64,
551}
552
553impl Clock {
554    /// The absolute `dayTime`: `day × 24000 + daytime` — the integer `time set`
555    /// takes.
556    pub fn absolute(self) -> i64 {
557        self.day * DAY_TICKS + self.daytime
558    }
559
560    /// The phase the moon shows on this clock's day.
561    pub fn phase(self) -> MoonPhase {
562        MoonPhase::of_day(self.day)
563    }
564
565    /// `(day D, T)` — how a message prints a clock beside its spelling.
566    pub fn label(self) -> String {
567        format!("(day {}, {})", self.day, self.daytime)
568    }
569}
570
571/// Where a celestial time is written, for the rules of its shape (`DW0931`).
572#[derive(Clone, Copy, Debug, PartialEq, Eq)]
573pub enum CelestialSite {
574    /// `world.time`.
575    World,
576    /// A `set-time` effect, quest or dialogue.
577    Cut,
578    /// A design record row's `time`.
579    Row,
580    /// A showcase camera's `sky.time`.
581    Camera,
582}
583
584/// An altitude in hundredths of a degree, as a message prints it: `+2.86°`.
585pub 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
591/// **The rules of one celestial value's shape** (spec-0081 §6), as the message
592/// each broken rule earns — empty when the value is well formed. One function,
593/// so `delvec validate` (the world, every cut, every design row) and the camera
594/// record's reader say the same sentence about the same value.
595pub 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    // The world's phase is only a fact when the world's own time is well formed:
644    // a world refused for naming no body, or for leaving a visible moon
645    // unnamed, has no phase for a cut to restate, and its own refusal says so.
646    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
669/// `DW0931` over every time a campaign's documents state: the world's, every
670/// `set-time` at every effect root and depth, every dialogue `set-time`, and
671/// every design record row. (A camera's sky is the camera record's reader's:
672/// `delvec`'s design gate, through [`shape_findings`].)
673pub(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    /// The client's sky constants, recorded with the reading that measured
729    /// them: `SkyRenderer` draws the sun on a quad of `SUN_SIZE` 30 and the moon
730    /// on one of `MOON_SIZE` 20, both at height 100; each celestial texture is
731    /// 32 × 32 with a full-brightness disc 8 pixels wide (the sun's middle row
732    /// reads `1, 2, 4, 7, 10, 14, 19, 23, 27, 32, 36, 40, 255 ×8, 40 … 1`). A
733    /// client-jar pin bump re-measures them.
734    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    /// The real tick at which the sun's altitude is `alt` degrees on the given
744    /// half of the arc, by bisection on the vendored track.
745    fn solve_sun(alt: f64, west: bool) -> f64 {
746        // The western half runs noon (6000) -> midnight (18000), the altitude
747        // falling; the eastern half midnight (18000) -> noon (30000), rising.
748        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            // west: altitude decreases with time; east: it increases.
758            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        // The moon stands opposite the sun: the moon at altitude `a` on one
782        // side is the sun at `-a` on the other.
783        let derive = |body: Body, pos: Position| -> i64 {
784            let half = match body {
785                Body::Sun => sun_half,
786                Body::Moon => moon_half,
787            };
788            // (altitude of the named body, the named body is in the west)
789            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        // The zenith and nadir are where the track says they are.
818        assert!((sun_altitude_degrees(6000.0) - 90.0).abs() < 1e-9);
819        assert!((sun_altitude_degrees(18000.0) + 90.0).abs() < 1e-9);
820        // One hour, spelled from two sides, by construction.
821        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        // Read at the keywords (§2.5).
875        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        // The keywords' altitudes on the pinned track (§2.3).
895        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}