Add target resolution module with ordinal and prefix matching
This commit is contained in:
parent
be63a1cbde
commit
4c969d2987
2 changed files with 529 additions and 0 deletions
194
src/mudlib/targeting.py
Normal file
194
src/mudlib/targeting.py
Normal 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
335
tests/test_targeting.py
Normal 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
|
||||
Loading…
Reference in a new issue