fuzzyworld

tower / files / test / far-end.test.ts

far-end.test.ts

8.1 KB · history · blame

1import { readFileSync } from 'node:fs'
2import { describe, expect, it } from 'vitest'
3import { SPIN_PERIOD_S } from '../src/canon/constants.js'
4import {
5 AIMED_AT_EDGE, ICE_FRONT_M, ICE_MANTLE_M, ROD_GLOW_BY_DAY, aimedShare, iceCover, iceMantle, iceMargin, intoIce,
6 lightShare, rangeRock,
7} from '../src/terrain/ice.js'
8import { ICE_CLEAR_M, lanesOf } from '../src/terrain/ships.js'
9import { TRIANGLE_KM } from '../src/views/cap-pattern.js'
10import { FROST_TAU_S, PANE_KM, TURN_S, frostAt, loopOrder, loopS, paneAt, paneCorners, panes } from '../src/world/frost.js'
11import { type Sea, parseWorld } from '../src/world/schema.js'
12
13const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
14const deepward = world.features.find((f) => f.id === 'deepward-sea') as Sea
15
16describe('the Deepward Sea freezes in bands (D-090)', () => {
17 it('has the bands §1.9.6 gives: open to 512 km, pack to 525, fast to the cap', () => {
18 expect(deepward.ice).toMatchObject({ edgeM: 512_000, fastM: 525_000 })
19 })
20
21 it('is open water short of the edge, pack across the band, and solid fast ice beyond', () => {
22 const ice = deepward.ice!
23 expect(iceCover(intoIce(ice, 505_000))).toBe(0)
24 const pack = [513_000, 518_000, 524_000].map((z) => iceCover(intoIce(ice, z)))
25 expect(pack[0]).toBeGreaterThan(0.25)
26 expect(pack[1]).toBeGreaterThan(pack[0]!)
27 expect(pack[2]).toBeGreaterThan(pack[1]!)
28 expect(pack[2]).toBeLessThan(1)
29 expect(iceCover(intoIce(ice, 530_000))).toBe(1)
30 })
31
32 it('reads a frozen sea with its ice the other way round, and no ice at all without it', () => {
33 const w = parseWorld({ ...JSON.parse(readFileSync('content/world.json', 'utf8')) })
34 const sunward = w.features.find((f) => f.id === 'sunward-sea') as Sea
35 expect(sunward.ice).toBeNull()
36 expect(iceCover(intoIce({ edgeM: 30_000, fastM: 15_000, range: null }, 10_000))).toBe(1)
37 expect(iceCover(intoIce({ edgeM: 30_000, fastM: 15_000, range: null }, 35_000))).toBe(0)
38 })
39
40 it("keeps the ships on the open water, clear of the ice edge", () => {
41 const lanes = lanesOf(deepward, world.seed)
42 expect(lanes.length).toBe(deepward.ships)
43 for (const l of lanes) {
44 expect(l.z0 + l.weaveM).toBeLessThan(deepward.ice!.edgeM - ICE_CLEAR_M)
45 expect(l.z0 - l.weaveM).toBeGreaterThan(496_000)
46 }
47 })
48})
49
50describe('ice mountains at the foot of the cap (D-093)', () => {
51 const range = deepward.ice!.range!
52 const level = deepward.levelM
53 const bed = level - 420
54
55 it('stands on a range Tower raised: from the sea bed at 524 km to 2,400 m at the cap', () => {
56 expect(range).toEqual({ fromM: 524_000, heightM: 2400 })
57 expect(rangeRock(0, bed, range.heightM)).toBe(bed)
58 expect(rangeRock(1, bed, range.heightM)).toBe(range.heightM)
59 // Steepening toward the window.
60 const a = rangeRock(0.5, bed, 2400) - rangeRock(0.4, bed, 2400)
61 const b = rangeRock(1, bed, 2400) - rangeRock(0.9, bed, 2400)
62 expect(b).toBeGreaterThan(a)
63 })
64
65 it('is iced from where the rock leaves the water, in a low front', () => {
66 const t = iceMargin(level, bed, range.heightM)
67 expect(rangeRock(t, bed, range.heightM)).toBeCloseTo(level - 40, 6)
68 expect(iceMantle(0)).toBeCloseTo(ICE_FRONT_M, 6)
69 })
70
71 it('thickens as an ice sheet does, to 350 m on the heights', () => {
72 expect(iceMantle(1000)).toBeGreaterThan(150)
73 expect(iceMantle(1000)).toBeLessThan(ICE_MANTLE_M)
74 expect(iceMantle(10_000)).toBe(ICE_MANTLE_M)
75 })
76
77 it('stands kilometres up the window all round, with open water still open', async () => {
78 const { compileWorld } = await import('../src/terrain/compile.js')
79 const { prepare } = await import('../src/terrain/field.js')
80 const rt = prepare(compileWorld(world))
81 const tops: number[] = []
82 for (let i = 0; i < 24; i++) {
83 const theta = (i / 24) * 2 * Math.PI
84 const cap = rt.sample(theta, 539_980, 20)
85 expect(cap.water).toBeLessThan(cap.h)
86 tops.push(cap.h)
87 const fast = rt.sample(theta, 526_000, 20)
88 expect(fast.water).toBe(level)
89 expect(fast.ice).toBe(1)
90 const open = rt.sample(theta, 505_000, 20)
91 expect(open.glacier).toBe(0)
92 expect(open.water).toBe(level)
93 }
94 expect(Math.min(...tops)).toBeGreaterThan(1500)
95 expect(Math.max(...tops)).toBeLessThan(4000)
96 // A range, not a kerb: the heights vary round the ring.
97 expect(Math.max(...tops) - Math.min(...tops)).toBeGreaterThan(100)
98 }, 60_000)
99})
100
101describe('Tower keeps the far end dim (D-091)', () => {
102 const ice = deepward.ice!
103 const open = 496_000
104
105 it('aims its full light short of the sea, and less and less toward the ice', () => {
106 expect(aimedShare(ice, open, 480_000)).toBe(1)
107 expect(aimedShare(ice, open, 504_000)).toBeLessThan(1)
108 expect(aimedShare(ice, open, 504_000)).toBeGreaterThan(AIMED_AT_EDGE)
109 expect(aimedShare(ice, open, 512_000)).toBeCloseTo(AIMED_AT_EDGE, 6)
110 expect(aimedShare(ice, open, 520_000)).toBeLessThan(AIMED_AT_EDGE)
111 expect(aimedShare(ice, open, 530_000)).toBe(0)
112 })
113
114 it('leaves the fast ice only the rod by day: a twentieth, dusk', () => {
115 const day = { sun: 320, rod: 0.32, moon: 0 }
116 expect(lightShare(ice, open, 535_000, day)).toBeCloseTo(ROD_GLOW_BY_DAY, 2)
117 expect(lightShare(ice, open, 300_000, day)).toBe(1)
118 })
119
120 it('changes nothing by a moonless night: the rod is never aimed', () => {
121 expect(lightShare(ice, open, 535_000, { sun: 0, rod: 0.32, moon: 0 })).toBe(1)
122 })
123
124 it('takes the moon off the fast ice', () => {
125 expect(lightShare(ice, open, 535_000, { sun: 0, rod: 0.32, moon: 0.32 })).toBeCloseTo(0.5, 6)
126 })
127})
128
129describe('the far cap frosts, and Tower clears one pane per turn (D-090)', () => {
130 it('has 282 panes: 216 whole triangles and 66 cut by the rim', () => {
131 expect(panes().length).toBe(282)
132 const whole = panes().filter((p) => paneCorners(p).every(([x, y]) => Math.hypot(x, y) <= 50 + 1e-9))
133 expect(whole.length).toBe(216)
134 })
135
136 it('uses the same triangles as the mullions', () => {
137 expect(PANE_KM).toBeCloseTo(TRIANGLE_KM, 12)
138 })
139
140 it('finds each pane from a point inside it', () => {
141 for (const p of panes()) {
142 const c = paneCorners(p)
143 const x = (c[0]![0] + c[1]![0] + c[2]![0]) / 3
144 const y = (c[0]![1] + c[1]![1] + c[2]![1]) / 3
145 expect(paneAt(x, y)).toEqual(p)
146 }
147 })
148
149 it('takes one turn of the world per pane: a loop of 35.1 hours', () => {
150 expect(TURN_S).toBeCloseTo(SPIN_PERIOD_S, 9)
151 expect(loopS() / 3600).toBeCloseTo(35.1, 1)
152 })
153
154 it('clears every pane exactly once a loop, in an order reshuffled each loop', () => {
155 const a = loopOrder(world.seed, 7)
156 expect([...a].sort((x, y) => x - y)).toEqual(panes().map((_, i) => i))
157 expect(loopOrder(world.seed, 7)).toEqual(a)
158 expect(loopOrder(world.seed, 8)).not.toEqual(a)
159 expect(loopOrder(world.seed + 1, 7)).not.toEqual(a)
160 })
161
162 it('thaws the pane being cleared across its turn, and nothing else at once', () => {
163 const L = loopS()
164 const k = 12
165 const first = loopOrder(world.seed, k)[0]!
166 const out = new Float32Array(panes().length)
167 frostAt(world.seed, k * L + 0.001, out)
168 const before = out[first]!
169 frostAt(world.seed, k * L + TURN_S * 0.5, out)
170 expect(out[first]).toBeCloseTo(before * 0.5, 2)
171 frostAt(world.seed, k * L + TURN_S * 0.999, out)
172 expect(out[first]).toBeLessThan(0.01)
173 frostAt(world.seed, k * L + TURN_S * 3, out)
174 expect(out[first]).toBeCloseTo(1 - Math.exp(-(2 * TURN_S) / FROST_TAU_S), 3)
175 })
176
177 it('does not jump where one loop ends and the next begins', () => {
178 const L = loopS()
179 const a = frostAt(world.seed, 20 * L - 1e-3, new Float32Array(panes().length))
180 const b = frostAt(world.seed, 20 * L + 1e-3, new Float32Array(panes().length))
181 for (let i = 0; i < a.length; i++) expect(Math.abs(a[i]! - b[i]!)).toBeLessThan(1e-3)
182 })
183
184 it('leaves about one pane in ten nearly clear, averaged over a loop', () => {
185 const L = loopS()
186 const out = new Float32Array(panes().length)
187 let clear = 0, total = 0
188 for (let t = 30 * L; t < 31 * L; t += L / 97) {
189 frostAt(world.seed, t, out)
190 for (const f of out) { total++; if (f < 0.3) clear++ }
191 }
192 expect(clear / total).toBeGreaterThan(0.06)
193 expect(clear / total).toBeLessThan(0.16)
194 })
195})