fuzzyworld

tower / cascadia / files / test / canon.test.ts

Branch cascadia · default branch · all branches

canon.test.ts

Blame: the commit that last changed each line · file · history

ec7e78a8 2026-09-07 Pavel Safronov1/**
2 * Canon is derived from four free parameters (decisions.md D-007), so these tests
3 * pin every derived figure to the number printed in design.md. If a constant is
4 * ever retuned, the docs and these expectations must move together.
5 */
6import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7import * as C from '../src/canon/constants.js'
8import { pressureAtRadius, relativeDensityAtRadius } from '../src/canon/atmosphere.js'
9import {
10 landArea, landStripCount, landStripWidth, setSlatCount, slatCount, slatWidth,
11} from '../src/canon/slats.js'
12import {
13 MAP_CUT_WITHOUT_SLATS, isSlat, landStripIndex, mapThetaOrigin, slatCentre,
14} from '../src/canon/geometry.js'
15import { readFileSync } from 'node:fs'
16
17const near = (actual: number, expected: number, tolerance: number) =>
18 expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance)
19
20describe('§1.1 dimensions', () => {
21 it('circumference is 314.16 km', () => near(C.CIRCUMFERENCE_M, 314_159, 5))
22 it('inner surface is Wisconsin total area, 169,646 km²', () =>
23 near(C.INNER_AREA_M2 / 1e6, 169_646, 5))
24 it('land is Wisconsin land area, 140,268 km²', () =>
25 near(C.LAND_AREA_M2 / 1e6, 140_268, 5))
26})
27
28describe('§1.2 spin', () => {
29 it('gives exactly 1 g at the floor', () => near(C.gravityAtHeight(0), C.G0, 1e-9))
30 it('ω = 0.014005 rad/s', () => near(C.OMEGA_RAD_S, 0.014005, 1e-6))
31 it('spin period is 448.6 s', () => near(C.SPIN_PERIOD_S, 448.6, 0.1))
32 it('rim speed is 700.2 m/s', () => near(C.RIM_SPEED_M_S, 700.2, 0.1))
33 it('gravity falls linearly to zero on the axis', () => {
34 near(C.gravityAtRadius(0), 0, 1e-12)
35 near(C.gravityAtHeight(1000) / C.G0, 0.98, 0.001)
36 })
37})
38
39describe('§1.4 the hull is sealed by default (D-017)', () => {
40 afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT))
41
42 it('defaults to no slats at all', () => {
43 expect(C.SLAT_COUNT_DEFAULT).toBe(0)
44 expect(slatCount()).toBe(0)
45 })
46 it('makes the whole inner surface land — Wisconsin’s total area', () =>
47 near(landArea() / 1e6, 169_646, 5))
48})
49
50describe('§1.4 slats, when the author explores them', () => {
51 beforeEach(() => setSlatCount(8))
52 afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT))
53
54 it('is 8 slats, 6.80 km wide', () => {
55 expect(slatCount()).toBe(8)
56 near(slatWidth(), 6800, 15)
57 })
58 it('land strips are 32.5 km wide', () => near(landStripWidth(), 32_470, 30))
59 it('slat and land widths tile the circumference', () =>
60 near(slatCount() * (slatWidth() + landStripWidth()), C.CIRCUMFERENCE_M, 1))
61})
62
63describe('§1.4 slat count is adjustable from 0 to 16', () => {
64 afterEach(() => setSlatCount(C.SLAT_COUNT_DEFAULT))
65
66 it('keeps land at Wisconsin’s land area for every count from 1 up', () => {
67 for (let n = 1; n <= C.SLAT_COUNT_MAX; n++) {
68 setSlatCount(n)
69 near(landArea() / 1e6, 140_268, 5)
70 near(slatCount() * (slatWidth() + landStripWidth()), C.CIRCUMFERENCE_M, 1)
71 }
72 })
73
74 it('divides a fixed total width, so more slats means narrower ones', () => {
75 setSlatCount(4)
76 const wide = slatWidth()
77 setSlatCount(16)
78 // The total is what is fixed, so 16 narrow slats sum to the same as 4 wide ones.
79 near(slatWidth() * 16, wide * 4, 1)
80 near(slatWidth() * 4, wide, 1)
81 near(slatWidth(), 3400, 10)
82 near(landStripWidth(), 16_235, 20)
83 })
84
85 it('makes one unbroken surface at zero, and says so about the land area', () => {
86 setSlatCount(0)
87 expect(slatCount()).toBe(0)
88 expect(landStripCount()).toBe(1)
89 expect(slatWidth()).toBe(0)
90 near(landStripWidth(), C.CIRCUMFERENCE_M, 1)
91 // With no slats the D-002 coincidence no longer holds — land is everything.
92 near(landArea() / 1e6, 169_646, 5)
93 for (const theta of [0, 1, 3, 5, 6.28]) {
94 expect(isSlat(theta)).toBe(false)
95 expect(landStripIndex(theta)).toBe(0)
96 }
97 // With no slats there is no empty theta, so the cut goes opposite the capital.
98 expect(mapThetaOrigin()).toBe(Math.PI)
99 })
100
101 it('clamps out-of-range and non-finite input rather than corrupting canon', () => {
102 for (const [input, expected] of [[-5, 0], [99, 16], [3.4, 3], [NaN, 0]] as const) {
103 setSlatCount(C.SLAT_COUNT_DEFAULT)
104 setSlatCount(input)
105 expect(slatCount()).toBe(expected)
106 }
107 })
108
109 it('cuts the map at a slat centre at every count, so no strip is split', () => {
110 for (let n = 1; n <= C.SLAT_COUNT_MAX; n++) {
111 setSlatCount(n)
112 expect(isSlat(mapThetaOrigin())).toBe(true)
113 near(mapThetaOrigin(), slatCentre(n - 1), 1e-9)
114 }
115 })
116})
117
118describe('§1.3 sun system', () => {
119 it('beam radius is 1592 m', () => near(C.BEAM_RADIUS_M, 1592, 1))
120 it('beam carries 54.3 TW', () => near(C.BEAM_POWER_W / 1e12, 54.3, 0.2))
da6bcb0e 2026-09-08 Pavel Safronov121 it('full brightness is Earth clear-sky noon, 320 W/m² mean', () =>
122 near(C.BRIGHTNESS_FULL_W_M2, 320, 2))
123 it('collector is 244 km across', () => near(C.COLLECTOR_DIAMETER_M / 1000, 244, 1))
124 /**
125 * The far cap mirror (D-048). The shape of the load is the whole argument: the
126 * survivors arrive collimated, so this is a spot problem and not a disc problem.
127 */
128 it('the beam reaches the far cap as 1.7 MW/m² in a 3.2 km spot', () => {
129 near(C.BEAM_FAR_CAP_FLUX_W_M2 / 1e6, 1.67, 0.05)
130 // A thousandth of the cap's area — which is why 99.9% of it can be window.
131 near(C.BEAM_AREA_M2 / (Math.PI * C.RADIUS_M ** 2), 0.001, 0.0002)
132 })
133 it('an absorber there would sit at 2330 K; a 99.9% mirror at 1.2 solar constants', () => {
134 near((C.BEAM_FAR_CAP_FLUX_W_M2 / 5.67e-8) ** 0.25, 2330, 20)
135 near(C.FAR_CAP_MIRROR_ABSORBED_W_M2, 1665, 30)
136 })
137 it('the mirror lifts utilisation from 76% to 94% by doubling the column', () => {
138 near(C.BEAM_UTILISATION_ONE_PASS, 0.756, 0.005)
139 near(C.BEAM_UTILISATION_TWO_PASS, 0.940, 0.005)
140 })
2bb3e0de 2026-09-08 Pavel Safronov141 it('the dawn front crosses the world at 225 m/s, about Tower rim speed', () => {
142 near(C.DAWN_FRONT_SPEED_M_S, 225, 1)
143 // 40 minutes of a 24 hour day: the far cap's morning lags the sun cap's by
144 // that much, which is the only longitude a world with no east has.
145 near(C.DAY_SWEEP_FRACTION * 24 * 60, 40, 0.1)
146 })
392bb8a7 2026-09-08 Pavel Safronov147 it('night is the beam banked to a thousandth, 0.32 W/m²', () => {
148 near(C.BRIGHTNESS_NIGHT_W_M2, 0.32, 0.01)
149 near(C.NIGHT_BEAM_FRACTION, 0.001, 1e-9)
150 })
da6bcb0e 2026-09-08 Pavel Safronov151 /**
152 * The reason night has to be made this way rather than by aiming the swarm off.
153 * §1.3 gives the glow to the beam and the air, so a fully lit beam is still a
154 * light source however the diffusers are pointed: 779 W/m²/sr over a strip 3.65°
155 * wide running horizon to horizon is ~0.2 sr, and with the mean cosine of a
156 * strip through the zenith that lands near 100 W/m² on the floor — half of
157 * temperate, which is not a night by anyone's reckoning (D-046).
158 */
159 it('a lit beam alone would light the floor to about 100 W/m²', () => {
160 const strip = (C.BEAM_ANGULAR_WIDTH_RAD * Math.PI) * (2 / Math.PI)
161 near(C.BEAM_RADIANCE_W_M2_SR * strip, 100, 25)
162 })
ec7e78a8 2026-09-07 Pavel Safronov163 it('stays inside the étendue limit — D-003 is no longer a physics break', () => {
164 expect(C.BEAM_CONCENTRATION).toBeLessThan(C.ETENDUE_LIMIT)
165 near(C.BEAM_CONCENTRATION / C.ETENDUE_LIMIT, 0.11, 0.005)
166 })
167})
168
169describe('§1.3.1 diffuser swarm', () => {
170 it('is 78 billion mirrorlets', () => near(C.SWARM_COUNT / 1e9, 78, 1))
171 it('masses 158 tonnes in total', () => near(C.SWARM_MASS_KG / 1000, 158, 2))
172 it('has optical depth 1.41, leaving 24% of light for the far cap', () => {
173 near(C.SWARM_OPTICAL_DEPTH, 1.41, 0.02)
174 near(Math.exp(-C.SWARM_OPTICAL_DEPTH), 0.24, 0.01)
175 })
176 it('aims to a 2.3 m spot on the floor — human-scale privacy', () =>
177 near(C.AIM_SPOT_AT_FLOOR_M, 2.3, 0.05))
178})
179
180describe('§1.5 atmosphere', () => {
181 it('the axis holds 5.2 kPa — thin, but real air', () =>
182 near(pressureAtRadius(0) / 1000, 5.2, 0.1))
183 it('is 5.2% of floor density on the axis', () =>
184 near(relativeDensityAtRadius(0), 0.052, 0.001))
185 it('is full density at the floor', () =>
186 near(relativeDensityAtRadius(C.RADIUS_M), 1, 1e-12))
187})
188
189describe('the map cut agrees with the terrain the tool renders', () => {
190 it('matches content/geography.json, or the raster lands half a world out', () => {
191 const geo = JSON.parse(readFileSync('content/geography.json', 'utf8'))
192 near(geo.render.mapThetaOrigin, MAP_CUT_WITHOUT_SLATS, 1e-5)
193 })
194})