From 525b2fd812e7c6cc571d504719cf0b861e9f8033 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Fri, 13 Feb 2026 22:15:32 -0500 Subject: [PATCH] Refactor look command to use structured room display - Add Where: header with zone description - Add Location: line with quadrant and coordinates - Add Nearby: line showing entities in viewport (not on player's tile) - Add Exits: line showing available cardinal directions - Replace 'Here:' with individual entity lines showing posture - Replace 'Portals:' with individual 'You see {name}.' lines - Add look routing to examine command - Add comprehensive tests for new structured output - Update existing tests to match new output format --- src/mudlib/commands/look.py | 56 +++++++++- tests/test_commands.py | 6 +- tests/test_look_command.py | 205 +++++++++++++++++++++++++++++++++++ tests/test_portal_display.py | 3 +- 4 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 tests/test_look_command.py diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index c184a22..e0904f8 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -1,11 +1,19 @@ """Look command for viewing the world.""" from mudlib.commands import CommandDefinition, register +from mudlib.commands.examine import cmd_examine from mudlib.commands.things import _format_thing_name from mudlib.effects import get_effects_at from mudlib.entity import Entity from mudlib.player import Player from mudlib.render.ansi import RESET, colorize_terrain +from mudlib.render.room import ( + render_entity_lines, + render_exits, + render_location, + render_nearby, + render_where, +) from mudlib.thing import Thing from mudlib.zone import Zone @@ -19,8 +27,13 @@ async def cmd_look(player: Player, args: str) -> None: Args: player: The player executing the command - args: Command arguments (unused for now) + args: Command arguments (if provided, route to examine) """ + # If args provided, route to examine + if args.strip(): + await cmd_examine(player, args) + return + zone = player.location if zone is None or not isinstance(zone, Zone): player.writer.write("You are nowhere.\r\n") @@ -99,8 +112,38 @@ async def cmd_look(player: Player, args: str) -> None: line.append(colorize_terrain(tile, player.color_depth)) output_lines.append("".join(line)) + # Build structured output + output = [] + + # Where header + output.append(render_where(zone.description)) + + # Viewport + output.append("\r\n".join(output_lines)) + + # Location line + output.append(render_location(zone, player.x, player.y)) + + # Collect nearby entities (in viewport but not on player's tile) + nearby_entities = [ + obj + for obj in nearby + if isinstance(obj, Entity) + and obj is not player + and (not hasattr(obj, "alive") or obj.alive) + and not (obj.x == player.x and obj.y == player.y) + ] + if nearby_entities: + output.append(render_nearby(nearby_entities, player)) + + # Exits line + output.append(render_exits(zone, player.x, player.y)) + # Send to player - player.writer.write("\r\n".join(output_lines) + "\r\n") + player.writer.write("\r\n".join(output) + "\r\n") + + # Blank line before entity/item details + player.writer.write("\r\n") # Show entities (mobs, other players) at the player's position entities_here = [ @@ -111,8 +154,9 @@ async def cmd_look(player: Player, args: str) -> None: and (not hasattr(obj, "alive") or obj.alive) ] if entities_here: - names = ", ".join(e.name for e in entities_here) - player.writer.write(f"Here: {names}\r\n") + entity_lines = render_entity_lines(entities_here, player) + # Convert \n to \r\n for telnet + player.writer.write(entity_lines.replace("\n", "\r\n") + "\r\n") # Show items on the ground at player's position from mudlib.portal import Portal @@ -130,8 +174,8 @@ async def cmd_look(player: Player, args: str) -> None: player.writer.write(f"On the ground: {names}\r\n") if portals: - names = ", ".join(p.name for p in portals) - player.writer.write(f"Portals: {names}\r\n") + for portal in portals: + player.writer.write(f"You see {portal.name}.\r\n") await player.writer.drain() diff --git a/tests/test_commands.py b/tests/test_commands.py index a52d034..7ec14c0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -495,9 +495,9 @@ async def test_look_shows_entities_here(player, test_zone): await look.cmd_look(player, "") output = "".join(c[0][0] for c in player.writer.write.call_args_list) - assert "Here: " in output - assert "goblin" in output - assert "Ally" in output + # New format shows individual entity lines, not "Here: " + assert "goblin is standing here." in output + assert "Ally is standing here." in output @pytest.mark.asyncio diff --git a/tests/test_look_command.py b/tests/test_look_command.py new file mode 100644 index 0000000..f87ad2b --- /dev/null +++ b/tests/test_look_command.py @@ -0,0 +1,205 @@ +"""Tests for the look command with structured room display.""" + +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.player import Player +from mudlib.portal import Portal +from mudlib.thing import Thing +from mudlib.zone import Zone + + +@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 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 diff --git a/tests/test_portal_display.py b/tests/test_portal_display.py index 47b054a..ff271bf 100644 --- a/tests/test_portal_display.py +++ b/tests/test_portal_display.py @@ -64,7 +64,8 @@ async def test_look_shows_portal_at_position(player, test_zone, mock_writer): await cmd_look(player, "") output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) - assert "portal" in output.lower() and "shimmering doorway" in output.lower() + # New format: "You see {portal.name}." + assert "you see shimmering doorway." in output.lower() @pytest.mark.asyncio