Refactor look/examine targeting and improve room rendering

This commit is contained in:
Jared Miller 2026-02-15 12:40:16 -05:00
parent 8424404d27
commit f40ee68f9a
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 108 additions and 108 deletions

View file

@ -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 <thing> 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="*"))

View file

@ -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")

View file

@ -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.",

View file

@ -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 <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)
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")
output = get_output(player)
finally:
mudlib.commands.examine.examine_target = original
# Should see the item's description (examine behavior)
assert "A sharp blade." in output
output = get_output(player)
assert called["args"] == "sword"
assert called["prefer_inventory"] is False
assert "examined" 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

View file

@ -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."