diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index 76b0592..7051d56 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -4,16 +4,24 @@ from mudlib.commands import CommandDefinition, register from mudlib.commands.things import _format_thing_name from mudlib.effects import get_effects_at from mudlib.entity import Entity +from mudlib.gametime import get_game_day, get_game_hour from mudlib.player import Player from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.room import ( + render_atmosphere, render_entity_lines, render_exits, render_location, render_nearby, render_where, ) +from mudlib.seasons import get_season from mudlib.thing import Thing +from mudlib.weather import ( + WeatherCondition, + get_current_weather, + get_weather_description, +) from mudlib.zone import Zone # Viewport dimensions @@ -160,6 +168,25 @@ async def cmd_look(player: Player, args: str) -> None: # Where header 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 weather.condition == WeatherCondition.clear: + weather_desc = "" + else: + weather_desc = get_weather_description(weather) + + 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/render/room.py b/src/mudlib/render/room.py index 43b51fe..443be91 100644 --- a/src/mudlib/render/room.py +++ b/src/mudlib/render/room.py @@ -2,6 +2,8 @@ from __future__ import annotations +from mudlib.timeofday import get_sky_description, get_time_period + def render_where(zone_name: str) -> str: """Render the zone description line. @@ -137,3 +139,23 @@ def render_entity_lines(entities: list, viewer) -> str: lines.append(f"{entity.name} {msg}") return "\n".join(lines) + + +def render_atmosphere(hour: int, weather_desc: str, season: str) -> str: + """Render atmosphere line combining time-of-day and weather. + + Args: + hour: Game hour (0-23) + weather_desc: Weather description (empty string for clear weather) + season: Season name ("spring", "summer", "autumn", "winter") + + Returns: + Formatted atmosphere line + """ + sky = get_sky_description(hour) + period = get_time_period(hour) + + if weather_desc: + return f"{sky}. {weather_desc}. [{period}, {season}]" + else: + return f"{sky}. [{period}, {season}]" diff --git a/src/mudlib/weather.py b/src/mudlib/weather.py index 7771639..4002301 100644 --- a/src/mudlib/weather.py +++ b/src/mudlib/weather.py @@ -221,3 +221,43 @@ def advance_weather( new_intensity = rng.uniform(0.0, 1.0) return WeatherState(condition=new_condition, intensity=new_intensity) + + +# Global weather state +_current_weather: WeatherState | None = None + + +def init_weather( + condition: WeatherCondition = WeatherCondition.clear, intensity: float = 0.5 +) -> None: + """Initialize the global weather state. + + Args: + condition: Initial weather condition + intensity: Initial intensity (0.0 to 1.0) + """ + global _current_weather + _current_weather = WeatherState(condition=condition, intensity=intensity) + + +def get_current_weather() -> WeatherState: + """Get the current global weather state. + + Returns: + Current weather state (defaults to clear if not initialized) + """ + if _current_weather is None: + return WeatherState(condition=WeatherCondition.clear, intensity=0.5) + return _current_weather + + +def tick_weather(season: str = "summer", climate: str = "temperate") -> None: + """Advance weather by one step. Called once per game hour. + + Args: + season: Current season + climate: Climate profile to use + """ + global _current_weather + current = get_current_weather() + _current_weather = advance_weather(current, season=season, climate=climate) diff --git a/tests/test_gametime.py b/tests/test_gametime.py index 916c01b..681e866 100644 --- a/tests/test_gametime.py +++ b/tests/test_gametime.py @@ -7,6 +7,14 @@ import pytest from mudlib.gametime import GameTime, get_game_day, get_game_hour, init_game_time +@pytest.fixture(autouse=True) +def _reset_globals(): + yield + import mudlib.gametime + + mudlib.gametime._game_time = None + + def test_get_game_hour_at_epoch(): """Game hour should be 0 at epoch.""" epoch = time.time() diff --git a/tests/test_look_command.py b/tests/test_look_command.py index f87ad2b..4e4bbf0 100644 --- a/tests/test_look_command.py +++ b/tests/test_look_command.py @@ -1,5 +1,6 @@ """Tests for the look command with structured room display.""" +import time from unittest.mock import AsyncMock, MagicMock import pytest @@ -7,12 +8,24 @@ import pytest from mudlib.commands import look # noqa: F401 from mudlib.commands.look import cmd_look from mudlib.entity import Mob +from mudlib.gametime import init_game_time from mudlib.player import Player from mudlib.portal import Portal from mudlib.thing import Thing +from mudlib.weather import WeatherCondition, init_weather from mudlib.zone import Zone +@pytest.fixture(autouse=True) +def _reset_globals(): + yield + import mudlib.gametime + import mudlib.weather + + mudlib.gametime._game_time = None + mudlib.weather._current_weather = None + + @pytest.fixture def mock_writer(): writer = MagicMock() @@ -203,3 +216,124 @@ async def test_look_nowhere(mock_reader, mock_writer): output = get_output(player) assert "You are nowhere." in output + + +@pytest.mark.asyncio +async def test_look_includes_atmosphere_clear(): + """Look should include atmosphere line with clear weather.""" + # Initialize game time and weather + init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0) + init_weather(condition=WeatherCondition.clear, intensity=0.5) + + # Create player + 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, "") + output = get_output(player) + + # Should see atmosphere line with period and season + assert "[" in output and "]" in output + # Should not have weather description for clear weather + # (the format is: sky. [period, season]) + seasons = ["spring]", "summer]", "autumn]", "winter]"] + assert any(season in output for season in seasons) + + +@pytest.mark.asyncio +async def test_look_includes_atmosphere_with_rain(): + """Look should include atmosphere line with rain.""" + # Initialize game time and weather + init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0) + init_weather(condition=WeatherCondition.rain, intensity=0.5) + + # Create player + 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, "") + output = get_output(player) + + # Should see atmosphere line with rain description + assert "rain" in output.lower() + # Should see season tag + assert "[" in output and "]" in output + + +@pytest.mark.asyncio +async def test_look_atmosphere_between_where_and_viewport(): + """Atmosphere line should appear between Where header and viewport.""" + # Initialize game time and weather + init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0) + init_weather(condition=WeatherCondition.clear, intensity=0.5) + + # Create player + 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, "") + output = get_output(player) + + # Find positions of key sections + where_pos = output.find("Where:") + location_pos = output.find("Location:") + + # Atmosphere should be between Where and Location + # Look for a bracket tag (season indicator) + bracket_pos = output.find("[") + + assert where_pos < bracket_pos < location_pos diff --git a/tests/test_npc_schedule.py b/tests/test_npc_schedule.py index 671ae9f..771b125 100644 --- a/tests/test_npc_schedule.py +++ b/tests/test_npc_schedule.py @@ -26,6 +26,14 @@ def clear_mobs(): mob_templates.clear() +@pytest.fixture(autouse=True) +def _reset_globals(): + yield + import mudlib.gametime + + mudlib.gametime._game_time = None + + def test_schedule_entry_creation(): """ScheduleEntry can be created with required fields.""" entry = ScheduleEntry(hour=6, state="working") diff --git a/tests/test_render_room.py b/tests/test_render_room.py new file mode 100644 index 0000000..2bb5d02 --- /dev/null +++ b/tests/test_render_room.py @@ -0,0 +1,145 @@ +"""Tests for room rendering functions.""" + +from mudlib.render.room import ( + render_atmosphere, + render_entity_lines, + render_exits, + render_location, + render_nearby, + render_where, +) + + +class MockZone: + """Mock zone for testing.""" + + def __init__(self, width=100, height=100, passable_tiles=None): + self.width = width + self.height = height + self.description = "Test Zone" + self._passable = passable_tiles or set() + + def is_passable(self, x, y): + return (x, y) in self._passable + + +class MockEntity: + """Mock entity for testing.""" + + def __init__(self, name, posture="standing"): + self.name = name + self.posture = posture + + +def test_render_where(): + """render_where should format zone description.""" + assert render_where("The Overworld") == "Where: The Overworld" + + +def test_render_location_center(): + """render_location should show center quadrant.""" + zone = MockZone(width=90, height=90) + assert render_location(zone, 45, 45) == "Location: center 45, 45" + + +def test_render_location_northeast(): + """render_location should show northeast quadrant.""" + zone = MockZone(width=90, height=90) + assert render_location(zone, 70, 10) == "Location: northeast 70, 10" + + +def test_render_location_southwest(): + """render_location should show southwest quadrant.""" + zone = MockZone(width=90, height=90) + assert render_location(zone, 10, 70) == "Location: southwest 10, 70" + + +def test_render_nearby_empty(): + """render_nearby should return empty string when no entities.""" + assert render_nearby([], None) == "" + + +def test_render_nearby_with_entities(): + """render_nearby should show count and names.""" + entities = [MockEntity("Goku"), MockEntity("Vegeta")] + result = render_nearby(entities, None) + assert result == "Nearby: (2) Goku / Vegeta" + + +def test_render_exits_all_directions(): + """render_exits should list all passable directions.""" + zone = MockZone(passable_tiles={(5, 4), (5, 6), (6, 5), (4, 5)}) + assert render_exits(zone, 5, 5) == "Exits: north south east west" + + +def test_render_exits_partial(): + """render_exits should list only passable directions.""" + zone = MockZone(passable_tiles={(5, 4), (6, 5)}) + assert render_exits(zone, 5, 5) == "Exits: north east" + + +def test_render_exits_none(): + """render_exits should show 'Exits:' with no directions if trapped.""" + zone = MockZone(passable_tiles=set()) + assert render_exits(zone, 5, 5) == "Exits:" + + +def test_render_entity_lines_empty(): + """render_entity_lines should return empty string when no entities.""" + assert render_entity_lines([], None) == "" + + +def test_render_entity_lines_with_postures(): + """render_entity_lines should show entity names with postures.""" + entities = [ + MockEntity("Krillin", "resting"), + MockEntity("Piccolo", "standing"), + ] + result = render_entity_lines(entities, None) + assert "Krillin is resting here." in result + assert "Piccolo is standing here." in result + + +def test_render_atmosphere_clear_weather(): + """render_atmosphere should omit weather desc when clear.""" + result = render_atmosphere(12, "", "summer") + assert "[day, summer]" in result + # Should have sky description but no weather text + assert result.count(". [") == 1 # only one period before bracket + # Should not have double period before bracket + assert ". . [" not in result + + +def test_render_atmosphere_with_rain(): + """render_atmosphere should include weather desc when not clear.""" + result = render_atmosphere(12, "rain patters steadily", "spring") + assert "rain patters steadily" in result + assert "[day, spring]" in result + + +def test_render_atmosphere_dawn(): + """render_atmosphere should show dawn period.""" + result = render_atmosphere(5, "", "autumn") + assert "[dawn, autumn]" in result + + +def test_render_atmosphere_dusk(): + """render_atmosphere should show dusk period.""" + result = render_atmosphere(18, "", "winter") + assert "[dusk, winter]" in result + + +def test_render_atmosphere_night(): + """render_atmosphere should show night period.""" + result = render_atmosphere(22, "", "spring") + assert "[night, spring]" in result + + +def test_render_atmosphere_with_heavy_snow(): + """render_atmosphere should include heavy weather descriptions.""" + result = render_atmosphere(22, "heavy snow blankets everything", "winter") + assert "heavy snow blankets everything" in result + assert "[night, winter]" in result + # Should have format: sky. weather. [period, season] + parts = result.split(". ") + assert len(parts) == 3 # sky, weather, [period, season]