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 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
85/// A track's keyframes as `(ticks, value)`, in file order — what a test reads
86/// the game's numbers back through.
87pub 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
95/// The ease a track declares, as the file spells it (`None` = linear).
96pub fn day_ease(name: &str) -> Option<serde_json::Value> {
97    track(day_timeline(), name).ease.clone()
98}
99
100/// CSS-convention cubic bezier through (0,0), (x1,y1), (x2,y2), (1,1): solve
101/// the x-curve for the parameter, sample the y-curve. Newton–Raphson from the
102/// progress itself, with bisection as the floor — arithmetic only, no
103/// trigonometry, so the value is a function of the IEEE operations alone.
104fn 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
151/// Sample a numeric track at a (fractional) tick: the segment from the last
152/// keyframe at or before the tick to the next one, wrapping over the period,
153/// eased by the track's ease (absent = linear, `constant` = step).
154fn 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
196/// The `visual/sun_angle` of the pinned day timeline at a `daytime` tick, in
197/// degrees: 0 at noon, growing westward, 180 at midnight. The moon's angle is
198/// this plus 180 (`moon_angle` is the same track at 540 → 180).
199pub fn sun_angle_degrees(daytime: f64) -> f64 {
200    sample(day_timeline(), SUN_ANGLE_TRACK, daytime)
201}
202
203/// The sun's altitude above the horizon, in degrees, at a `daytime` tick: the
204/// body travels a great circle through the zenith, so an angle `α` from the
205/// zenith is an altitude of `90 − α` on the western half of the arc and
206/// `α − 270` on the eastern half. Piecewise linear in the angle — no
207/// trigonometry.
208pub fn sun_altitude_degrees(daytime: f64) -> f64 {
209    altitude_of_angle(sun_angle_degrees(daytime))
210}
211
212/// Altitude in degrees of a body standing `angle` degrees from the zenith.
213pub 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
218/// Whether the sun at this `daytime` stands on the western half of its arc —
219/// past noon and before midnight. At noon and midnight exactly the side means
220/// nothing and this answers `false` (east), so a value is a function of the
221/// tick alone.
222pub 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
227/// The `gameplay/sky_light_level` multiplier of the pinned day timeline at a
228/// `daytime` tick: 1.0 on the day plateau, 4/15 on the night plateau, linear
229/// ramps between.
230pub fn sky_light_factor(daytime: i64) -> f64 {
231    sample(day_timeline(), SKY_LIGHT_TRACK, daytime as f64)
232}
233
234/// Whether the pinned day timeline's visible sky is **dark** at a `daytime`
235/// tick: the tick lies on the night plateau of `visual/sky_color` — between two
236/// consecutive keyframes that both carry a value other than the day's first
237/// (ticks 13670..=22330 at the pin, where the sky is multiplied by black).
238/// Outside it the sky is lit or in its dusk or dawn ramp. Read off the
239/// vendored file, never typed.
240pub 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
248/// Whether the `gameplay/monsters_burn` track of the pinned day timeline is on
249/// at a `daytime` tick (a step track: the value of the last keyframe at or
250/// before the tick, wrapping).
251pub 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
264/// The two ticks the `monsters_burn` track switches at: `(off, on)` — 12542 and
265/// 23460 in the pinned file.
266pub 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
277/// The moon's eight phases, kebab-cased, in the pinned `moon.json`'s order:
278/// index `i` is the phase of day `i` modulo 8.
279pub 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
295/// The moon cycle in days: the number of phase keyframes.
296pub fn moon_cycle_days() -> i64 {
297    phase_names().len() as i64
298}
299
300/// The `time_check` period over which a predicate reads the phase: the moon
301/// timeline's own period.
302pub fn moon_period_ticks() -> i64 {
303    moon_timeline().period_ticks
304}
305
306/// **A body in the sky**: the sun or the moon. The moon stands exactly opposite
307/// the sun (`moon_angle` is `sun_angle` + 180), so the two are one position seen
308/// from two sides.
309#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
310pub enum Body {
311    Sun,
312    Moon,
313}
314
315impl Body {
316    /// The word a document writes for this body.
317    pub fn keyword(self) -> &'static str {
318        match self {
319            Body::Sun => "sun",
320            Body::Moon => "moon",
321        }
322    }
323}
324
325/// **Where a body stands on its arc** — one point of it, defined by the body's
326/// drawn disc and the horizon (spec-0081 §3.2).
327#[derive(
328    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
329)]
330#[serde(rename_all = "kebab-case")]
331pub enum Position {
332    /// The disc's centre on the eastern horizon.
333    Rising,
334    /// The disc stands wholly clear of the eastern horizon.
335    JustRisen,
336    /// The body at the zenith.
337    High,
338    /// The disc's centre on the western horizon.
339    Setting,
340    /// The disc has wholly gone below the western horizon.
341    JustSet,
342    /// The body at the nadir — the other body is `high`.
343    Below,
344}
345
346/// Every position, in declaration order.
347pub 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    /// The word a document writes for this position.
358    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
370/// Half the angle the sun's DRAWN disc spans, in hundredths of a degree:
371/// `atan(30 × 8/32 / 100)` = 4.29° — the client's `SUN_SIZE` 30 at height 100,
372/// of which the texture's full-brightness disc is 8 of 32 pixels.
373pub const SUN_DISC_HALF_CENTIDEG: i32 = 429;
374
375/// Half the moon's drawn disc: `atan(20 × 8/32 / 100)` = 2.86° (`MOON_SIZE` 20).
376pub const MOON_DISC_HALF_CENTIDEG: i32 = 286;
377
378/// The `daytime` tick at which `body` stands at `pos` — the integer nearest the
379/// pinned track's solution. Derived once and frozen; the test
380/// `the_position_table_is_the_curve` re-derives every entry.
381pub 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
398/// The named body's altitude at `pos`, in hundredths of a degree, by the
399/// position's definition.
400pub 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
414/// Whether the moon is at or above the horizon at a `daytime` tick: from the
415/// sun's setting to its rising, both inclusive (the moon's centre is on the
416/// horizon at those two ticks).
417pub 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/// **One of the moon's eight phases**, held as its index (0 = `full-moon`, the
424/// phase of day 0). Written in a document as the pinned game's own name,
425/// kebab-cased; the names are DATA, read from the vendored `moon.json`, never an
426/// enum this engine authors (spec-0039: vanilla registry values are data).
427#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
428pub struct MoonPhase(u8);
429
430impl MoonPhase {
431    /// The phase of day `day` (modulo the cycle).
432    pub fn of_day(day: i64) -> MoonPhase {
433        MoonPhase(day.rem_euclid(moon_cycle_days()) as u8)
434    }
435
436    /// The index into the cycle: the day, modulo 8, this phase shows on.
437    pub fn index(self) -> i64 {
438        i64::from(self.0)
439    }
440
441    /// The pinned game's name, kebab-cased (`new-moon`).
442    pub fn name(self) -> &'static str {
443        phase_names()[self.0 as usize].as_str()
444    }
445
446    /// The phase a document names, or `None` for a word the pinned game does
447    /// not use.
448    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    /// The eight names, `a, b, …`, for a message.
456    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/// **A sky stated in a designer's words**: one body, where it stands, and —
497/// where the moon shows — its phase (spec-0081 §3.1). Exactly one of `sun` and
498/// `moon` (`DW0931`).
499#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
500#[serde(deny_unknown_fields)]
501pub struct CelestialTime {
502    /// Where the sun stands. Exclusive with `moon`.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub sun: Option<Position>,
505    /// Where the moon stands. Exclusive with `sun`.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub moon: Option<Position>,
508    /// The moon's phase. Required on `world.time` whenever the moon is at or
509    /// above the horizon at the stated position, refused wherever it is below.
510    /// Absent on a `set-time`, a design row or a camera sky = the world's.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub phase: Option<MoonPhase>,
513}
514
515impl CelestialTime {
516    /// The one body this statement names and where it stands, or `None` when it
517    /// names neither or both (`DW0931`).
518    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    /// The `daytime` tick this statement names. A statement naming neither or
527    /// both bodies is refused by `DW0931` before anything reads its hour; it
528    /// reads as its `sun` if it has one, else its `moon`, else noon, so the
529    /// function is total.
530    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    /// The moon's altitude at the stated position, in hundredths of a degree.
539    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    /// Whether the moon is at or above the horizon at the stated position.
548    pub fn moon_shows(self) -> bool {
549        self.moon_altitude_centideg() >= 0
550    }
551
552    /// The canonical JSON spelling — `{"moon":"high","phase":"new-moon"}`.
553    pub fn spelling(self) -> String {
554        serde_json::to_string(&self).expect("a celestial time serialises")
555    }
556}
557
558/// **The clock one time value sets**: `dayTime` split the way the game's two
559/// read-backs split it. Two time values are equal when their clocks are.
560#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
561pub struct Clock {
562    /// `time query day`: `dayTime / 24000`.
563    pub day: i64,
564    /// `time query daytime`: `dayTime % 24000`.
565    pub daytime: i64,
566}
567
568impl Clock {
569    /// The absolute `dayTime`: `day × 24000 + daytime` — the integer `time set`
570    /// takes.
571    pub fn absolute(self) -> i64 {
572        self.day * DAY_TICKS + self.daytime
573    }
574
575    /// The phase the moon shows on this clock's day.
576    pub fn phase(self) -> MoonPhase {
577        MoonPhase::of_day(self.day)
578    }
579
580    /// `(day D, T)` — how a message prints a clock beside its spelling.
581    pub fn label(self) -> String {
582        format!("(day {}, {})", self.day, self.daytime)
583    }
584}
585
586/// Where a celestial time is written, for the rules of its shape (`DW0931`).
587#[derive(Clone, Copy, Debug, PartialEq, Eq)]
588pub enum CelestialSite {
589    /// `world.time`.
590    World,
591    /// A `set-time` effect, quest or dialogue.
592    Cut,
593    /// A design record row's `time`.
594    Row,
595    /// A showcase camera's `sky.time`.
596    Camera,
597}
598
599/// An altitude in hundredths of a degree, as a message prints it: `+2.86°`.
600pub 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
606/// **The rules of one celestial value's shape** (spec-0081 §6), as the message
607/// each broken rule earns — empty when the value is well formed. One function,
608/// so `delvec validate` (the world, every cut, every design row) and the camera
609/// record's reader say the same sentence about the same value.
610pub 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    // The world's phase is only a fact when the world's own time is well formed:
659    // a world refused for naming no body, or for leaving a visible moon
660    // unnamed, has no phase for a cut to restate, and its own refusal says so.
661    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
684/// `DW0931` over every time a campaign's documents state: the world's, every
685/// `set-time` at every effect root and depth, every dialogue `set-time`, and
686/// every design record row. (A camera's sky is the camera record's reader's:
687/// `delvec`'s design gate, through [`shape_findings`].)
688pub(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    /// The client's sky constants, recorded with the reading that measured
744    /// them: `SkyRenderer` draws the sun on a quad of `SUN_SIZE` 30 and the moon
745    /// on one of `MOON_SIZE` 20, both at height 100; each celestial texture is
746    /// 32 × 32 with a full-brightness disc 8 pixels wide (the sun's middle row
747    /// reads `1, 2, 4, 7, 10, 14, 19, 23, 27, 32, 36, 40, 255 ×8, 40 … 1`). A
748    /// client-jar pin bump re-measures them.
749    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    /// The real tick at which the sun's altitude is `alt` degrees on the given
759    /// half of the arc, by bisection on the vendored track.
760    fn solve_sun(alt: f64, west: bool) -> f64 {
761        // The western half runs noon (6000) -> midnight (18000), the altitude
762        // falling; the eastern half midnight (18000) -> noon (30000), rising.
763        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            // west: altitude decreases with time; east: it increases.
773            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        // The moon stands opposite the sun: the moon at altitude `a` on one
797        // side is the sun at `-a` on the other.
798        let derive = |body: Body, pos: Position| -> i64 {
799            let half = match body {
800                Body::Sun => sun_half,
801                Body::Moon => moon_half,
802            };
803            // (altitude of the named body, the named body is in the west)
804            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        // The zenith and nadir are where the track says they are.
833        assert!((sun_altitude_degrees(6000.0) - 90.0).abs() < 1e-9);
834        assert!((sun_altitude_degrees(18000.0) + 90.0).abs() < 1e-9);
835        // One hour, spelled from two sides, by construction.
836        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        // Read at the keywords (§2.5).
890        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        // The keywords' altitudes on the pinned track (§2.3).
910        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}