tower / files / test / water.test.ts
water.test.ts
80bcc97a 2026-09-17 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { freeFallDriftM } from '../src/canon/motion.js' | |
| 3 | import { fallPoint } from '../src/terrain/falls.js' | |
| 4 | import { fetchOf, foamOf, manningSpeed, windSea } from '../src/terrain/water.js' | |
| 5 | ||
| 6 | /** The figures world-generation.md §4.2 and §4.4 print, so the doc and the code agree. */ | |
| 7 | describe('wind waves', () => { | |
| 8 | it('a 10 m/s wind across 30 km of lake raises 0.9 m waves at a 4.2 s period', () => { | |
| 9 | const s = windSea(10, 30_000) | |
| 10 | expect(s.heightM).toBeCloseTo(0.9, 1) | |
| 11 | expect(s.periodS).toBeCloseTo(4.2, 1) | |
| 12 | }) | |
| 13 | ||
| 14 | it('a mountain tarn 2 km across raises 0.2 m', () => { | |
| 15 | expect(windSea(10, 2000).heightM).toBeCloseTo(0.23, 2) | |
| 16 | }) | |
| 17 | ||
| 18 | it('a ring sea, with unlimited fetch, is fully developed: 2.1 m at 10 m/s, 8.6 m at 20', () => { | |
| 19 | expect(windSea(10, Infinity).heightM).toBeCloseTo(2.14, 2) | |
| 20 | expect(windSea(20, Infinity).heightM).toBeCloseTo(8.57, 2) | |
| 21 | }) | |
| 22 | ||
| 23 | it('never builds a sea past fully developed, however far the wind blows', () => { | |
| 24 | expect(windSea(15, 5e6).heightM).toBeLessThanOrEqual(windSea(15, Infinity).heightM + 1e-9) | |
| 25 | }) | |
| 26 | ||
| 27 | it('gives deep-water wavelength for the peak period', () => { | |
| 28 | const s = windSea(10, 30_000) | |
| 29 | expect(s.lengthM).toBeCloseTo((9.80665 * s.periodS ** 2) / (2 * Math.PI), 6) | |
| 30 | }) | |
| 31 | ||
| 32 | it('makes no waves without wind', () => { | |
| 33 | expect(windSea(0, 1e5).heightM).toBe(0) | |
| 34 | }) | |
| 35 | }) | |
| 36 | ||
| 37 | describe('fetch', () => { | |
| 38 | const band = { type: 'band', zFrom: 0, zTo: 42_000 } as const | |
| 39 | it('is unlimited around a ring sea', () => { | |
| 40 | expect(fetchOf(band, Math.PI / 2)).toBe(Infinity) | |
| 41 | }) | |
| 42 | it('is the width of the band for a wind straight along the axis', () => { | |
| 43 | expect(fetchOf(band, 0)).toBeCloseTo(42_000, 6) | |
| 44 | }) | |
| 45 | }) | |
| 46 | ||
| 47 | describe('rivers', () => { | |
| 48 | it('runs a lowland river at a walking pace and a mountain one fast', () => { | |
| 49 | expect(manningSpeed(2.7, 0.002)).toBeGreaterThan(1) | |
| 50 | expect(manningSpeed(2.7, 0.002)).toBeLessThan(3) | |
| 51 | expect(manningSpeed(1.5, 0.08)).toBeGreaterThan(manningSpeed(1.5, 0.002)) | |
| 52 | }) | |
| 53 | it('turns white as it steepens', () => { | |
| 54 | expect(foamOf(0.002)).toBe(0) | |
| 55 | expect(foamOf(0.2)).toBe(1) | |
| 56 | }) | |
| 57 | }) | |
| 58 | ||
| 59 | describe('waterfalls', () => { | |
| 60 | const fall = { river: 'r', z: 100_000, a: 5000, dz: 1, da: 0, topM: 1700, bottomM: 700, widthM: 60, speedMS: 3 } | |
| 61 | ||
| 62 | it('lands antispinward of the lip by the free-fall drift: 133 m for a kilometre', () => { | |
| 63 | const lip = fallPoint(fall, 0, 0) | |
| 64 | const foot = fallPoint(fall, 0, 1000) | |
| 65 | // A river running deepward: spinward is +a, so the drift is a loss of a. | |
| 66 | expect(lip.a - foot.a).toBeCloseTo(freeFallDriftM(1000), 6) | |
| 67 | expect(lip.a - foot.a).toBeCloseTo(133, -1) | |
| 68 | }) | |
| 69 | ||
| 70 | it('carries the river forward over the lip at its own speed', () => { | |
| 71 | const t = Math.sqrt((2 * 1000) / 9.80665) | |
| 72 | expect(fallPoint(fall, 0, 1000).z - fall.z).toBeCloseTo(3 * t, 6) | |
| 73 | }) | |
| 74 | ||
| 75 | it('leans the same way whichever way the river runs', () => { | |
| 76 | const spinwardRiver = { ...fall, dz: 0, da: 1 } | |
| 77 | const foot = fallPoint(spinwardRiver, 0, 500) | |
| 78 | const noSpin = spinwardRiver.a + spinwardRiver.da * spinwardRiver.speedMS * Math.sqrt((2 * 500) / 9.80665) | |
| 79 | expect(noSpin - foot.a).toBeCloseTo(freeFallDriftM(500), 6) | |
| 80 | }) | |
| 81 | }) |