diff --git a/src/mudlib/commands/examine.py b/src/mudlib/commands/examine.py index 6fc5f2e..4e1e703 100644 --- a/src/mudlib/commands/examine.py +++ b/src/mudlib/commands/examine.py @@ -3,50 +3,55 @@ from mudlib.commands import CommandDefinition, register from mudlib.entity import Entity from mudlib.player import Player +from mudlib.targeting import find_entity_on_tile, find_in_inventory, find_thing_on_tile from mudlib.thing import Thing from mudlib.zone import Zone -def _find_object_in_inventory(name: str, player: Player) -> Thing | Entity | None: - """Find an object in player inventory by name or alias.""" - name_lower = name.lower() - for obj in player.contents: - # Only examine Things and Entities - if not isinstance(obj, (Thing, Entity)): - continue +async def examine_target( + player: Player, + target_name: str, + *, + prefer_inventory: bool = True, +) -> None: + """Resolve and describe a target for examine/look style commands.""" + zone = player.location if isinstance(player.location, Zone) else None - # Match by name - if obj.name.lower() == name_lower: - return obj + # look should prioritize entities/ground; direct examine keeps + # historical inventory-first behavior. + ordered_finders = [] + if prefer_inventory: + ordered_finders.append(lambda: find_in_inventory(target_name, player)) + ordered_finders.append(lambda: find_entity_on_tile(target_name, player)) + if zone is not None: + ordered_finders.append( + lambda: find_thing_on_tile(target_name, zone, player.x, player.y) + ) + if not prefer_inventory: + ordered_finders.append(lambda: find_in_inventory(target_name, player)) - # Match by alias (Things have aliases) - if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases): - return obj + found: Thing | Entity | None = None + for finder in ordered_finders: + found = finder() + if found is not None: + break - return None + if found is None: + await player.send("You don't see that here.\r\n") + return + if isinstance(found, Entity): + if getattr(found, "description", ""): + await player.send(f"{found.description}\r\n") + else: + await player.send(f"{found.name} is {found.posture}.\r\n") + return -def _find_object_at_position(name: str, player: Player) -> Thing | Entity | None: - """Find an object on the ground at player position by name or alias.""" - zone = player.location - if zone is None or not isinstance(zone, Zone): - return None - - name_lower = name.lower() - for obj in zone.contents_at(player.x, player.y): - # Only examine Things and Entities - if not isinstance(obj, (Thing, Entity)): - continue - - # Match by name - if obj.name.lower() == name_lower: - return obj - - # Match by alias (Things have aliases) - if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases): - return obj - - return None + desc = getattr(found, "description", "") + if desc: + await player.send(f"{desc}\r\n") + else: + await player.send("You see nothing special.\r\n") async def cmd_examine(player: Player, args: str) -> None: @@ -55,26 +60,7 @@ async def cmd_examine(player: Player, args: str) -> None: await player.send("Examine what?\r\n") return - target_name = args.strip() - - # Search inventory first - found = _find_object_in_inventory(target_name, player) - - # Then search ground - if not found: - found = _find_object_at_position(target_name, player) - - # Not found anywhere - if not found: - await player.send("You don't see that here.\r\n") - return - - # Show description (both Thing and Entity have description) - desc = getattr(found, "description", "") - if desc: - await player.send(f"{desc}\r\n") - else: - await player.send("You see nothing special.\r\n") + await examine_target(player, args.strip(), prefer_inventory=True) register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*")) diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index 9bb344b..a096a89 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -37,52 +37,11 @@ async def cmd_look(player: Player, args: str) -> None: player: The player executing the command args: Command arguments (if provided, use targeting to resolve) """ - # If args provided, use targeting to resolve + # If args provided, route directly to examine behavior. if args.strip(): - from mudlib.targeting import ( - find_entity_on_tile, - find_in_inventory, - find_thing_on_tile, - ) + from mudlib.commands.examine import examine_target - target_name = args.strip() - - # First try to find an entity on the tile - entity = find_entity_on_tile(target_name, player) - if entity: - # Show entity info (name and posture) - if hasattr(entity, "description") and entity.description: - await player.send(f"{entity.description}\r\n") - else: - await player.send(f"{entity.name} is {entity.posture}.\r\n") - return - - # Then try to find a thing on the ground - zone = player.location - if zone is not None and isinstance(zone, Zone): - thing = find_thing_on_tile(target_name, zone, player.x, player.y) - if thing: - # Show thing description - desc = getattr(thing, "description", "") - if desc: - await player.send(f"{desc}\r\n") - else: - await player.send("You see nothing special.\r\n") - return - - # Finally try inventory - thing = find_in_inventory(target_name, player) - if thing: - # Show thing description - desc = getattr(thing, "description", "") - if desc: - await player.send(f"{desc}\r\n") - else: - await player.send("You see nothing special.\r\n") - return - - # Nothing found - await player.send("You don't see that here.\r\n") + await examine_target(player, args.strip(), prefer_inventory=False) return zone = player.location @@ -220,7 +179,7 @@ async def cmd_look(player: Player, args: str) -> None: output.append(render_nearby(nearby_entities, player)) # Exits line - output.append(render_exits(zone, player.x, player.y)) + output.append(render_exits(zone, player.x, player.y, player)) # Send to player player.writer.write("\r\n".join(output) + "\r\n") diff --git a/src/mudlib/render/room.py b/src/mudlib/render/room.py index 443be91..db24a7f 100644 --- a/src/mudlib/render/room.py +++ b/src/mudlib/render/room.py @@ -80,7 +80,7 @@ def render_nearby(entities: list, viewer) -> str: return f"Nearby: ({count}) {names}" -def render_exits(zone, x: int, y: int) -> str: +def render_exits(zone, x: int, y: int, viewer=None) -> str: """Render available exits from current position. Args: @@ -94,7 +94,6 @@ def render_exits(zone, x: int, y: int) -> str: exits = [] # Check cardinal directions - # NOTE: up/down exits deferred until z-axis movement is implemented if zone.is_passable(x, y - 1): # north (y decreases going up) exits.append("north") if zone.is_passable(x, y + 1): # south @@ -103,6 +102,11 @@ def render_exits(zone, x: int, y: int) -> str: exits.append("east") if zone.is_passable(x - 1, y): # west exits.append("west") + # Vertical exit is based on current altitude state. + if viewer is not None and getattr(viewer, "flying", False): + exits.append("down") + elif viewer is not None: + exits.append("up") if exits: return f"Exits: {' '.join(exits)}" @@ -111,6 +115,7 @@ def render_exits(zone, x: int, y: int) -> str: _POSTURE_MESSAGES = { "standing": "is standing here.", + "sleeping": "is sleeping here.", "resting": "is resting here.", "flying": "is flying above.", "fighting": "is fighting here.", diff --git a/tests/test_look_command.py b/tests/test_look_command.py index e7149d5..daf38d7 100644 --- a/tests/test_look_command.py +++ b/tests/test_look_command.py @@ -98,7 +98,7 @@ 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 + assert "Exits: north south east west up" in output @pytest.mark.asyncio @@ -169,14 +169,36 @@ async def test_look_shows_portals(player, test_zone): @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) + called = {} + + async def fake_examine(p, args, *, prefer_inventory=True): + called["args"] = args + called["prefer_inventory"] = prefer_inventory + await p.send("examined\r\n") + + import mudlib.commands.examine + + original = mudlib.commands.examine.examine_target + mudlib.commands.examine.examine_target = fake_examine # type: ignore[invalid-assignment] + try: + await cmd_look(player, "sword") + finally: + mudlib.commands.examine.examine_target = original - await cmd_look(player, "sword") output = get_output(player) + assert called["args"] == "sword" + assert called["prefer_inventory"] is False + assert "examined" in output - # Should see the item's description (examine behavior) - assert "A sharp blade." in output + +@pytest.mark.asyncio +async def test_look_flying_shows_down_exit(player): + """Flying players should see down as a vertical exit.""" + player.flying = True + + await cmd_look(player, "") + output = get_output(player) + assert "Exits: north south east west down" in output @pytest.mark.asyncio diff --git a/tests/test_room_render.py b/tests/test_room_render.py index cf26eed..c846bfd 100644 --- a/tests/test_room_render.py +++ b/tests/test_room_render.py @@ -126,6 +126,30 @@ def test_render_exits_partial(): assert result == "Exits: north east" +def test_render_exits_includes_up_for_grounded_viewer(): + """Grounded viewer should see up as a vertical exit.""" + terrain = [["." for _ in range(3)] for _ in range(3)] + zone = Zone(name="test", width=3, height=3, terrain=terrain, impassable={"^"}) + + class Viewer: + flying = False + + result = render_exits(zone, 1, 1, Viewer()) + assert result == "Exits: north south east west up" + + +def test_render_exits_includes_down_for_flying_viewer(): + """Flying viewer should see down as a vertical exit.""" + terrain = [["." for _ in range(3)] for _ in range(3)] + zone = Zone(name="test", width=3, height=3, terrain=terrain, impassable={"^"}) + + class Viewer: + flying = True + + result = render_exits(zone, 1, 1, Viewer()) + assert result == "Exits: north south east west down" + + def test_render_entity_lines_empty(): """render_entity_lines returns empty string when no entities.""" assert render_entity_lines([], None) == "" @@ -169,3 +193,7 @@ def test_render_entity_lines_postures(): # Unconscious (stamina <= 0) mob_exhausted = Mob(name="Goku", x=10, y=10, stamina=0) assert render_entity_lines([mob_exhausted], None) == "Goku is unconscious." + + # Sleeping + mob_sleeping = Mob(name="Goku", x=10, y=10, sleeping=True, resting=True) + assert render_entity_lines([mob_sleeping], None) == "Goku is sleeping here."