# Weather

Weather is a six-state machine that punctuates a run with rain, storms, droughts, and the aurora. One weighted event is pending at a time, picked by `WeatherSchedule` from `balance.json`'s `weather` section; the aurora runs on a separate season track in parallel. The guiding rule is **duration is magnitude** — how long an event lasts places its severity in a 0..1 band, and every consequence is that band lerped across a min/max pair. Two files:

- `scripts/core/WeatherManager.gd` — autoload. Owns the clock and the `state`, applies results, and persists.
- `scripts/core/WeatherSchedule.gd` — pure static scheduling + consequence math. No node, no signals, no RNG of its own: every function takes its `cfg` dictionary and a `RandomNumberGenerator` explicitly, so `tools/smoke_test.gd` can exercise it deterministically. It decides only *what* happens and *how big*; `WeatherManager` owns the clock and applies it.

## States

```gdscript
enum WeatherState { CLEAR, RAIN, STORM_WARNING, STORM, DROUGHT, AURORA }
```

`RAIN`, `STORM`, and `DROUGHT` are the weighted scheduler kinds (`WeatherSchedule.KINDS`). `AURORA` is **not** scheduled that way — it has its own season track (below). `STORM_WARNING` is the 58 s telegraph phase before a storm.

## The single scheduler

The old parallel per-kind timers are gone. `WeatherManager` holds one pending event (`_pending_kind`, `_pending_duration`, `_next_event_in`) and re-arms it through `WeatherSchedule.roll_next()` after every event ends.

`roll_next()` does a weighted pick where **weight = 1 / mean interval days**, so one shared draw reproduces each kind's intended cadence. On top of that sit hard floors:

- **Earliest-due floor** — a kind cannot fire before `<kind>_interval_days_min` (× the challenge mult) since it last occurred. A kind that has *never* occurred this run uses `<kind>_first_day` instead, so drought's first onset waits until day 12 (past the tutorial, past the window where a new player has no water infrastructure).
- **`required_quiet()`** — minimum clear days after the most recent event: its `<kind>_min_gap_days`, plus a **burst brake** (more than `burst_max_events` inside `burst_window_days` forces `burst_clear_days` of calm). This is a public function precisely so the smoke test can assert it directly, since the combined mean gap would otherwise mask it.

The delay is drawn at 0.6–1.4× the combined mean gap, then floored by both the picked kind's earliest-due and `required_quiet()`, so ~30% weather occupancy can never clump into one bad stretch.

Challenge/Harrow **sky dials** arrive as per-kind interval multipliers (`ChallengeManager.rain_interval_mult()` / `storm_interval_mult()`), passed into `roll_next` and folded into both the weights and the floors. Floors only ever scale *down* (clamped ≤ 1), so a challenge that makes weather rarer keeps the standard quiet rules while a stormy challenge can compress them.

`_history` (an array of `{kind, ended_day}`, trimmed to the burst window) and `_last_by_kind` feed these rules. They store absolute `ended_day` values on `_elapsed`'s clock, so **they must always be saved and restored together** — splitting them skews `since_last` by the whole difference.

## Duration is magnitude

`severity_of(kind, duration, cfg)` maps a rolled duration to 0..1 within that kind's `<kind>_duration_min/max`. `consequence(kind, severity, cfg)` then lerps every effect range by that severity and returns the full effect set; callers read only the keys they need.

Storms are the exception. They are **music-locked to 154 s** (58 s warning + 96 s storm), so they carry no duration range — `FIXED_DURATION_KINDS = ["storm"]` — and roll a severity independently with `_rng.randf()` at warning start. `ScreenFlash` reads `storm_severity` to telegraph how bad the incoming storm will be, and the warning toast picks one of three omen strings by severity band.

## Rain

`_begin_rain()` resolves `fill_pct`, `dig_speed`, and `pocket_mult` from severity. Fill is delivered **across** the event by `_drip(delta)` rather than as a lump at the start, so the visible rain is the water actually arriving (the pond always, the well if `SurvivalManager.well_built`, the water tower if owned). Rain also speeds digging (`dig_speed_mult`) and soaks the water-pocket roll (`water_pocket_mult` > 1). Maren narrates the onset.

## Drought

`_begin_drought()` resolves `well_trickle`, `dig_speed` (slower), `water_drain` (thirstier), `caches`, and `pocket_mult` (< 1, parched) from severity, and calls `ForestManager.spawn_pond_caches()`. While it runs, `_process` does three things per frame:

