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