From 4c4d947ce2a62b252ef1b1eba9f9fc5ee9331120 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 16:05:05 -0500 Subject: [PATCH] Add visibility system for time and weather effects Night, fog, and storms now reduce viewport size. Night reduces by 6 width and 2 height (21x11 -> 15x9). Thick fog reduces by 8 width and 4 height. Storm reduces by 4 width and 2 height. Effects stack but clamp to minimum 7x5. Dawn and dusk subtly dim by 2 width. --- src/mudlib/commands/look.py | 48 ++++++++----- src/mudlib/visibility.py | 54 +++++++++++++++ tests/test_look_command.py | 80 +++++++++++++++++++++ tests/test_visibility.py | 135 ++++++++++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 src/mudlib/visibility.py create mode 100644 tests/test_visibility.py diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index 7051d56..9bb344b 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -17,6 +17,7 @@ from mudlib.render.room import ( ) from mudlib.seasons import get_season from mudlib.thing import Thing +from mudlib.visibility import get_visibility from mudlib.weather import ( WeatherCondition, get_current_weather, @@ -90,16 +91,38 @@ async def cmd_look(player: Player, args: str) -> None: await player.writer.drain() return + # Compute environment state once + hour = None + day = None + weather = None + season = None + try: + hour = get_game_hour() + day = get_game_day() + weather = get_current_weather() + season = get_season(day) + except RuntimeError: + pass + + # Use hour/weather for visibility + if hour is not None and weather is not None: + effective_width, effective_height = get_visibility( + hour, weather, VIEWPORT_WIDTH, VIEWPORT_HEIGHT + ) + else: + effective_width = VIEWPORT_WIDTH + effective_height = VIEWPORT_HEIGHT + # Get the viewport from the zone - viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + viewport = zone.get_viewport(player.x, player.y, effective_width, effective_height) # Calculate center position - center_x = VIEWPORT_WIDTH // 2 - center_y = VIEWPORT_HEIGHT // 2 + center_x = effective_width // 2 + center_y = effective_height // 2 # Get nearby entities (players and mobs) from the zone # Viewport half-diagonal distance for range - viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2 + viewport_range = effective_width // 2 + effective_height // 2 nearby = zone.contents_near(player.x, player.y, viewport_range) # Build a list of (relative_x, relative_y) for other entities @@ -129,13 +152,13 @@ async def cmd_look(player: Player, args: str) -> None: rel_y = dy + center_y # Check if within viewport bounds - if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: + if 0 <= rel_x < effective_width and 0 <= rel_y < effective_height: entity_positions.append((rel_x, rel_y)) # Build the output with ANSI coloring # priority: player @ > other players * > mobs * > effects > terrain - half_width = VIEWPORT_WIDTH // 2 - half_height = VIEWPORT_HEIGHT // 2 + half_width = effective_width // 2 + half_height = effective_height // 2 output_lines = [] for y, row in enumerate(viewport): @@ -169,13 +192,7 @@ async def cmd_look(player: Player, args: str) -> None: output.append(render_where(zone.description)) # Atmosphere line (between Where and viewport) - try: - hour = get_game_hour() - day = get_game_day() - weather = get_current_weather() - season = get_season(day) - - # Get weather description (empty string for clear) + if hour is not None and weather is not None and season is not None: if weather.condition == WeatherCondition.clear: weather_desc = "" else: @@ -183,9 +200,6 @@ async def cmd_look(player: Player, args: str) -> None: atmosphere = render_atmosphere(hour, weather_desc, season) output.append(atmosphere) - except RuntimeError: - # Game time not initialized, skip atmosphere - pass # Viewport output.append("\r\n".join(output_lines)) diff --git a/src/mudlib/visibility.py b/src/mudlib/visibility.py new file mode 100644 index 0000000..a3decc9 --- /dev/null +++ b/src/mudlib/visibility.py @@ -0,0 +1,54 @@ +"""Visibility calculations based on time and weather.""" + +from mudlib.weather import WeatherCondition, WeatherState + + +def get_visibility( + hour: int, + weather: WeatherState, + base_width: int = 21, + base_height: int = 11, +) -> tuple[int, int]: + """Calculate effective viewport dimensions. + + Night, fog, and storms reduce visibility. Multiple effects stack + but never reduce below minimum (7x5). + + Returns: + Tuple of (effective_width, effective_height) + """ + width = base_width + height = base_height + + # Time-based reductions + if hour >= 20 or hour <= 4: + # Night (hours 20-4) + width -= 6 + height -= 2 + elif 5 <= hour <= 6: + # Dawn (hours 5-6) + width -= 2 + elif 18 <= hour <= 19: + # Dusk (hours 18-19) + width -= 2 + + # Weather-based reductions + if weather.condition == WeatherCondition.fog: + if weather.intensity >= 0.7: + # Thick fog + width -= 8 + height -= 4 + elif weather.intensity >= 0.4: + # Moderate fog + width -= 4 + height -= 2 + elif weather.condition == WeatherCondition.storm: + # Storm + width -= 4 + height -= 2 + + # Clamp to minimum + width = max(7, width) + height = max(5, height) + + return width, height diff --git a/tests/test_look_command.py b/tests/test_look_command.py index 4e4bbf0..e7149d5 100644 --- a/tests/test_look_command.py +++ b/tests/test_look_command.py @@ -337,3 +337,83 @@ async def test_look_atmosphere_between_where_and_viewport(): bracket_pos = output.find("[") assert where_pos < bracket_pos < location_pos + + +@pytest.mark.asyncio +async def test_look_night_reduces_viewport(): + """Look at night should show smaller viewport than day.""" + + # Set time to noon (hour 12) - epoch was 12 hours ago + # elapsed_game_hours = (time.time() - epoch) / 60 / 1.0 = 12 + # so epoch = time.time() - 12 * 60 + init_game_time(epoch=time.time() - 12 * 60, real_minutes_per_game_hour=1.0) + init_weather(condition=WeatherCondition.clear, intensity=0.5) + + mock_reader = MagicMock() + mock_writer = MagicMock() + mock_writer.write = MagicMock() + mock_writer.drain = AsyncMock() + + terrain = [["." for _ in range(50)] for _ in range(50)] + zone = Zone( + name="test_zone", + description="The Test Zone", + width=50, + height=50, + terrain=terrain, + impassable={"^", "~"}, + ) + + player = Player( + name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer + ) + player.location = zone + zone._contents.append(player) + + await cmd_look(player, "") + day_output = get_output(player) + + # Find viewport section (between atmosphere and Location:) + day_lines = day_output.split("\r\n") + # Viewport is after atmosphere (containing '[') and before Location: + viewport_start_idx = None + viewport_end_idx = None + for i, line in enumerate(day_lines): + if "[" in line and viewport_start_idx is None: + viewport_start_idx = i + 1 + if line.startswith("Location:"): + viewport_end_idx = i + break + + assert viewport_start_idx is not None + assert viewport_end_idx is not None + day_viewport_height = viewport_end_idx - viewport_start_idx + + # Reset writer + mock_writer.reset_mock() + + # Set time to night (hour 22) - epoch was 22 hours ago + init_game_time(epoch=time.time() - 22 * 60, real_minutes_per_game_hour=1.0) + + await cmd_look(player, "") + night_output = get_output(player) + + # Find viewport section + night_lines = night_output.split("\r\n") + viewport_start_idx = None + viewport_end_idx = None + for i, line in enumerate(night_lines): + if "[" in line and viewport_start_idx is None: + viewport_start_idx = i + 1 + if line.startswith("Location:"): + viewport_end_idx = i + break + + assert viewport_start_idx is not None + assert viewport_end_idx is not None + night_viewport_height = viewport_end_idx - viewport_start_idx + + # Night should have fewer viewport lines (9 vs 11) + assert night_viewport_height < day_viewport_height + assert day_viewport_height == 11 # full viewport at noon + assert night_viewport_height == 9 # reduced viewport at night diff --git a/tests/test_visibility.py b/tests/test_visibility.py new file mode 100644 index 0000000..2e92057 --- /dev/null +++ b/tests/test_visibility.py @@ -0,0 +1,135 @@ +"""Tests for visibility calculations.""" + +from mudlib.visibility import get_visibility +from mudlib.weather import WeatherCondition, WeatherState + + +def test_clear_day_full_visibility(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 21 + assert height == 11 + + +def test_night_reduces_visibility(): + hour = 22 # night (20-4) + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 15 # 21 - 6 + assert height == 9 # 11 - 2 + + +def test_thick_fog_during_day(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.fog, intensity=0.8) + width, height = get_visibility(hour, weather) + assert width == 13 # 21 - 8 + assert height == 7 # 11 - 4 + + +def test_night_plus_thick_fog_clamps_to_minimum(): + hour = 22 # night + weather = WeatherState(condition=WeatherCondition.fog, intensity=0.9) + width, height = get_visibility(hour, weather) + # Night: -6 width, -2 height (21x11 -> 15x9) + # Thick fog: -8 width, -4 height (15x9 -> 7x5) + # Should clamp to minimum 7x5 + assert width == 7 + assert height == 5 + + +def test_storm_reduces_visibility(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.storm, intensity=0.7) + width, height = get_visibility(hour, weather) + assert width == 17 # 21 - 4 + assert height == 9 # 11 - 2 + + +def test_dawn_subtle_dimming(): + hour = 5 # dawn (5-6) + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 19 # 21 - 2 + assert height == 11 # 11 - 0 + + +def test_dusk_subtle_dimming(): + hour = 18 # dusk (18-19) + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 19 # 21 - 2 + assert height == 11 # 11 - 0 + + +def test_moderate_fog(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.fog, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 17 # 21 - 4 + assert height == 9 # 11 - 2 + + +def test_light_fog_no_reduction(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.fog, intensity=0.3) + width, height = get_visibility(hour, weather) + assert width == 21 # no reduction for light fog + assert height == 11 + + +def test_cloudy_no_visibility_reduction(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.cloudy, intensity=0.7) + width, height = get_visibility(hour, weather) + assert width == 21 + assert height == 11 + + +def test_rain_no_visibility_reduction(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.rain, intensity=0.7) + width, height = get_visibility(hour, weather) + assert width == 21 + assert height == 11 + + +def test_snow_no_visibility_reduction(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.snow, intensity=0.7) + width, height = get_visibility(hour, weather) + assert width == 21 + assert height == 11 + + +def test_custom_base_dimensions(): + hour = 12 # noon + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather, base_width=31, base_height=21) + assert width == 31 + assert height == 21 + + +def test_night_custom_base(): + hour = 22 # night + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather, base_width=31, base_height=21) + assert width == 25 # 31 - 6 + assert height == 19 # 21 - 2 + + +def test_midnight_is_night(): + hour = 0 # midnight + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 15 # reduced for night + assert height == 9 + + +def test_early_morning_is_night(): + hour = 3 # early morning + weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5) + width, height = get_visibility(hour, weather) + assert width == 15 # reduced for night + assert height == 9