fuzzyworld

tower / files / test / canon.test.ts

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