tower / files / test / far-end.test.ts
far-end.test.ts
e18dad04 2026-09-30 Pavel Safronov | 1 | import { readFileSync } from 'node:fs' |
| 2 | import { describe, expect, it } from 'vitest' | |
| 3 | import { SPIN_PERIOD_S } from '../src/canon/constants.js' | |
| 4 | import { | |
| 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' | |
| 8 | import { ICE_CLEAR_M, lanesOf } from '../src/terrain/ships.js' | |
| 9 | import { TRIANGLE_KM } from '../src/views/cap-pattern.js' | |
| 10 | import { FROST_TAU_S, PANE_KM, TURN_S, frostAt, loopOrder, loopS, paneAt, paneCorners, panes } from '../src/world/frost.js' | |
| 11 | import { type Sea, parseWorld } from '../src/world/schema.js' | |
| 12 | ||
| 13 | const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))) | |
| 14 | const deepward = world.features.find((f) => f.id === 'deepward-sea') as Sea | |
| 15 | ||
| 16 | describe('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 | ||
| 50 | describe('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 | ||
| 101 | describe('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 | ||
| 129 | describe('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 | }) |