mud/src/mudlib/targeting.py

194 lines
5.5 KiB
Python

"""Target resolution for commands that take entity/thing arguments."""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from mudlib.entity import Entity
from mudlib.thing import Thing
if TYPE_CHECKING:
from mudlib.zone import Zone
def parse_target(raw: str) -> tuple[int, str]:
"""Parse ordinal prefix from target name.
Examples:
"goblin" -> (1, "goblin")
"2.goblin" -> (2, "goblin")
"10.rat" -> (10, "rat")
Invalid ordinals (0, negative, non-numeric) are treated as part of the name.
"""
if "." not in raw:
return (1, raw)
prefix, rest = raw.split(".", 1)
try:
ordinal = int(prefix)
if ordinal >= 1:
return (ordinal, rest)
except ValueError:
pass
# Invalid ordinal, treat as plain name
return (1, raw)
def resolve_target(
name: str,
candidates: list[Any],
*,
key: Callable[[Any], str] | None = None,
) -> Any | None:
"""Resolve a target by name from a list of candidates.
Matching priority:
1. Parse ordinal from name (e.g., "2.goblin")
2. Exact match on name (case-insensitive)
3. Prefix match on name (case-insensitive)
4. Exact match on alias (case-insensitive, if candidate has aliases)
5. Prefix match on alias (case-insensitive)
6. Return Nth match based on ordinal
Args:
name: Target name to search for (may include ordinal prefix)
candidates: List of objects to search
key: Optional function to extract name from candidate (default: obj.name)
Returns:
Matching candidate or None if no match found
"""
if not candidates:
return None
ordinal, search_name = parse_target(name)
search_lower = search_name.lower()
# Helper to get name from candidate
def get_name(obj: Any) -> str:
if key:
return key(obj)
return obj.name
# Helper to get aliases from candidate
def get_aliases(obj: Any) -> list[str]:
if hasattr(obj, "aliases"):
return obj.aliases
return []
# Collect all matches in priority order
matches: list[Any] = []
# Priority 1: Exact name match
for candidate in candidates:
if get_name(candidate).lower() == search_lower:
matches.append(candidate)
# Priority 2: Prefix name match
if not matches:
for candidate in candidates:
if get_name(candidate).lower().startswith(search_lower):
matches.append(candidate)
# Priority 3: Exact alias match
if not matches:
for candidate in candidates:
for alias in get_aliases(candidate):
if alias.lower() == search_lower:
matches.append(candidate)
break
# Priority 4: Prefix alias match
if not matches:
for candidate in candidates:
for alias in get_aliases(candidate):
if alias.lower().startswith(search_lower):
matches.append(candidate)
break
# Return Nth match based on ordinal
if len(matches) >= ordinal:
return matches[ordinal - 1]
return None
def find_entity_on_tile(
name: str, player: "Entity", *, z_filter: bool = True
) -> "Entity | None":
"""Find a player or mob on the same tile as the player.
By default, only finds entities on the same z-axis (both flying or both grounded).
Skips the player themselves and dead mobs.
Args:
name: Target name (may include ordinal prefix)
player: The player doing the searching
z_filter: If True, filter by z-axis match (default True)
Returns:
Matching Entity or None
"""
if not player.location or not hasattr(player.location, "contents_at"):
return None
# Get all entities at the player's position
contents = player.location.contents_at(player.x, player.y) # type: ignore[misc]
# Filter to Entity instances, excluding self and dead mobs
candidates = []
for obj in contents:
if not isinstance(obj, Entity):
continue
if obj is player:
continue
if hasattr(obj, "alive") and not obj.alive:
continue
# Check z-axis match if filtering enabled
if z_filter and (
getattr(obj, "flying", False) != getattr(player, "flying", False)
):
continue
candidates.append(obj)
# Sort to prefer Players over Mobs (Players have reader/writer attributes)
candidates.sort(key=lambda e: not hasattr(e, "reader"))
return resolve_target(name, candidates)
def find_thing_on_tile(name: str, zone: "Zone", x: int, y: int) -> "Thing | None":
"""Find a Thing on the ground at the given coordinates.
Args:
name: Target name (may include ordinal prefix)
zone: The zone to search in
x: X coordinate
y: Y coordinate
Returns:
Matching Thing or None
"""
contents = zone.contents_at(x, y)
# Filter to Thing instances only
candidates = [obj for obj in contents if isinstance(obj, Thing)]
return resolve_target(name, candidates)
def find_in_inventory(name: str, player: "Entity") -> "Thing | None":
"""Find a Thing in the player's inventory.
Args:
name: Target name (may include ordinal prefix)
player: The player whose inventory to search
Returns:
Matching Thing or None
"""
# Filter to Thing instances in inventory
candidates = [obj for obj in player.contents if isinstance(obj, Thing)]
return resolve_target(name, candidates)