Refactor look/examine targeting and improve room rendering
This commit is contained in:
parent
8424404d27
commit
f40ee68f9a
5 changed files with 108 additions and 108 deletions
|
|
@ -3,50 +3,55 @@
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
from mudlib.player import Player
|
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.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
def _find_object_in_inventory(name: str, player: Player) -> Thing | Entity | None:
|
async def examine_target(
|
||||||
"""Find an object in player inventory by name or alias."""
|
player: Player,
|
||||||
name_lower = name.lower()
|
target_name: str,
|
||||||
for obj in player.contents:
|
*,
|
||||||
# Only examine Things and Entities
|
prefer_inventory: bool = True,
|
||||||
if not isinstance(obj, (Thing, Entity)):
|
) -> None:
|
||||||
continue
|
"""Resolve and describe a target for examine/look style commands."""
|
||||||
|
zone = player.location if isinstance(player.location, Zone) else None
|
||||||
|
|
||||||
# Match by name
|
# look <thing> should prioritize entities/ground; direct examine keeps
|
||||||
if obj.name.lower() == name_lower:
|
# historical inventory-first behavior.
|
||||||
return obj
|
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)
|
found: Thing | Entity | None = None
|
||||||
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
|
for finder in ordered_finders:
|
||||||
return obj
|
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:
|
desc = getattr(found, "description", "")
|
||||||
"""Find an object on the ground at player position by name or alias."""
|
if desc:
|
||||||
zone = player.location
|
await player.send(f"{desc}\r\n")
|
||||||
if zone is None or not isinstance(zone, Zone):
|
else:
|
||||||
return None
|
await player.send("You see nothing special.\r\n")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_examine(player: Player, args: str) -> None:
|
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")
|
await player.send("Examine what?\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
target_name = args.strip()
|
await examine_target(player, args.strip(), prefer_inventory=True)
|
||||||
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
|
|
||||||
register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*"))
|
register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*"))
|
||||||
|
|
|
||||||
|
|
@ -37,52 +37,11 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
player: The player executing the command
|
player: The player executing the command
|
||||||
args: Command arguments (if provided, use targeting to resolve)
|
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():
|
if args.strip():
|
||||||
from mudlib.targeting import (
|
from mudlib.commands.examine import examine_target
|
||||||
find_entity_on_tile,
|
|
||||||
find_in_inventory,
|
|
||||||
find_thing_on_tile,
|
|
||||||
)
|
|
||||||
|
|
||||||
target_name = args.strip()
|
await examine_target(player, args.strip(), prefer_inventory=False)
|
||||||
|
|
||||||
# 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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
zone = player.location
|
zone = player.location
|
||||||
|
|
@ -220,7 +179,7 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
output.append(render_nearby(nearby_entities, player))
|
output.append(render_nearby(nearby_entities, player))
|
||||||
|
|
||||||
# Exits line
|
# Exits line
|
||||||
output.append(render_exits(zone, player.x, player.y))
|
output.append(render_exits(zone, player.x, player.y, player))
|
||||||
|
|
||||||
# Send to player
|
# Send to player
|
||||||
player.writer.write("\r\n".join(output) + "\r\n")
|
player.writer.write("\r\n".join(output) + "\r\n")
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ def render_nearby(entities: list, viewer) -> str:
|
||||||
return f"Nearby: ({count}) {names}"
|
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.
|
"""Render available exits from current position.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -94,7 +94,6 @@ def render_exits(zone, x: int, y: int) -> str:
|
||||||
exits = []
|
exits = []
|
||||||
|
|
||||||
# Check cardinal directions
|
# 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)
|
if zone.is_passable(x, y - 1): # north (y decreases going up)
|
||||||
exits.append("north")
|
exits.append("north")
|
||||||
if zone.is_passable(x, y + 1): # south
|
if zone.is_passable(x, y + 1): # south
|
||||||
|
|
@ -103,6 +102,11 @@ def render_exits(zone, x: int, y: int) -> str:
|
||||||
exits.append("east")
|
exits.append("east")
|
||||||
if zone.is_passable(x - 1, y): # west
|
if zone.is_passable(x - 1, y): # west
|
||||||
exits.append("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:
|
if exits:
|
||||||
return f"Exits: {' '.join(exits)}"
|
return f"Exits: {' '.join(exits)}"
|
||||||
|
|
@ -111,6 +115,7 @@ def render_exits(zone, x: int, y: int) -> str:
|
||||||
|
|
||||||
_POSTURE_MESSAGES = {
|
_POSTURE_MESSAGES = {
|
||||||
"standing": "is standing here.",
|
"standing": "is standing here.",
|
||||||
|
"sleeping": "is sleeping here.",
|
||||||
"resting": "is resting here.",
|
"resting": "is resting here.",
|
||||||
"flying": "is flying above.",
|
"flying": "is flying above.",
|
||||||
"fighting": "is fighting here.",
|
"fighting": "is fighting here.",
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ async def test_look_includes_exits_line(player):
|
||||||
"""Look output should include 'Exits:' line."""
|
"""Look output should include 'Exits:' line."""
|
||||||
await cmd_look(player, "")
|
await cmd_look(player, "")
|
||||||
output = get_output(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
|
@pytest.mark.asyncio
|
||||||
|
|
@ -169,14 +169,36 @@ async def test_look_shows_portals(player, test_zone):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_look_with_args_routes_to_examine(player, test_zone):
|
async def test_look_with_args_routes_to_examine(player, test_zone):
|
||||||
"""look <thing> should route to examine command logic."""
|
"""look <thing> should route to examine command logic."""
|
||||||
# Add an item to examine (location param auto-adds to zone)
|
called = {}
|
||||||
Thing(name="sword", x=25, y=25, description="A sharp blade.", location=test_zone)
|
|
||||||
|
|
||||||
|
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")
|
await cmd_look(player, "sword")
|
||||||
output = get_output(player)
|
finally:
|
||||||
|
mudlib.commands.examine.examine_target = original
|
||||||
|
|
||||||
# Should see the item's description (examine behavior)
|
output = get_output(player)
|
||||||
assert "A sharp blade." in output
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,30 @@ def test_render_exits_partial():
|
||||||
assert result == "Exits: north east"
|
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():
|
def test_render_entity_lines_empty():
|
||||||
"""render_entity_lines returns empty string when no entities."""
|
"""render_entity_lines returns empty string when no entities."""
|
||||||
assert render_entity_lines([], None) == ""
|
assert render_entity_lines([], None) == ""
|
||||||
|
|
@ -169,3 +193,7 @@ def test_render_entity_lines_postures():
|
||||||
# Unconscious (stamina <= 0)
|
# Unconscious (stamina <= 0)
|
||||||
mob_exhausted = Mob(name="Goku", x=10, y=10, stamina=0)
|
mob_exhausted = Mob(name="Goku", x=10, y=10, stamina=0)
|
||||||
assert render_entity_lines([mob_exhausted], None) == "Goku is unconscious."
|
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."
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue