fuzzyworld

tower / files / test / weather.test.ts

weather.test.ts

Blame: the commit that last changed each line · file · history

a088e5d5 2026-09-17 Pavel Safronov1import { describe, expect, it } from 'vitest'
2import { CIRCUMFERENCE_M } from '../src/canon/constants.js'
3import { boomAngle, lanesOf, shipAt, shipsAt, waveAt } from '../src/terrain/ships.js'
4import { type WeatherPlan, withKind } from '../src/world/schema.js'
5import { actualAt, hoursOf, inHarbour, kindOfSpeed, occurrences, seaWindAt } from '../src/world/weather.js'
6
7const 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. */
10const 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})
14const plan = (events: WeatherPlan['events'], cycleDays = 0): WeatherPlan => ({ cycleDays, events })
15
16describe('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
55describe('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
71describe('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
100describe('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
109describe('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})