fuzzyworld

tower / files / test / sky.test.ts

sky.test.ts

7.9 KB · history · blame

1import { describe, expect, it } from 'vitest'
2import { LENGTH_M } from '../src/canon/constants.js'
3import { defaultCalendar } from '../src/world/schema.js'
4import { dateLabel, hourWhenSunAt, hoursAt, skyAt, sunTimes, utcOffsetH } from '../src/world/sky.js'
5
6/**
7 * Sydney, which Tower kept until D-089: its published sunrise tables check the solar maths,
8 * whichever city the world file names.
9 */
10const sydney = { ...defaultCalendar(), city: 'Sydney', timeZone: 'Australia/Sydney', latitude: -33.8688, longitude: 151.2093 }
11/** Pisa, which Tower keeps now (D-089). */
12const pisa = defaultCalendar()
13/** The world day on which the city has this date (day 0 is 2026-09-23). */
14const dayOf = (iso: string) =>
15 Math.round((Date.parse(`${iso}T00:00:00Z`) - Date.parse(`${sydney.day0}T00:00:00Z`)) / 86_400_000)
16const hm = (h: number, m: number) => h + m / 60
17/** Within four minutes: the published tables round, and the formula is good to one or two. */
18const nearMinutes = (actual: number, expected: number) =>
19 expect(Math.abs(actual - expected) * 60).toBeLessThan(4)
20
21describe("Tower keeps Sydney's days (D-074)", () => {
22 it('starts on the spring equinox, 2026', () => {
23 expect(dateLabel(sydney, 0)).toContain('23 Sep 2026')
24 })
25
26 it("rises and sets with Sydney's shortest and longest days", () => {
27 // Geoscience Australia: 21 Jun 2027, 07:00–16:54 AEST; 21 Dec 2026, 05:41–20:05 AEDT.
28 const winter = sunTimes(sydney, dayOf('2027-06-21'))
29 nearMinutes(winter.sunriseH, hm(7, 0))
30 nearMinutes(winter.sunsetH, hm(16, 54))
31 const summer = sunTimes(sydney, dayOf('2026-12-21'))
32 nearMinutes(summer.sunriseH, hm(5, 41))
33 nearMinutes(summer.sunsetH, hm(20, 5))
34 })
35
36 it('keeps daylight saving: the clock moves, the sun does not', () => {
37 expect(utcOffsetH('Australia/Sydney', new Date('2026-09-23T02:00:00Z'))).toBe(10)
38 expect(utcOffsetH('Australia/Sydney', new Date('2026-12-21T02:00:00Z'))).toBe(11)
39 // Either side of the switch on 4 Oct 2026, sunrise jumps by about an hour.
40 const before = sunTimes(sydney, dayOf('2026-10-03')).sunriseH
41 const after = sunTimes(sydney, dayOf('2026-10-05')).sunriseH
42 expect(after - before).toBeGreaterThan(0.9)
43 })
44})
45
46describe("Tower keeps Pisa's days (D-089)", () => {
47 it('starts on the autumn equinox, a twelve-hour day', () => {
48 const { sunriseH, sunsetH } = sunTimes(pisa, 0)
49 expect(Math.abs(sunsetH - sunriseH - 12)).toBeLessThan(0.25)
50 })
51
52 it('shortens toward December and lengthens toward June', () => {
53 const len = (iso: string) => { const t = sunTimes(pisa, dayOf(iso)); return t.sunsetH - t.sunriseH }
54 expect(len('2026-12-21')).toBeLessThan(9.25)
55 expect(len('2027-06-21')).toBeGreaterThan(15.25)
56 })
57
58 it("keeps Italy's daylight saving", () => {
59 expect(utcOffsetH('Europe/Rome', new Date('2026-09-23T10:00:00Z'))).toBe(2)
60 expect(utcOffsetH('Europe/Rome', new Date('2026-12-21T11:00:00Z'))).toBe(1)
61 // Either side of the switch on 25 Oct 2026, sunrise jumps back by about an hour.
62 const before = sunTimes(pisa, dayOf('2026-10-24')).sunriseH
63 const after = sunTimes(pisa, dayOf('2026-10-26')).sunriseH
64 expect(before - after).toBeGreaterThan(0.9)
65 })
66
67 it('opens the clock at the time in Pisa now', () => {
68 // 10:00 UTC on day 0 is noon in Pisa (CEST, +2).
69 expect(hoursAt(pisa, new Date('2026-09-23T10:00:00Z'))).toBeCloseTo(12)
70 })
71})
72
73describe('the sun runs the length of the world, sunrise to sunset', () => {
74 const day = dayOf('2026-12-21')
75 const { sunriseH, sunsetH } = sunTimes(sydney, day)
76
77 it('lights at the sun cap and goes out at the far cap', () => {
78 expect(skyAt(sydney, day * 24 + sunriseH + 0.01, 'temperate').sunZ).toBeLessThan(1000)
79 expect(skyAt(sydney, day * 24 + sunsetH - 0.01, 'temperate').sunZ).toBeGreaterThan(LENGTH_M - 1000)
80 })
81
82 it('moves at a steady pace: halfway through the day it is halfway along', () => {
83 const mid = skyAt(sydney, day * 24 + (sunriseH + sunsetH) / 2, 'temperate')
84 expect(mid.sunZ).toBeCloseTo(LENGTH_M / 2, -2)
85 expect(mid.power).toBe(1)
86 expect(mid.watts).toBeCloseTo(200)
87 })
88
89 it('is off at night, with the beam banked to a thousandth', () => {
90 const night = skyAt(sydney, day * 24 + 23, 'temperate')
91 expect(night.sunZ).toBeNull()
92 expect(night.power).toBe(0)
93 // The rod's 0.32 W/m², and the moon's share of that again, as lit as it is (D-088).
94 expect(night.watts).toBeCloseTo(0.32 * (1 + night.moonLight), 2)
95 })
96
97 it('comes up and goes out over a quarter of an hour, not instantly', () => {
98 const early = skyAt(sydney, day * 24 + sunriseH + 0.125, 'temperate')
99 expect(early.power).toBeGreaterThan(0.2)
100 expect(early.power).toBeLessThan(0.8)
101 })
102
103 it('shuts down entirely when the dial says dark', () => {
104 const dark = skyAt(sydney, day * 24 + 12, 'dark')
105 expect(dark.sunZ).toBeNull()
106 expect(dark.watts).toBe(0)
107 })
108
109 it('can be asked when it will be over a place', () => {
110 const h = hourWhenSunAt(sydney, day, LENGTH_M / 4)
111 expect(skyAt(sydney, day * 24 + h, 'full').sunZ).toBeCloseTo(LENGTH_M / 4, -2)
112 })
113})
114
115describe('the calendar is in the world file', () => {
116 it('reads the committed one: Pisa, from the equinox', async () => {
117 const { readFileSync } = await import('node:fs')
118 const { parseWorld } = await import('../src/world/schema.js')
119 const cal = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))).calendar
120 expect(cal).toEqual(pisa)
121 })
122 it('falls back field by field when something in it is unusable', async () => {
123 const { parseWorld } = await import('../src/world/schema.js')
124 const cal = parseWorld({ calendar: { city: 'Perth', timeZone: 'Not/AZone', day0: 'soon', latitude: -31.95 } }).calendar
125 expect(cal.city).toBe('Perth')
126 expect(cal.timeZone).toBe('Europe/Rome')
127 expect(cal.day0).toBe('2026-09-23')
128 expect(cal.latitude).toBe(-31.95)
129 })
130})
131
132describe('the clock opens at the time in Sydney now', () => {
133 it('turns a real instant into Tower time', () => {
134 // 02:00 UTC on day 0 is noon in Sydney (AEST, +10).
135 expect(hoursAt(sydney, new Date('2026-09-23T02:00:00Z'))).toBeCloseTo(12)
136 // With daylight saving, 01:00 UTC on 21 Dec is noon AEDT, 89 days on.
137 expect(hoursAt(sydney, new Date('2026-12-21T01:00:00Z'))).toBeCloseTo(89 * 24 + 12)
138 })
139 it('has no past: before day 0 it is day 0', () => {
140 expect(hoursAt(sydney, new Date('2026-01-01T00:00:00Z'))).toBe(0)
141 })
142})
143
144describe("Tower's moon (D-088)", () => {
145 const sunset = sunTimes(sydney, 0).sunsetH
146 const sunrise = sunTimes(sydney, 1).sunriseH + 24
147
148 it('is out by day, and with the beam shut down', () => {
149 const noon = skyAt(sydney, 12, 'full')
150 expect(noon.moonZ).toBeNull()
151 expect(noon.moonLight).toBe(0)
152 expect(skyAt(sydney, 23, 'dark').moonZ).toBeNull()
153 })
154
155 it('crosses from the far cap at sunset to the sun cap at sunrise', () => {
156 expect(skyAt(sydney, sunset + 0.01, 'full').moonZ!).toBeGreaterThan(LENGTH_M * 0.99)
157 expect(skyAt(sydney, (sunset + sunrise) / 2, 'full').moonZ!).toBeCloseTo(LENGTH_M / 2, -3)
158 expect(skyAt(sydney, sunrise - 0.01, 'full').moonZ!).toBeLessThan(LENGTH_M * 0.01)
159 // And after midnight it is the same night's moon, still going.
160 const late = skyAt(sydney, 24 + 2, 'full').moonZ!
161 expect(late).toBeLessThan(skyAt(sydney, 23, 'full').moonZ!)
162 })
163
164 it("is lit as Earth's Moon is lit, and brightens the night by it", () => {
165 // Full moon 26 Sep 2026 16:49 UTC — early on the 27th in Sydney, world day 4's night.
166 const full = skyAt(sydney, 3 * 24 + 23, 'full')
167 expect(full.moonPhase.fraction).toBeGreaterThan(0.97)
168 // New moon 11 Oct 2026: nothing to light.
169 const nu = skyAt(sydney, dayOfNew() * 24 + 23, 'full')
170 expect(nu.moonPhase.fraction).toBeLessThan(0.03)
171 expect(full.watts).toBeGreaterThan(nu.watts * 1.9)
172 })
173})
174
175/** The world day of Sydney's new moon, 11 Oct 2026. */
176function dayOfNew(): number {
177 return Math.round((Date.parse('2026-10-11T00:00:00Z') - Date.parse(`${sydney.day0}T00:00:00Z`)) / 86_400_000)
178}