diff --git a/src/mudlib/weather.py b/src/mudlib/weather.py new file mode 100644 index 0000000..7771639 --- /dev/null +++ b/src/mudlib/weather.py @@ -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) diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..f75c034 --- /dev/null +++ b/tests/test_weather.py @@ -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)