Add weather system with tests

Implements procedural weather transitions with:
- WeatherCondition enum (clear, cloudy, rain, storm, snow, fog)
- WeatherState dataclass (condition + intensity 0-1)
- get_weather_description() for atmospheric text varying by intensity
- advance_weather() with probabilistic transitions based on season/climate
- Climate profiles: temperate (balanced), arid (clear/rare rain),
  arctic (snow/fog/cloudy)
This commit is contained in:
Jared Miller 2026-02-14 15:49:03 -05:00
parent 32f52ef704
commit 4b0a7315c1
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 431 additions and 0 deletions

223
src/mudlib/weather.py Normal file
View file

@ -0,0 +1,223 @@
"""Weather system with procedural transitions."""
from __future__ import annotations
import enum
import random
from dataclasses import dataclass
class WeatherCondition(enum.Enum):
clear = "clear"
cloudy = "cloudy"
rain = "rain"
storm = "storm"
snow = "snow"
fog = "fog"
@dataclass
class WeatherState:
condition: WeatherCondition
intensity: float # 0.0 to 1.0
def get_weather_description(state: WeatherState) -> str:
"""Return atmospheric text for the current weather."""
condition = state.condition
intensity = state.intensity
if condition == WeatherCondition.clear:
return "the sky is clear"
elif condition == WeatherCondition.cloudy:
if intensity < 0.4:
return "thin clouds drift overhead"
elif intensity < 0.7:
return "clouds fill the sky"
else:
return "heavy clouds loom darkly"
elif condition == WeatherCondition.rain:
if intensity < 0.3:
return "a light drizzle falls"
elif intensity < 0.6:
return "rain patters steadily"
else:
return "rain hammers down relentlessly"
elif condition == WeatherCondition.storm:
if intensity < 0.5:
return "thunder rumbles in the distance"
else:
return "lightning splits the sky as the storm rages"
elif condition == WeatherCondition.snow:
if intensity < 0.3:
return "light snow drifts down"
elif intensity < 0.6:
return "snow falls steadily"
else:
return "heavy snow blankets everything"
elif condition == WeatherCondition.fog:
if intensity < 0.4:
return "thin mist hangs in the air"
elif intensity < 0.7:
return "fog obscures the distance"
else:
return "thick fog shrouds everything"
return ""
# Climate profiles: transition weights for each condition
# Format: {from_condition: {to_condition: weight, ...}, ...}
CLIMATE_PROFILES = {
"temperate": {
WeatherCondition.clear: {
"clear": 50,
"cloudy": 40,
"fog": 10,
},
WeatherCondition.cloudy: {
"clear": 30,
"cloudy": 35,
"rain": 20,
"snow": 10,
"fog": 5,
},
WeatherCondition.rain: {
"cloudy": 35,
"rain": 30,
"storm": 15,
"snow": 10,
"fog": 10,
},
WeatherCondition.storm: {
"rain": 60,
"cloudy": 40,
},
WeatherCondition.snow: {
"snow": 40,
"cloudy": 35,
"fog": 25,
},
WeatherCondition.fog: {
"fog": 30,
"cloudy": 40,
"clear": 30,
},
},
"arid": {
WeatherCondition.clear: {
"clear": 90,
"cloudy": 10,
},
WeatherCondition.cloudy: {
"clear": 70,
"cloudy": 20,
"rain": 5,
"fog": 5,
},
WeatherCondition.rain: {
"cloudy": 60,
"rain": 20,
"clear": 20,
},
WeatherCondition.storm: {
"rain": 50,
"cloudy": 50,
},
WeatherCondition.snow: {
"cloudy": 100,
},
WeatherCondition.fog: {
"clear": 60,
"fog": 20,
"cloudy": 20,
},
},
"arctic": {
WeatherCondition.clear: {
"cloudy": 50,
"fog": 30,
"clear": 20,
},
WeatherCondition.cloudy: {
"snow": 40,
"cloudy": 35,
"fog": 25,
},
WeatherCondition.rain: {
"snow": 40,
"cloudy": 40,
"fog": 20,
},
WeatherCondition.storm: {
"snow": 60,
"cloudy": 40,
},
WeatherCondition.snow: {
"snow": 50,
"cloudy": 30,
"fog": 20,
},
WeatherCondition.fog: {
"fog": 40,
"cloudy": 40,
"snow": 20,
},
},
}
def advance_weather(
current: WeatherState,
season: str = "summer",
rng: random.Random | None = None,
climate: str = "temperate",
) -> WeatherState:
"""Advance weather by one step (one game hour)."""
if rng is None:
rng = random.Random()
# Get climate profile
profile = CLIMATE_PROFILES.get(climate, CLIMATE_PROFILES["temperate"])
# Get transition weights for current condition
transitions = profile.get(current.condition, {})
# Filter out snow if not winter/autumn
if season not in ("winter", "autumn"):
transitions = {k: v for k, v in transitions.items() if k != "snow"}
# If no valid transitions, stay in current condition
if not transitions:
return WeatherState(
condition=current.condition, intensity=rng.uniform(0.0, 1.0)
)
# Weighted random choice
conditions = list(transitions.keys())
weights = list(transitions.values())
# Convert string keys to WeatherCondition enums
resolved_conditions = []
for cond in conditions:
if isinstance(cond, str):
resolved_conditions.append(WeatherCondition[cond])
else:
resolved_conditions.append(cond)
new_condition = rng.choices(resolved_conditions, weights=weights, k=1)[0]
# Generate appropriate intensity for the new condition
if new_condition == WeatherCondition.clear:
new_intensity = 0.5 # Clear has no meaningful intensity variation
elif new_condition == WeatherCondition.storm:
new_intensity = rng.uniform(0.5, 1.0) # Storms are always intense
else:
new_intensity = rng.uniform(0.0, 1.0)
return WeatherState(condition=new_condition, intensity=new_intensity)

