Add target resolution module with ordinal and prefix matching

This commit is contained in:
Jared Miller 2026-02-14 01:11:57 -05:00
parent be63a1cbde
commit 4c969d2987
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 529 additions and 0 deletions

194
src/mudlib/targeting.py Normal file
View file

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

335
tests/test_targeting.py Normal file
View file

@ -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