tower / files / test / weather.test.ts
weather.test.ts
a088e5d5 2026-09-17 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import { CIRCUMFERENCE_M } from '../src/canon/constants.js' | |
| 3 | import { boomAngle, lanesOf, shipAt, shipsAt, waveAt } from '../src/terrain/ships.js' | |
| 4 | import { type WeatherPlan, withKind } from '../src/world/schema.js' | |
| 5 | import { actualAt, hoursOf, inHarbour, kindOfSpeed, occurrences, seaWindAt } from '../src/world/weather.js' | |
| 6 | ||
| 7 | const baseline = { directionDeg: 90, speedMS: 8 } | |
| 8 | ||
| 9 | /** A storm on day 5 from 12:00 for 12 hours, published on day 3, over the Sunward Sea. */ | |
| 10 | const storm = (over: Partial<WeatherPlan['events'][number]> = {}): WeatherPlan['events'][number] => ({ | |
| 11 | id: 's', target: 'sea', kind: 'storm', start: { day: 5, hour: 12 }, durationH: 12, | |
| 12 | wind: { directionDeg: 90, speedMS: 28 }, publishedAt: { day: 3, hour: 9 }, revisions: [], ...over, | |
| 13 | }) | |
| 14 | const plan = (events: WeatherPlan['events'], cycleDays = 0): WeatherPlan => ({ cycleDays, events }) | |
| 15 | ||
| 16 | describe('the weather as Tower makes it', () => { | |
| 17 | it('is the baseline when nothing is scheduled', () => { | |
| 18 | const s = actualAt(plan([storm()]), 'sea', baseline, hoursOf({ day: 4, hour: 0 })) | |
| 19 | expect(s.wind).toEqual(baseline) | |
| 20 | expect(s.occurrence).toBeNull() | |
| 21 | }) | |
| 22 | ||
| 23 | it('is the scheduled wind during the event, rising over its first hour', () => { | |
| 24 | const p = plan([storm()]) | |
| 25 | expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 18 })).wind.speedMS).toBe(28) | |
| 26 | expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 12.5 })).wind.speedMS).toBeCloseTo(18, 6) | |
| 27 | }) | |
| 28 | ||
| 29 | it('follows Tower\'s latest revision, from the moment it was made', () => { | |
| 30 | const p = plan([storm({ revisions: [{ | |
| 31 | at: { day: 5, hour: 15 }, kind: 'calm', wind: { directionDeg: 90, speedMS: 1 }, | |
| 32 | reason: 'a boat is out that should not be', | |
| 33 | }] })]) | |
| 34 | expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 14 })).kind).toBe('storm') | |
| 35 | const after = actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 16 })) | |
| 36 | expect(after.kind).toBe('calm') | |
| 37 | expect(after.revision?.reason).toContain('boat') | |
| 38 | }) | |
| 39 | ||
| 40 | it('only affects its own body of water', () => { | |
| 41 | expect(actualAt(plan([storm()]), 'other', baseline, hoursOf({ day: 5, hour: 18 })).wind).toEqual(baseline) | |
| 42 | }) | |
| 43 | ||
| 44 | it('repeats a cycling plan, and keeps a revision to the occurrence it was made in', () => { | |
| 45 | const p = plan([storm({ revisions: [{ | |
| 46 | at: { day: 5, hour: 13 }, kind: 'breeze', wind: { directionDeg: 90, speedMS: 6 }, reason: 'eased', | |
| 47 | }] })], 28) | |
| 48 | expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 18 })).kind).toBe('breeze') | |
| 49 | // Twenty-eight days on, the storm comes round again, unrevised. | |
| 50 | expect(actualAt(p, 'sea', baseline, hoursOf({ day: 33, hour: 18 })).kind).toBe('storm') | |
| 51 | expect(occurrences(p, 'sea', 0, 24 * 60)).toHaveLength(3) | |
| 52 | }) | |
| 53 | }) | |
| 54 | ||
| 55 | describe('the sea state lags the wind', () => { | |
| 56 | it('builds over hours rather than jumping with the wind', () => { | |
| 57 | const p = plan([storm()]) | |
| 58 | const early = seaWindAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 13.5 })).speedMS | |
| 59 | const late = seaWindAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 20 })).speedMS | |
| 60 | expect(early).toBeLessThan(late) | |
| 61 | expect(late).toBeGreaterThan(25) | |
| 62 | }) | |
| 63 | ||
| 64 | it('keeps running after the wind drops', () => { | |
| 65 | const p = plan([storm()]) | |
| 66 | const justAfter = seaWindAt(p, 'sea', baseline, hoursOf({ day: 6, hour: 1 })).speedMS | |
| 67 | expect(justAfter).toBeGreaterThan(baseline.speedMS + 5) | |
| 68 | }) | |
| 69 | }) | |
| 70 | ||
| 71 | describe('ships and the published plan', () => { | |
| 72 | it('stay in harbour for a published storm, from a few hours before it', () => { | |
| 73 | const p = plan([storm()]) | |
| 74 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 8 }))).toBe(false) | |
| 75 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 10 }))).toBe(true) | |
| 76 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 20 }))).toBe(true) | |
| 77 | expect(inHarbour(p, 'sea', hoursOf({ day: 6, hour: 1 }))).toBe(false) | |
| 78 | }) | |
| 79 | ||
| 80 | it('go out anyway for a storm nobody was told about, and are caught out by it', () => { | |
| 81 | const p = plan([storm({ publishedAt: { day: 5, hour: 12 } })]) | |
| 82 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 10 }))).toBe(false) | |
| 83 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 14 }))).toBe(false) | |
| 84 | }) | |
| 85 | ||
| 86 | it('are caught out by a storm announced too late to run for harbour', () => { | |
| 87 | const p = plan([storm({ publishedAt: { day: 5, hour: 11 } })]) | |
| 88 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 18 }))).toBe(false) | |
| 89 | }) | |
| 90 | ||
| 91 | it('stay in for a storm Tower has quietly called off: people act on what they were told', () => { | |
| 92 | const p = plan([storm({ revisions: [{ | |
| 93 | at: { day: 4, hour: 0 }, kind: 'calm', wind: { directionDeg: 0, speedMS: 1 }, reason: 'changed its mind', | |
| 94 | }] })]) | |
| 95 | expect(actualAt(p, 'sea', baseline, hoursOf({ day: 5, hour: 18 })).kind).toBe('calm') | |
| 96 | expect(inHarbour(p, 'sea', hoursOf({ day: 5, hour: 18 }))).toBe(true) | |
| 97 | }) | |
| 98 | }) | |
| 99 | ||
| 100 | describe('reading a wind', () => { | |
| 101 | it('names speeds roughly as Beaufort does', () => { | |
| 102 | expect(kindOfSpeed(0.5)).toBe('calm') | |
| 103 | expect(kindOfSpeed(8)).toBe('breeze') | |
| 104 | expect(kindOfSpeed(20)).toBe('gale') | |
| 105 | expect(kindOfSpeed(30)).toBe('storm') | |
| 106 | }) | |
| 107 | }) | |
| 108 | ||
| 109 | describe('ships', () => { | |
| 110 | const sea = { | |
| 111 | ...withKind({ | |
| 112 | id: 'sea', name: 'Sea', blurb: '', notes: '', public: true, seed: 7, | |
| 113 | geometry: { type: 'band', zFrom: 0, zTo: 42_000 }, | |
| 114 | }, 'sea'), | |
| 115 | } as Extract<ReturnType<typeof withKind>, { kind: 'sea' }> | |
| 116 | ||
| 117 | it('are the same fleet for everyone: lanes come from the seeds', () => { | |
| 118 | expect(lanesOf(sea, 1)).toEqual(lanesOf(sea, 1)) | |
| 119 | expect(lanesOf(sea, 1)).not.toEqual(lanesOf(sea, 2)) | |
| 120 | expect(lanesOf(sea, 1)).toHaveLength(sea.ships) | |
| 121 | }) | |
| 122 | ||
| 123 | it('keep off the shores and sail at a cruising speed', () => { | |
| 124 | for (const lane of lanesOf(sea, 1)) { | |
| 125 | for (const h of [0, 3.3, 50, 1000]) { | |
| 126 | const { z } = shipAt(lane, h) | |
| 127 | expect(z).toBeGreaterThan(0) | |
| 128 | expect(z).toBeLessThan(42_000) | |
| 129 | } | |
| 130 | expect(lane.speedMS).toBeGreaterThanOrEqual(3) | |
| 131 | expect(lane.speedMS).toBeLessThanOrEqual(6) | |
| 132 | } | |
| 133 | }) | |
| 134 | ||
| 135 | it('move at their speed, in the direction they head', () => { | |
| 136 | const lane = lanesOf(sea, 1)[0]! | |
| 137 | const a0 = shipAt(lane, 10).a, a1 = shipAt(lane, 10 + 1 / 60).a | |
| 138 | let da = a1 - a0 | |
| 139 | da -= Math.round(da / CIRCUMFERENCE_M) * CIRCUMFERENCE_M | |
| 140 | expect(Math.sign(da)).toBe(lane.dir) | |
| 141 | expect(Math.abs(da)).toBeCloseTo(lane.speedMS * 60, 6) | |
| 142 | expect(Math.sign(Math.sin(shipAt(lane, 10).headingRad))).toBe(lane.dir) | |
| 143 | }) | |
| 144 | ||
| 145 | it('stay in harbour for a published storm', () => { | |
| 146 | const world = { | |
| 147 | version: 2, seed: 1, ground: { baseM: 0, reliefM: 0, notes: '' }, features: [sea], | |
| 148 | weather: plan([storm()]), | |
| 149 | } as unknown as Parameters<typeof shipsAt>[0] | |
| 150 | expect(shipsAt(world, hoursOf({ day: 4, hour: 0 }))).toHaveLength(sea.ships) | |
| 151 | expect(shipsAt(world, hoursOf({ day: 5, hour: 18 }))).toHaveLength(0) | |
| 152 | }) | |
| 153 | ||
| 154 | it('ride a wave sum whose slope is the derivative of its height', () => { | |
| 155 | const at = (z: number, a: number) => waveAt(1.2, 80, 0.7, z, a, 123.4) | |
| 156 | const e = 0.01 | |
| 157 | const w = at(500, 900) | |
| 158 | expect(w.gz).toBeCloseTo((at(500 + e, 900).h - at(500 - e, 900).h) / (2 * e), 4) | |
| 159 | expect(w.ga).toBeCloseTo((at(500, 900 + e).h - at(500, 900 - e).h) / (2 * e), 4) | |
| 160 | }) | |
| 161 | ||
| 162 | it('let the sail out running before the wind and sheet it in heading into it', () => { | |
| 163 | expect(Math.abs(boomAngle(0, 0))).toBeCloseTo(Math.PI / 2, 6) | |
| 164 | expect(Math.abs(boomAngle(0, Math.PI))).toBeCloseTo(0, 6) | |
| 165 | expect(boomAngle(0, Math.PI / 2)).toBeCloseTo(Math.PI / 4, 6) | |
| 166 | expect(boomAngle(0, -Math.PI / 2)).toBeCloseTo(-Math.PI / 4, 6) | |
| 167 | }) | |
| 168 | }) |