nx_day_night_cycle.nx source
↩ module page · 189 lines · 8044 B
1// nx_day_night_cycle.nx -- diurnal lighting + sun position model.
2//
3// Per honest gap roadmap 2026-05-16: world needs a day/night loop so
4// gameplay has dawn/dusk dramatic moments, hostile mobs spawn at
5// night, and atmosphere primitives have meaningful sun position to
6// drive sky color.
7//
8// MODEL:
9// - 1 game day = N ticks (caller-configurable; default 24000 ticks
10// for Minecraft-compatible 20 minutes at 20 tps).
11// - Sub-phases:
12// * DAWN ticks [0, day/8) sun rising; sky reddening
13// * MORNING ticks [day/8, day*3/8) sun climbing
14// * NOON ticks [day*3/8, day*5/8) sun overhead
15// * AFTERNOON ticks [day*5/8, day*7/8) sun descending
16// * DUSK ticks [day*7/8, day*9/16) sun setting; sky reddening
17// * NIGHT ticks [day*9/16, day) moon up; hostile spawn enabled
18// (Actually for simplicity: day_frac in [0, Q]; mid-day = Q/2;
19// midnight = 0 / Q. Sub-phases inferred from day_frac.)
20//
21// PUBLIC APIs:
22// nx_day_frac_q14(tick, ticks_per_day) -> Q14 [0, Q]
23// nx_sun_zenith_q14(day_frac) -> Q14 (0 = overhead, Q = horizon)
24// nx_ambient_light_q14(day_frac) -> Q14 [0, Q]
25// nx_phase(day_frac) -> NX_PHASE_*
26// nx_is_night(day_frac) -> 0/1
27//
28// genealogy_id: solar_day_canon + minecraft_day_night
29// lineage_id: nx_day_night_cycle_q14_v1
30
31// nx_safety_envelope:
32// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
33// sil_target: SIL1
34// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
35// verdict: NOT_YET_EVALUATED
36
37import "nx_syscalls.nx"
38import "nx_tier.nx"
39const NX_MAGIC_24000: i64 = 24000
40const NX_MAGIC_12000: i64 = 12000
41const NX_MAGIC_48000: i64 = 48000
42
43const NX_DN_Q: nx_int = 16384
44
45// ===== Sealed-enum phases ==========================================
46const NX_PHASE_DAWN: nx_int = 0
47const NX_PHASE_MORNING: nx_int = 1
48const NX_PHASE_NOON: nx_int = 2
49const NX_PHASE_AFTERNOON: nx_int = 3
50const NX_PHASE_DUSK: nx_int = 4
51const NX_PHASE_NIGHT: nx_int = 5
52const NX_PHASE_COUNT: nx_int = 6
53
54func nx_phase_is_valid(p: nx_int) -> nx_int {
55 if p >= 0 { if p < NX_PHASE_COUNT { return 1 } }
56 return 0
57}
58
59// ===== Default day length ===========================================
60// 20 tps * 60 sec * 20 min = 24000 ticks (Minecraft default).
61const NX_DN_DEFAULT_TICKS_PER_DAY: nx_int = 24000
62
63// ===== Day fraction =================================================
64// Returns the current point in the day as a Q14 fraction [0, Q].
65// 0 = midnight; Q/2 = noon; Q = next midnight.
66func nx_day_frac_q14(tick: nx_int, ticks_per_day: nx_int) -> nx_int {
67 var ttl: nx_int = ticks_per_day
68 if ttl <= 0 { ttl = NX_DN_DEFAULT_TICKS_PER_DAY }
69 var t: nx_int = tick % ttl
70 if t < 0 { t = t + ttl }
71 return (t * NX_DN_Q) / ttl
72}
73
74// ===== Phase classifier ============================================
75// Maps day_frac to one of 6 phases.
76// night [0, 0.20Q)
77// dawn [0.20Q, 0.30Q)
78// morning [0.30Q, 0.45Q)
79// noon [0.45Q, 0.55Q)
80// afternoon[0.55Q, 0.70Q)
81// dusk [0.70Q, 0.80Q)
82// night [0.80Q, Q)
83func nx_phase(day_frac_q14: nx_int) -> nx_int {
84 let q: nx_int = NX_DN_Q
85 if day_frac_q14 < (q * 20) / 100 { return NX_PHASE_NIGHT }
86 if day_frac_q14 < (q * 30) / 100 { return NX_PHASE_DAWN }
87 if day_frac_q14 < (q * 45) / 100 { return NX_PHASE_MORNING }
88 if day_frac_q14 < (q * 55) / 100 { return NX_PHASE_NOON }
89 if day_frac_q14 < (q * 70) / 100 { return NX_PHASE_AFTERNOON }
90 if day_frac_q14 < (q * 80) / 100 { return NX_PHASE_DUSK }
91 return NX_PHASE_NIGHT
92}
93
94// Is-night predicate (for hostile-mob spawn gating).
95func nx_is_night(day_frac_q14: nx_int) -> nx_int {
96 if nx_phase(day_frac_q14) == NX_PHASE_NIGHT { return 1 }
97 return 0
98}
99
100// ===== Sun zenith angle ============================================
101// Returns Q14 with 0 = directly overhead, Q = at the horizon, beyond
102// Q = below horizon (night).
103//
104// Sinusoidal model: zenith = |cos(2*pi * day_frac)| approximately.
105// At day_frac=0 (midnight): zenith = Q (below horizon).
106// At day_frac=Q/2 (noon): zenith = 0 (overhead).
107// We use a piecewise-linear approximation:
108// day_frac in [0, 0.25Q]: zenith from Q to Q/2
109// day_frac in [0.25Q, 0.5Q]: zenith from Q/2 to 0
110// day_frac in [0.5Q, 0.75Q]: zenith from 0 to Q/2
111// day_frac in [0.75Q, Q]: zenith from Q/2 to Q
112func nx_sun_zenith_q14(day_frac_q14: nx_int) -> nx_int {
113 let q: nx_int = NX_DN_Q
114 if day_frac_q14 <= q / 4 {
115 let f: nx_int = (day_frac_q14 * q) / (q / 4)
116 return q - (f * (q / 2)) / q
117 }
118 if day_frac_q14 <= q / 2 {
119 let f: nx_int = ((day_frac_q14 - q / 4) * q) / (q / 4)
120 return (q / 2) - (f * (q / 2)) / q
121 }
122 if day_frac_q14 <= (q * 3) / 4 {
123 let f: nx_int = ((day_frac_q14 - q / 2) * q) / (q / 4)
124 return (f * (q / 2)) / q
125 }
126 let f: nx_int = ((day_frac_q14 - (q * 3) / 4) * q) / (q / 4)
127 return (q / 2) + (f * (q / 2)) / q
128}
129
130// ===== Ambient light ===============================================
131// Q14 [0, Q] inverse of zenith but more dramatic.
132// noon (day_frac = Q/2): ambient = Q
133// dawn/dusk: ambient = Q/2
134// midnight: ambient = Q/16 (moonlight floor)
135func nx_ambient_light_q14(day_frac_q14: nx_int) -> nx_int {
136 let q: nx_int = NX_DN_Q
137 let zenith: nx_int = nx_sun_zenith_q14(day_frac_q14)
138 // ambient = clamp(Q - zenith, Q/16, Q)
139 var light: nx_int = q - zenith
140 let floor_q: nx_int = q / 16
141 if light < floor_q { light = floor_q }
142 if light > q { light = q }
143 return light
144}
145
146// ===== Self-test ====================================================
147func main() -> i64 {
148 let q: nx_int = NX_DN_Q
149
150 // T1: Day frac wraps modulo ticks_per_day.
151 if nx_day_frac_q14(0, NX_MAGIC_24000) != 0 { return __syscall(93, 1, 0, 0, 0, 0, 0) }
152 if nx_day_frac_q14(NX_MAGIC_12000, NX_MAGIC_24000) != q / 2 { return __syscall(93, 2, 0, 0, 0, 0, 0) }
153 if nx_day_frac_q14(NX_MAGIC_48000, NX_MAGIC_24000) != 0 { return __syscall(93, 3, 0, 0, 0, 0, 0) } // 2 days
154 // Default day length (24000).
155 if nx_day_frac_q14(0, 0) != 0 { return __syscall(93, 4, 0, 0, 0, 0, 0) }
156
157 // T2: Phase classification.
158 if nx_phase(0) != NX_PHASE_NIGHT { return __syscall(93, 10, 0, 0, 0, 0, 0) }
159 if nx_phase(q / 2) != NX_PHASE_NOON { return __syscall(93, 11, 0, 0, 0, 0, 0) }
160 if nx_phase((q * 25) / 100) != NX_PHASE_DAWN { return __syscall(93, 12, 0, 0, 0, 0, 0) }
161 if nx_phase((q * 75) / 100) != NX_PHASE_DUSK { return __syscall(93, 13, 0, 0, 0, 0, 0) }
162 if nx_phase(q - 100) != NX_PHASE_NIGHT { return __syscall(93, 14, 0, 0, 0, 0, 0) }
163
164 // T3: is_night predicate.
165 if nx_is_night(0) != 1 { return __syscall(93, 20, 0, 0, 0, 0, 0) }
166 if nx_is_night(q / 2) != 0 { return __syscall(93, 21, 0, 0, 0, 0, 0) }
167 if nx_is_night((q * 90) / 100) != 1 { return __syscall(93, 22, 0, 0, 0, 0, 0) }
168
169 // T4: Sun zenith: midnight = Q (below horizon); noon = 0 (overhead).
170 if nx_sun_zenith_q14(0) != q { return __syscall(93, 30, 0, 0, 0, 0, 0) }
171 if nx_sun_zenith_q14(q / 2) != 0 { return __syscall(93, 31, 0, 0, 0, 0, 0) }
172 // Quarter day (dawn): zenith = Q/2.
173 if nx_sun_zenith_q14(q / 4) != q / 2 { return __syscall(93, 32, 0, 0, 0, 0, 0) }
174
175 // T5: Ambient light: night floor; noon max.
176 let l_noon: nx_int = nx_ambient_light_q14(q / 2)
177 if l_noon != q { return __syscall(93, 40, 0, 0, 0, 0, 0) }
178 let l_night: nx_int = nx_ambient_light_q14(0)
179 if l_night != q / 16 { return __syscall(93, 41, 0, 0, 0, 0, 0) }
180 // Dawn: about Q/2.
181 let l_dawn: nx_int = nx_ambient_light_q14(q / 4)
182 if l_dawn != q / 2 { return __syscall(93, 42, 0, 0, 0, 0, 0) }
183
184 // T6: Phase validity predicate.
185 if nx_phase_is_valid(NX_PHASE_NOON) != 1 { return __syscall(93, 50, 0, 0, 0, 0, 0) }
186 if nx_phase_is_valid(99) != 0 { return __syscall(93, 51, 0, 0, 0, 0, 0) }
187
188 return 0
189}