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