208
tests/test_weather.py Normal file
View file

@ -0,0 +1,208 @@
"""Tests for weather system."""
import random
from mudlib.weather import (
WeatherCondition,
WeatherState,
advance_weather,
get_weather_description,
)
def test_weather_condition_enum():
assert WeatherCondition.clear.value == "clear"
assert WeatherCondition.cloudy.value == "cloudy"
assert WeatherCondition.rain.value == "rain"
assert WeatherCondition.storm.value == "storm"
assert WeatherCondition.snow.value == "snow"
assert WeatherCondition.fog.value == "fog"
def test_weather_state_dataclass():
state = WeatherState(condition=WeatherCondition.rain, intensity=0.5)
assert state.condition == WeatherCondition.rain
assert state.intensity == 0.5
def test_get_weather_description_returns_non_empty():
state = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
description = get_weather_description(state)
assert isinstance(description, str)
assert len(description) > 0
def test_get_weather_description_varies_by_condition():
clear = get_weather_description(
WeatherState(condition=WeatherCondition.clear, intensity=0.5)
)
rain = get_weather_description(
WeatherState(condition=WeatherCondition.rain, intensity=0.5)
)
snow = get_weather_description(
WeatherState(condition=WeatherCondition.snow, intensity=0.5)
)
# Different conditions should produce different descriptions
assert clear != rain
assert rain != snow
assert clear != snow
def test_get_weather_description_varies_by_intensity():
light_rain = get_weather_description(
WeatherState(condition=WeatherCondition.rain, intensity=0.2)
)
heavy_rain = get_weather_description(
WeatherState(condition=WeatherCondition.rain, intensity=0.9)
)
# Different intensities should produce different descriptions
assert light_rain != heavy_rain
def test_advance_weather_returns_new_state():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng = random.Random(42)
new_state = advance_weather(current, season="summer", rng=rng)
assert isinstance(new_state, WeatherState)
assert isinstance(new_state.condition, WeatherCondition)
assert 0.0 <= new_state.intensity <= 1.0
def test_advance_weather_is_deterministic_with_seed():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng1 = random.Random(42)
state1 = advance_weather(current, season="summer", rng=rng1)
rng2 = random.Random(42)
state2 = advance_weather(current, season="summer", rng=rng2)
assert state1.condition == state2.condition
assert state1.intensity == state2.intensity
def test_advance_weather_transitions_naturally():
# Clear can become cloudy
rng = random.Random(42)
for _ in range(100):
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng)
if new_state.condition == WeatherCondition.cloudy:
break
else:
raise AssertionError("clear never transitioned to cloudy in 100 iterations")
# Cloudy can become rain or clear
rng = random.Random(43)
found_rain = False
found_clear = False
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng)
if new_state.condition == WeatherCondition.rain:
found_rain = True
if new_state.condition == WeatherCondition.clear:
found_clear = True
if found_rain and found_clear:
break
assert found_rain or found_clear
def test_storm_transitions_to_rain_or_cloudy():
# Storm should always transition away (doesn't last)
rng = random.Random(44)
found_non_storm = False
for _ in range(50):
current = WeatherState(condition=WeatherCondition.storm, intensity=0.8)
new_state = advance_weather(current, season="summer", rng=rng)
if new_state.condition in (WeatherCondition.rain, WeatherCondition.cloudy):
found_non_storm = True
break
assert found_non_storm, "storm should transition to rain or cloudy"
def test_snow_only_in_winter_autumn():
# Snow in winter
rng = random.Random(45)
found_snow = False
for _ in range(200):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="winter", rng=rng)
if new_state.condition == WeatherCondition.snow:
found_snow = True
break
assert found_snow, "snow should be possible in winter"
# Snow should be rare or impossible in summer
rng = random.Random(46)
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng)
# Should not produce snow in summer
assert new_state.condition != WeatherCondition.snow
def test_climate_temperate_default():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng = random.Random(47)
# Should work without climate parameter (defaults to temperate)
new_state = advance_weather(current, season="summer", rng=rng)
assert isinstance(new_state, WeatherState)
def test_climate_arid_favors_clear():
# Arid should heavily favor clear weather
rng = random.Random(48)
clear_count = 0
for _ in range(100):
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng, climate="arid")
if new_state.condition == WeatherCondition.clear:
clear_count += 1
# Arid should stay clear most of the time
assert clear_count > 70, f"arid should favor clear, got {clear_count}/100"
def test_climate_arid_no_snow():
# Arid should never produce snow
rng = random.Random(49)
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="winter", rng=rng, climate="arid")
assert new_state.condition != WeatherCondition.snow
def test_climate_arctic_favors_snow_fog_cloudy():
# Arctic should produce snow, fog, or cloudy frequently
rng = random.Random(50)
arctic_conditions = 0
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="winter", rng=rng, climate="arctic")
if new_state.condition in (
WeatherCondition.snow,
WeatherCondition.fog,
WeatherCondition.cloudy,
):
arctic_conditions += 1
# Arctic should heavily favor snow/fog/cloudy
assert arctic_conditions > 70, (
f"arctic should favor snow/fog/cloudy, got {arctic_conditions}/100"
)
def test_advance_weather_accepts_all_seasons():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng = random.Random(51)
for season in ["spring", "summer", "autumn", "winter"]:
new_state = advance_weather(current, season=season, rng=rng)
assert isinstance(new_state, WeatherState)