1. **Evaporates the pond** — `DROUGHT_POND_EVAP_FRACTION` (0.7) of capacity drains across the full drought. `drought_progress()` (0..1) drives the amber vignette ramp.
2. **Daily departure check** — once per game day, if village-accessible water (`stored_water + well_water`, *not* the digger's personal meter) is under `drought_departure_reserve_days` of need, one villager leaves via `VillageManager.drought_departure()`. This runs *in parallel* with the normal metric-shortfall departures; the compounding is deliberate.
3. **Farmer's call** — once the pond has visibly receded (< 60% of capacity) and caches exist, Maren calls out the glinting bed (toast + narrative). Not persisted, so a mid-drought reload re-fires it once.

`_end_drought()` clears caches (`ForestManager.clear_pond_caches()`) and restores every multiplier. The receding water is what exposes the caches; the slow refill afterward (pond regen ~0.048/s) is the recovery arc.

## Aurora — the season track

Aurora is a night boon, not a weighted event, so it lives in `_aurora_tick(delta)`, advanced every frame regardless of the main `state`:

- A **season** opens on its own countdown (`aurora_season_gap`: first at day 5–8, second gap 12–16, later 18–23 — front-loaded so the first is reachable and the post-first stretch isn't a letdown).
- An open season budgets `aurora_nights_min..max` (2–3) nights inside an `aurora_season_window_days` (6) window. Each **night** (`day_time ≥ 0.78 or < 0.22`), while the sky is otherwise `CLEAR` **and no expedition is suspending weather**, rolls one `aurora_night_fire_chance` (0.7) — so lit nights are not necessarily consecutive.
- A lit night's reward is `aurora_consequence(night_index, cfg, strength, rng)` = base × decay × strength. Decay across a season is `[1.0, 0.7, 0.5]`; strength is 1.0 pre-ritual and `aurora_astrolabe_strength` (1.3) once the Astrolabe has been used (the sky answers the ritual).

The reward set: `yield_bonus` (probabilistic +1 dig unit via `yield_bonus_chance` → `TerrainDigging.roll_yield`), `chew_mult` (Maw recoils; floored at 0.05 so a strong aurora can't stop it outright), `soul_pct` (arrivals via `VillageManager.aurora_arrivals` — **souls need a fed village**), and `soul_cap`. The lit night ends at dawn (`_end_aurora_night`).

`rng` is optional: passed, the cap is rolled; omitted, it collapses to the range midpoint so the smoke test can read the consequence set deterministically.

### Why `soul_cap` exists

`soul_pct` is proportional to population, but the ordinary daily tick in `VillageManager._tick_population` is **flat-additive** — its best tier is `randi_range(1,3) + randi_range(4,6)`, so at most +9 people per day at *any* village size. The aurora was therefore the only proportional term in the system, and it ran away at scale: post-ritual it drew ~52 souls in one night at population 800, versus a fortnight of ordinary growth.

`aurora_consequence` now returns a `soul_cap` rolled per night from `aurora_soul_cap_min`/`_max` (14/19) and scaled by the **same** `decay` vector as the rewards — without that scaling every night of a season would clamp to the same number and the season's fade would disappear. `aurora_arrivals(pct, cap)` clamps to it, keeping the existing `min_support >= population` gate and the `maxi(1, …)` floor untouched.

Two deliberate calls: the cap **ignores `strength`** (a post-ritual aurora burns brighter but the souls hard-top-out), and it only starts binding around population 215, so early-game auroras are unchanged. Full tuning tables in [Weather and Storms](/docs/underroot/weather-and-storms).

### Offline and expedition holds

Aurora is **offline-safe**: `OfflineSimulator` brackets its catch-up with `begin_offline_sim()`/`end_offline_sim()`, and `yield_bonus_chance()` returns 0 while `_offline_simulating`, matching the offline chew path that ignores weather. Weather pauses offline.

It also honours the expedition hold. `_begin_pending()` refuses to open rain/storm/drought while `_suspended`, but the aurora runs on its own track and so needs saying separately — the night's fire roll carries its own `not _suspended` check. The check is on the **roll**, not the whole tick, so a suspended night is deferred rather than spent: `_aurora_night_rolled` stays false and the night comes back when the player does. See [Black Hollow](/docs/underroot/black-hollow).

## Storm resolution

`_resolve_storm()` → `_apply_storm_effects()` reads the storm consequence set from severity and applies five things, returning a result dictionary carried on `EventBus.storm_ended`:

1. **Villager deaths** (`deaths`, clamped so population never drops below 1) via `GameManager.record_villager_deaths` (skips the per-villager `villager_died` path).
2. **Resource loss** — every material in `STORM_CORE_MATS` (`["dirt","clay","stone","coal"]`) loses `core_loss` of stock (≥ 1), plus one random extra the player holds. Iron is deliberately excluded.
3. **Water fill** — pond, well, and water tower topped out; a storm always leaves water abundant.
4. **Strange root** — `root_loss` of held `strange_root` destroyed, and `bind_strip` of bound wall columns lose their root binding.
5. **Wall destruction — per front.** A contiguous run of `wall_col` of *each front's* wall columns is ripped out, measured inward from that front's chew position. `_storm_batter_front(f, col_fraction)` walks the front's direction (east `dir < 0` walks −x, a Two Fronts west front walks +x), stopping at the first gap or a column the Maw hasn't reached. Both walls take a battering, each from its own Maw. See [The Maw](/docs/underroot/the-maw).

## Cutscene deferral

A storm coming due while a full-screen cutscene plays (Astrolabe ritual, population milestone) is pushed out by `STORM_CUTSCENE_DEFER` (6 s) and re-checked, so it never opens on top of another cutscene. `_cutscene_depth` is kept in sync via `EventBus.cutscene_started`/`cutscene_ended`; `reset()` clears it. Rain and drought have no cutscene of their own and never defer.

## Expedition suspension

`suspend_for_expedition()` / `resume_from_expedition()` hold the weather while a Black Hollow expedition owns the screen. Suspending reverts an in-progress `STORM_WARNING`/`STORM` to `CLEAR`, re-arms the scheduler, and emits `EventBus.storm_toast_confirmed` — which is the teardown signal the cutscene, `ScreenFlash`, `StormToast`, the debris canvas, and the storm music all listen for, so nothing is left stranded over the mine. It is idempotent: both the route menu and the dig overlay call it, and only the overlay resumes.

The tree pause already freezes this whole scheduler, so `_suspended` is a second line of defence rather than the first. `MawController` carries a matching pair for the same reason.

## What other systems read

| Accessor | Read by | Effect |
|---|---|---|
| `dig_speed_mult()` | `TerrainDigging.get_dig_time` | Rain faster, drought slower |
| `water_drain_mult()` | `SurvivalManager` (live only) | Drought = thirstier village |
| `well_trickle_mult()` | `SurvivalManager` | Drought slows the well |
| `pond_regen_mult()` | `ForestManager._process` | 0 during drought |
| `water_pocket_mult()` | `DiscoveryManager` | Rain soaks (×1.5–2.0), drought parches (×0.5–0.3) |
| `yield_bonus_chance()` | `TerrainDigging.roll_yield` | Aurora +1 dig-unit chance (0 offline) |
| `maw_chew_mult()` | `MawController` | Aurora slows the Maw |
| `drought_progress()` | amber vignette | 0..1 through the drought |

## Persistence

Weather rides a top-level `weather` save section, matching survival/village/maw. Absent on pre-existing saves, which load as a fresh `CLEAR` schedule — **no `SAVE_VERSION` bump needed**. A save written mid-`STORM_WARNING`/`STORM` restores as `CLEAR` (no music/cutscene to resume) and re-arms the scheduler; restored rain/drought/aurora re-emit their `*_started` signal deferred so listeners connected this frame still catch it. `load_save_data` filters `_history`/`_last_by_kind` rather than trusting them, since `WeatherSchedule` iterates history with typed loops.

## Tuning

Every weather number lives in `data/balance.json` → `weather`, read via `_cfg()`. See [Weather and Storms](/docs/underroot/weather-and-storms) for the full value tables and safe ranges. Run the data validator after any `balance.json` edit — and when a script starts reading a *new* balance key, add it to `REQUIRED_BALANCE_KEYS` in `tools/validate_data.gd`.

## Key files

| File | Role |
|---|---|
| `scripts/core/WeatherManager.gd` | Autoload. Clock, state, application, persistence. |
| `scripts/core/WeatherSchedule.gd` | Pure static scheduling + consequence math. |
| `data/balance.json` (`weather`, `time`) | All tuning; `time.day_duration` is the scheduling base unit. |

## Related

- [The Maw](/docs/underroot/the-maw)
- [Black Hollow](/docs/underroot/black-hollow)
- [Challenges](/docs/underroot/challenges)
- [Survival and Village](/docs/underroot/survival-and-village)
- [Forest and Surface](/docs/underroot/forest-and-surface)
- [The Astrolabe](/docs/underroot/the-astrolabe-and-harrow)
- [Weather and Storms](/docs/underroot/weather-and-storms)