fuzzyworld

tower / files / src / world / weather.ts

weather.ts

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

a088e5d5 2026-09-17 Pavel Safronov1/**
2 * Reading Tower's weather plan at a moment. See world-generation.md §4.9.
3 *
4 * Two readings of the same plan, and they differ on purpose:
5 *
6 * - **what Tower does** (`actualAt`): the event as last revised before now;
7 * - **what people were told** (`published`): the event as announced, before any
8 * revision — because that is what they plan around. A sailor told of a storm stays in
9 * harbour, even after Tower quietly calls the storm off.
10 *
11 * Everything is a pure function of the plan and a time in hours since day 0, so the
12 * whole world agrees on the weather at a moment without anything having to be simulated
13 * up to it.
14 */
15import type { WeatherEvent, WeatherKind, WeatherPlan, WeatherRevision, Wind, WorldTime } from './schema.js'
16
17export const hoursOf = (t: WorldTime): number => t.day * 24 + t.hour
18
19export const timeOf = (hours: number): WorldTime => {
20 const day = Math.floor(hours / 24)
21 return { day, hour: hours - day * 24 }
22}
23
24/** One occurrence of an event: the event itself, or a later repeat of it in a cycling plan. */
25export interface Occurrence {
26 event: WeatherEvent
27 startH: number
28 endH: number
29 publishedH: number
30 /** Revisions made during this occurrence, earliest first. */
31 revisions: WeatherRevision[]
32}
33
34/** Occurrences over `target` (or every target) that overlap [fromH, toH]. */
35export function occurrences(plan: WeatherPlan, target: string | null, fromH: number, toH: number): Occurrence[] {
36 const out: Occurrence[] = []
37 const cycleH = plan.cycleDays * 24
38 for (const e of plan.events) {
39 if (target !== null && e.target !== target) continue
40 const start = hoursOf(e.start)
41 const end = start + e.durationH
42 const published = Math.min(hoursOf(e.publishedAt), start)
43 const repeats = cycleH > 0
44 ? { first: Math.floor((fromH - end) / cycleH), last: Math.ceil((toH - published) / cycleH) }
45 : { first: 0, last: 0 }
46 for (let k = Math.max(0, repeats.first); k <= repeats.last; k++) {
47 const shift = k * cycleH
48 const o = { startH: start + shift, endH: end + shift, publishedH: published + shift }
49 if (o.endH < fromH || o.publishedH > toH) continue
50 const revisions = e.revisions
51 .filter((r) => { const at = hoursOf(r.at); return at >= o.publishedH && at <= o.endH })
52 .sort((x, y) => hoursOf(x.at) - hoursOf(y.at))
53 out.push({ event: e, ...o, revisions })
54 }
55 }
56 return out.sort((x, y) => x.startH - y.startH)
57}
58
59/** The wind a speed reads as, roughly by the Beaufort scale. */
60export function kindOfSpeed(speedMS: number): WeatherKind {
61 if (speedMS < 1.5) return 'calm'
62 if (speedMS < 14) return 'breeze'
63 if (speedMS < 24.5) return 'gale'
64 return 'storm'
65}
66
67export interface WeatherState {
68 kind: WeatherKind
69 wind: Wind
70 /** The occurrence in force, if any. */
71 occurrence: Occurrence | null
72 /** The revision in force, if Tower has changed its mind. */
73 revision: WeatherRevision | null
74}
75
76/** Hours over which a scheduled wind rises from the baseline and falls back to it. */
77export const RAMP_H = 1
78
79/**
80 * What Tower actually does over `target` at `hours`: the latest revision made so far to
81 * the occurrence in force, or the occurrence as scheduled, or the body's baseline. The
82 * wind rises over the first hour and falls over the last, rather than switching.
83 */
84export function actualAt(plan: WeatherPlan, target: string, baseline: Wind, hours: number): WeatherState {
85 const now = occurrences(plan, target, hours, hours).filter((o) => o.startH <= hours && hours < o.endH)
86 const o = now[now.length - 1]
87 if (!o) return { kind: kindOfSpeed(baseline.speedMS), wind: baseline, occurrence: null, revision: null }
88 let revision: WeatherRevision | null = null
89 for (const r of o.revisions) if (hoursOf(r.at) <= hours) revision = r
90 const kind = revision ? revision.kind : o.event.kind
91 const wind = revision ? revision.wind : o.event.wind
92 const ramp = Math.min(1, (hours - o.startH) / RAMP_H, (o.endH - hours) / RAMP_H)
93 return {
94 kind, occurrence: o, revision,
95 wind: {
96 directionDeg: ramp < 0.5 ? baseline.directionDeg : wind.directionDeg,
97 speedMS: baseline.speedMS + (wind.speedMS - baseline.speedMS) * ramp,
98 },
99 }
100}
101
102/**
103 * The wind the *sea* is still answering. Waves take hours to build and keep running
104 * after the wind has dropped, so the sea state follows a weighted memory of the last six
105 * hours of wind, the most recent counting most. The direction is the wind's now.
106 */
107export function seaWindAt(plan: WeatherPlan, target: string, baseline: Wind, hours: number): Wind {
108 let sum = 0, weight = 0
109 for (let back = 0; back <= 6; back += 0.5) {
110 const w = Math.exp(-back / SEA_MEMORY_H)
111 sum += actualAt(plan, target, baseline, hours - back).wind.speedMS * w
112 weight += w
113 }
114 return { directionDeg: actualAt(plan, target, baseline, hours).wind.directionDeg, speedMS: sum / weight }
115}
116/** E-folding time of the sea's memory of the wind. */
117export const SEA_MEMORY_H = 2
118
119/** Hours before a published gale or storm that ships head for harbour. */
120export const HARBOUR_LEAD_H = 3
121
122/**
123 * Whether ships on `target` are in harbour at `hours`: a gale or a storm was published
124 * in time for them to get in — at least `HARBOUR_LEAD_H` before it — and it is on or due
125 * within those hours. People act on what they were told: a storm Tower has since called
126 * off still empties the sea. And a blow announced too late to run for harbour catches the
127 * fleet out, and it rides the weather until the weather ends.
128 */
129export function inHarbour(plan: WeatherPlan, target: string, hours: number): boolean {
130 return occurrences(plan, target, hours, hours + HARBOUR_LEAD_H).some((o) =>
131 (o.event.kind === 'gale' || o.event.kind === 'storm')
132 && o.publishedH <= o.startH - HARBOUR_LEAD_H
133 && o.startH - HARBOUR_LEAD_H <= hours && hours < o.endH)
134}
135
136/** A one-line description of an occurrence as the board shows it. */
137export function describe(o: Occurrence): string {
138 const t = timeOf(o.startH)
139 const hh = String(Math.floor(t.hour)).padStart(2, '0')
140 const mm = String(Math.round((t.hour % 1) * 60)).padStart(2, '0')
141 return `Day ${t.day}, ${hh}:${mm} — ${o.event.kind}, ${o.event.wind.speedMS} m/s, for ${o.event.durationH} h`
142}