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.
419 lines
12 KiB
Python
419 lines
12 KiB
Python
"""Tests for the look command with structured room display."""
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
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()
|
|
writer.write = MagicMock()
|
|
writer.drain = AsyncMock()
|
|
return writer
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_reader():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_zone():
|
|
"""Create a test zone with simple terrain."""
|
|
# 50x50 zone with grass everywhere
|
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
|
# Add some mountains
|
|
terrain[10][10] = "^"
|
|
terrain[10][11] = "^"
|
|
return Zone(
|
|
name="test_zone",
|
|
description="The Test Zone",
|
|
width=50,
|
|
height=50,
|
|
terrain=terrain,
|
|
impassable={"^", "~"},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def player(mock_reader, mock_writer, test_zone):
|
|
"""Create a test player in the test zone."""
|
|
p = Player(
|
|
name="TestPlayer",
|
|
x=25,
|
|
y=25,
|
|
reader=mock_reader,
|
|
writer=mock_writer,
|
|
)
|
|
p.location = test_zone
|
|
test_zone._contents.append(p)
|
|
return p
|
|
|
|
|
|
def get_output(player):
|
|
"""Get all output written to player's writer."""
|
|
return "".join([call[0][0] for call in player.writer.write.call_args_list])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_includes_where_header(player):
|
|
"""Look output should include 'Where: {zone description}' line."""
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
assert "Where: The Test Zone" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_includes_location_line(player):
|
|
"""Look output should include 'Location:' line with quadrant and coords."""
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
assert "Location: center 25, 25" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_includes_exits_line(player):
|
|
"""Look output should include 'Exits:' line."""
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
assert "Exits: north south east west" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_shows_nearby_entities(player, test_zone):
|
|
"""Look should show nearby entities (not on player's tile) in viewport."""
|
|
# Add some mobs in viewport range (location param auto-adds to zone)
|
|
Mob(name="Goku", x=26, y=25, location=test_zone)
|
|
Mob(name="Vegeta", x=27, y=26, location=test_zone)
|
|
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
|
|
# Should see nearby line with count
|
|
assert "Nearby: (2)" in output
|
|
assert "Goku" in output
|
|
assert "Vegeta" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_shows_entities_here(player, test_zone):
|
|
"""Look should show entities on player's tile with posture."""
|
|
# Add entities on player's tile (location param auto-adds to zone)
|
|
Mob(name="Krillin", x=25, y=25, resting=True, location=test_zone)
|
|
Mob(name="Piccolo", x=25, y=25, location=test_zone)
|
|
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
|
|
# Should see individual lines with postures
|
|
assert "Krillin is resting here." in output
|
|
assert "Piccolo is standing here." in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_shows_ground_items(player, test_zone):
|
|
"""Look should show items on the ground."""
|
|
# Add an item on player's tile (location param auto-adds to zone)
|
|
Thing(name="rusty sword", x=25, y=25, location=test_zone)
|
|
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
|
|
# Should see ground items
|
|
assert "rusty sword" in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_shows_portals(player, test_zone):
|
|
"""Look should show portals on player's tile."""
|
|
# Add a portal on player's tile (location param auto-adds to zone)
|
|
Portal(
|
|
name="wide dirt path",
|
|
x=25,
|
|
y=25,
|
|
location=test_zone,
|
|
target_zone="other",
|
|
target_x=0,
|
|
target_y=0,
|
|
)
|
|
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
|
|
# Should see portal with new format
|
|
assert "You see wide dirt path." in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_with_args_routes_to_examine(player, test_zone):
|
|
"""look <thing> should route to examine command logic."""
|
|
# Add an item to examine (location param auto-adds to zone)
|
|
Thing(name="sword", x=25, y=25, description="A sharp blade.", location=test_zone)
|
|
|
|
await cmd_look(player, "sword")
|
|
output = get_output(player)
|
|
|
|
# Should see the item's description (examine behavior)
|
|
assert "A sharp blade." in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_structure_order(player, test_zone):
|
|
"""Look output should have sections in correct order."""
|
|
# Add entities and items (location param auto-adds to zone)
|
|
Mob(name="Goku", x=25, y=25, location=test_zone)
|
|
|
|
await cmd_look(player, "")
|
|
output = get_output(player)
|
|
|
|
# Find positions of key sections
|
|
where_pos = output.find("Where:")
|
|
location_pos = output.find("Location:")
|
|
exits_pos = output.find("Exits:")
|
|
|
|
# Verify order: Where comes first, then viewport (with terrain),
|
|
# then Location, then Exits
|
|
assert where_pos < location_pos
|
|
assert location_pos < exits_pos
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_look_nowhere(mock_reader, mock_writer):
|
|
"""Look should show 'You are nowhere.' when player has no location."""
|
|
# Create player without a location
|
|
player = Player(
|
|
name="NowherePlayer",
|
|
x=0,
|
|
y=0,
|
|
reader=mock_reader,
|
|
writer=mock_writer,
|
|
)
|
|
player.location = None
|
|
|
|
await cmd_look(player, "")
|
|
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
|
|
|
|
|
|
@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
|