Add atmosphere rendering function

This commit is contained in:
Jared Miller 2026-02-14 15:56:45 -05:00
parent d91b180824
commit 9594e23011
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
7 changed files with 384 additions and 0 deletions

View file

@ -4,16 +4,24 @@ from mudlib.commands import CommandDefinition, register
from mudlib.commands.things import _format_thing_name from mudlib.commands.things import _format_thing_name
from mudlib.effects import get_effects_at from mudlib.effects import get_effects_at
from mudlib.entity import Entity from mudlib.entity import Entity
from mudlib.gametime import get_game_day, get_game_hour
from mudlib.player import Player from mudlib.player import Player
from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.ansi import RESET, colorize_terrain
from mudlib.render.room import ( from mudlib.render.room import (
render_atmosphere,
render_entity_lines, render_entity_lines,
render_exits, render_exits,
render_location, render_location,
render_nearby, render_nearby,
render_where, render_where,
) )
from mudlib.seasons import get_season
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.weather import (
WeatherCondition,
get_current_weather,
get_weather_description,
)
from mudlib.zone import Zone from mudlib.zone import Zone
# Viewport dimensions # Viewport dimensions
@ -160,6 +168,25 @@ async def cmd_look(player: Player, args: str) -> None:
# Where header # Where header
output.append(render_where(zone.description)) 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 # Viewport
output.append("\r\n".join(output_lines)) output.append("\r\n".join(output_lines))

View file

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from mudlib.timeofday import get_sky_description, get_time_period
def render_where(zone_name: str) -> str: def render_where(zone_name: str) -> str:
"""Render the zone description line. """Render the zone description line.
@ -137,3 +139,23 @@ def render_entity_lines(entities: list, viewer) -> str:
lines.append(f"{entity.name} {msg}") lines.append(f"{entity.name} {msg}")
return "\n".join(lines) 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}]"

View file

@ -221,3 +221,43 @@ def advance_weather(
new_intensity = rng.uniform(0.0, 1.0) new_intensity = rng.uniform(0.0, 1.0)
return WeatherState(condition=new_condition, intensity=new_intensity) 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)

View file

@ -7,6 +7,14 @@ import pytest
from mudlib.gametime import GameTime, get_game_day, get_game_hour, init_game_time 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(): def test_get_game_hour_at_epoch():
"""Game hour should be 0 at epoch.""" """Game hour should be 0 at epoch."""
epoch = time.time() epoch = time.time()

View file

@ -1,5 +1,6 @@
"""Tests for the look command with structured room display.""" """Tests for the look command with structured room display."""
import time
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -7,12 +8,24 @@ import pytest
from mudlib.commands import look # noqa: F401 from mudlib.commands import look # noqa: F401
from mudlib.commands.look import cmd_look from mudlib.commands.look import cmd_look
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.gametime import init_game_time
from mudlib.player import Player from mudlib.player import Player
from mudlib.portal import Portal from mudlib.portal import Portal
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.weather import WeatherCondition, init_weather
from mudlib.zone import Zone 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 @pytest.fixture
def mock_writer(): def mock_writer():
writer = MagicMock() writer = MagicMock()
@ -203,3 +216,124 @@ async def test_look_nowhere(mock_reader, mock_writer):
output = get_output(player) output = get_output(player)
assert "You are nowhere." in output 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

View file

@ -26,6 +26,14 @@ def clear_mobs():
mob_templates.clear() mob_templates.clear()
@pytest.fixture(autouse=True)
def _reset_globals():
yield
import mudlib.gametime
mudlib.gametime._game_time = None
def test_schedule_entry_creation(): def test_schedule_entry_creation():
"""ScheduleEntry can be created with required fields.""" """ScheduleEntry can be created with required fields."""
entry = ScheduleEntry(hour=6, state="working") entry = ScheduleEntry(hour=6, state="working")

145
tests/test_render_room.py Normal file
View file

@ -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]