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:
parent
32f52ef704
commit
4b0a7315c1
2 changed files with 431 additions and 0 deletions
223
src/mudlib/weather.py
Normal file
223
src/mudlib/weather.py
Normal 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
208
tests/test_weather.py
Normal 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)
|
||||||
Loading…
Reference in a new issue