194 lines
5.5 KiB
Python
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)
|