tower / files / src / world / weather.ts
weather.ts
a088e5d5 2026-09-17 Pavel Safronov | 1 | /** |
| 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 | */ | |
| 15 | import type { WeatherEvent, WeatherKind, WeatherPlan, WeatherRevision, Wind, WorldTime } from './schema.js' | |
| 16 | ||
| 17 | export const hoursOf = (t: WorldTime): number => t.day * 24 + t.hour | |
| 18 | ||
| 19 | export 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. */ | |
| 25 | export 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]. */ | |
| 35 | export 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. */ | |
| 60 | export 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 | ||
| 67 | export 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. */ | |
| 77 | export 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 | */ | |
| 84 | export 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 | */ | |
| 107 | export 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. */ | |
| 117 | export const SEA_MEMORY_H = 2 | |
| 118 | ||
| 119 | /** Hours before a published gale or storm that ships head for harbour. */ | |
| 120 | export 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 | */ | |
| 129 | export 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. */ | |
| 137 | export 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 | } |