From 4c969d29878c09d6808cdf0e4bd3a3ac0ef8fd13 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 01:11:57 -0500 Subject: [PATCH] Add target resolution module with ordinal and prefix matching --- src/mudlib/targeting.py | 194 +++++++++++++++++++++++ tests/test_targeting.py | 335 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 src/mudlib/targeting.py create mode 100644 tests/test_targeting.py diff --git a/src/mudlib/targeting.py b/src/mudlib/targeting.py new file mode 100644 index 0000000..37e4c2a --- /dev/null +++ b/src/mudlib/targeting.py @@ -0,0 +1,194 @@ +"""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) diff --git a/tests/test_targeting.py b/tests/test_targeting.py new file mode 100644 index 0000000..a6d8f2f --- /dev/null +++ b/tests/test_targeting.py @@ -0,0 +1,335 @@ +"""Tests for target resolution system.""" + +from mudlib.entity import Mob +from mudlib.targeting import ( + find_entity_on_tile, + find_in_inventory, + find_thing_on_tile, + parse_target, + resolve_target, +) +from mudlib.thing import Thing + + +class TestParseTarget: + """Test ordinal prefix parsing.""" + + def test_plain_name(self): + """Plain name returns ordinal 1.""" + ordinal, name = parse_target("goblin") + assert ordinal == 1 + assert name == "goblin" + + def test_ordinal_prefix(self): + """Parse '2.goblin' into (2, 'goblin').""" + ordinal, name = parse_target("2.goblin") + assert ordinal == 2 + assert name == "goblin" + + def test_first_ordinal(self): + """Parse '1.sword' into (1, 'sword').""" + ordinal, name = parse_target("1.sword") + assert ordinal == 1 + assert name == "sword" + + def test_large_ordinal(self): + """Parse large ordinal numbers.""" + ordinal, name = parse_target("10.rat") + assert ordinal == 10 + assert name == "rat" + + def test_zero_ordinal_invalid(self): + """Zero ordinal is invalid, treat as plain name.""" + ordinal, name = parse_target("0.sword") + assert ordinal == 1 + assert name == "0.sword" + + def test_negative_ordinal_invalid(self): + """Negative ordinal is invalid, treat as plain name.""" + ordinal, name = parse_target("-1.sword") + assert ordinal == 1 + assert name == "-1.sword" + + +class TestResolveTarget: + """Test target resolution with name matching.""" + + def test_exact_match(self): + """Find exact name match.""" + candidates = [Thing(name="sword"), Thing(name="shield")] + result = resolve_target("sword", candidates) + assert result is candidates[0] + + def test_exact_match_case_insensitive(self): + """Exact match is case-insensitive.""" + candidates = [Thing(name="Sword")] + result = resolve_target("sword", candidates) + assert result is candidates[0] + + def test_prefix_match(self): + """Find by name prefix.""" + candidates = [Thing(name="longsword")] + result = resolve_target("long", candidates) + assert result is candidates[0] + + def test_prefix_match_case_insensitive(self): + """Prefix match is case-insensitive.""" + candidates = [Thing(name="LongSword")] + result = resolve_target("long", candidates) + assert result is candidates[0] + + def test_alias_exact_match(self): + """Find by exact alias match.""" + candidates = [Thing(name="longsword", aliases=["blade", "weapon"])] + result = resolve_target("blade", candidates) + assert result is candidates[0] + + def test_alias_prefix_match(self): + """Find by alias prefix.""" + candidates = [Thing(name="longsword", aliases=["greatsword"])] + result = resolve_target("great", candidates) + assert result is candidates[0] + + def test_ordinal_disambiguation(self): + """Use ordinal to select Nth match.""" + candidates = [ + Thing(name="goblin"), + Thing(name="goblin"), + Thing(name="goblin"), + ] + result = resolve_target("2.goblin", candidates) + assert result is candidates[1] + + def test_ordinal_out_of_range(self): + """Ordinal beyond available matches returns None.""" + candidates = [Thing(name="goblin")] + result = resolve_target("2.goblin", candidates) + assert result is None + + def test_no_match(self): + """No matching candidates returns None.""" + candidates = [Thing(name="sword")] + result = resolve_target("shield", candidates) + assert result is None + + def test_custom_key_function(self): + """Use custom key function to extract name.""" + + class CustomObject: + def __init__(self, label): + self.label = label + + candidates = [CustomObject("sword"), CustomObject("shield")] + result = resolve_target("sword", candidates, key=lambda obj: obj.label) + assert result is candidates[0] + + def test_object_without_aliases(self): + """Handle objects without aliases attribute.""" + + class SimpleObject: + def __init__(self, name): + self.name = name + + candidates = [SimpleObject("sword")] + result = resolve_target("sword", candidates) + assert result is candidates[0] + + def test_exact_match_preferred_over_prefix(self): + """Exact match takes precedence over prefix.""" + candidates = [Thing(name="sword"), Thing(name="swordfish")] + result = resolve_target("sword", candidates) + assert result is candidates[0] + + def test_ordinal_with_prefix_match(self): + """Ordinal works with prefix matching.""" + candidates = [ + Thing(name="longsword"), + Thing(name="longbow"), + ] + result = resolve_target("2.long", candidates) + assert result is candidates[1] + + +class TestFindEntityOnTile: + """Test finding entities on the same tile.""" + + def test_find_nearby_player(self, player, nearby_player, test_zone): + """Find another player on same tile.""" + player.location = test_zone + nearby_player.location = test_zone + result = find_entity_on_tile("vegeta", player) + assert result is nearby_player + + def test_find_mob_on_tile(self, player, test_zone): + """Find mob on same tile.""" + player.location = test_zone + goblin = Mob(name="goblin", x=0, y=0, alive=True) + goblin.location = test_zone + test_zone._contents.append(goblin) + result = find_entity_on_tile("goblin", player) + assert result is goblin + + def test_skip_dead_mobs(self, player, test_zone): + """Skip dead mobs.""" + player.location = test_zone + dead_goblin = Mob(name="goblin", x=0, y=0, alive=False) + dead_goblin.location = test_zone + test_zone._contents.append(dead_goblin) + result = find_entity_on_tile("goblin", player) + assert result is None + + def test_skip_self(self, player, test_zone): + """Don't return the player themselves.""" + player.location = test_zone + result = find_entity_on_tile("goku", player) + assert result is None + + def test_z_axis_check_both_grounded(self, player, nearby_player, test_zone): + """Find entity when both grounded.""" + player.location = test_zone + nearby_player.location = test_zone + player.flying = False + nearby_player.flying = False + result = find_entity_on_tile("vegeta", player) + assert result is nearby_player + + def test_z_axis_check_both_flying(self, player, nearby_player, test_zone): + """Find entity when both flying.""" + player.location = test_zone + nearby_player.location = test_zone + player.flying = True + nearby_player.flying = True + result = find_entity_on_tile("vegeta", player) + assert result is nearby_player + + def test_z_axis_check_mismatch(self, player, nearby_player, test_zone): + """Don't find entity on different z-axis.""" + player.location = test_zone + nearby_player.location = test_zone + player.flying = False + nearby_player.flying = True + result = find_entity_on_tile("vegeta", player) + assert result is None + + def test_ordinal_with_multiple_entities(self, player, test_zone): + """Use ordinal to select specific entity.""" + player.location = test_zone + goblin1 = Mob(name="goblin", x=0, y=0, alive=True) + goblin2 = Mob(name="goblin", x=0, y=0, alive=True) + goblin1.location = test_zone + test_zone._contents.append(goblin1) + goblin2.location = test_zone + test_zone._contents.append(goblin2) + result = find_entity_on_tile("2.goblin", player) + assert result is goblin2 + + def test_prefix_match_entity(self, player, test_zone): + """Find entity by prefix.""" + player.location = test_zone + goblin = Mob(name="goblin", x=0, y=0, alive=True) + goblin.location = test_zone + test_zone._contents.append(goblin) + result = find_entity_on_tile("gob", player) + assert result is goblin + + def test_no_match(self, player, test_zone): + """Return None when no match.""" + player.location = test_zone + result = find_entity_on_tile("dragon", player) + assert result is None + + +class TestFindThingOnTile: + """Test finding Things on the ground.""" + + def test_find_by_name(self, test_zone): + """Find thing by exact name.""" + sword = Thing(name="sword", x=5, y=5) + sword.location = test_zone + test_zone._contents.append(sword) + result = find_thing_on_tile("sword", test_zone, 5, 5) + assert result is sword + + def test_find_by_alias(self, test_zone): + """Find thing by alias.""" + sword = Thing(name="longsword", aliases=["blade"], x=5, y=5) + sword.location = test_zone + test_zone._contents.append(sword) + result = find_thing_on_tile("blade", test_zone, 5, 5) + assert result is sword + + def test_prefix_match(self, test_zone): + """Find thing by name prefix.""" + sword = Thing(name="longsword", x=5, y=5) + sword.location = test_zone + test_zone._contents.append(sword) + result = find_thing_on_tile("long", test_zone, 5, 5) + assert result is sword + + def test_ordinal(self, test_zone): + """Use ordinal to select specific thing.""" + rock1 = Thing(name="rock", x=5, y=5) + rock2 = Thing(name="rock", x=5, y=5) + rock1.location = test_zone + test_zone._contents.append(rock1) + rock2.location = test_zone + test_zone._contents.append(rock2) + result = find_thing_on_tile("2.rock", test_zone, 5, 5) + assert result is rock2 + + def test_skip_non_things(self, test_zone): + """Only match Thing instances, skip entities.""" + mob = Mob(name="sword", x=5, y=5, alive=True) + mob.location = test_zone + test_zone._contents.append(mob) + result = find_thing_on_tile("sword", test_zone, 5, 5) + assert result is None + + def test_no_match(self, test_zone): + """Return None when no match.""" + result = find_thing_on_tile("shield", test_zone, 5, 5) + assert result is None + + +class TestFindInInventory: + """Test finding Things in player inventory.""" + + def test_find_by_name(self, player): + """Find item by exact name.""" + sword = Thing(name="sword") + sword.location = player + player._contents.append(sword) + result = find_in_inventory("sword", player) + assert result is sword + + def test_find_by_alias(self, player): + """Find item by alias.""" + sword = Thing(name="longsword", aliases=["blade"]) + sword.location = player + player._contents.append(sword) + result = find_in_inventory("blade", player) + assert result is sword + + def test_prefix_match(self, player): + """Find item by name prefix.""" + sword = Thing(name="longsword") + sword.location = player + player._contents.append(sword) + result = find_in_inventory("long", player) + assert result is sword + + def test_ordinal(self, player): + """Use ordinal to select specific item.""" + potion1 = Thing(name="potion") + potion2 = Thing(name="potion") + potion1.location = player + player._contents.append(potion1) + potion2.location = player + player._contents.append(potion2) + result = find_in_inventory("2.potion", player) + assert result is potion2 + + def test_no_match(self, player): + """Return None when no match.""" + result = find_in_inventory("shield", player) + assert result is None