Add atmosphere rendering function
This commit is contained in:
parent
d91b180824
commit
9594e23011
7 changed files with 384 additions and 0 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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}]"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
145
tests/test_render_room.py
Normal file
145
tests/test_render_room.py
Normal 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]
|
||||
Loading…
Reference in a new issue