mud/src/mudlib/commands/__init__.py

215 lines
6.3 KiB
Python

"""Command registry and dispatcher."""
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import cast
from mudlib.player import Player
logger = logging.getLogger(__name__)
# Type alias for command handlers
CommandHandler = Callable[[Player, str], Awaitable[None]]
@dataclass
class CommandDefinition:
"""Metadata wrapper for a registered command."""
name: str
handler: CommandHandler
aliases: list[str] = field(default_factory=list)
mode: str = "normal"
help: str = ""
hidden: bool = False
admin: bool = False
# Registry maps command names to definitions
_registry: dict[str, CommandDefinition] = {}
def unregister(name: str) -> None:
"""Unregister a command and all its aliases.
Args:
name: The main command name to unregister
"""
# Find the definition by name
defn = _registry.get(name)
if defn is None:
return
# Remove main name
_registry.pop(name, None)
# Remove all aliases
for alias in defn.aliases:
_registry.pop(alias, None)
# Also remove any other keys pointing to this same definition
keys_to_remove = [k for k, v in _registry.items() if v is defn]
for key in keys_to_remove:
del _registry[key]
def register(defn: CommandDefinition) -> None:
"""Register a command definition with its aliases.
Args:
defn: The command definition to register
"""
# Check for collision on the main name
if defn.name in _registry:
existing = _registry[defn.name]
if existing is not defn:
logger.warning(
"Command collision: '%s' already registered for '%s', "
"overwriting with '%s'",
defn.name,
existing.name,
defn.name,
)
_registry[defn.name] = defn
# Check for collisions on each alias
for alias in defn.aliases:
if alias in _registry:
existing = _registry[alias]
if existing is not defn:
logger.warning(
"Command collision: '%s' already registered for '%s', "
"overwriting with '%s'",
alias,
existing.name,
defn.name,
)
_registry[alias] = defn
def resolve_prefix(command: str) -> CommandDefinition | list[str] | None:
"""Resolve a command by exact match or shortest unique prefix.
Args:
command: Command name or prefix to resolve
Returns:
CommandDefinition if unique match found
list of command names if ambiguous
None if no match
"""
# Try exact match first (fast path)
defn = _registry.get(command)
if defn is not None:
return defn
# Try prefix matching
prefix_matches = [key for key in _registry if key.startswith(command)]
# Deduplicate by CommandDefinition identity
unique_defns = {}
for key in prefix_matches:
defn_obj = _registry[key]
unique_defns[id(defn_obj)] = defn_obj
if len(unique_defns) == 0:
# No matches
return None
elif len(unique_defns) == 1:
# Unique match
return next(iter(unique_defns.values()))
else:
# Multiple matches - return list of names
return sorted([d.name for d in unique_defns.values()])
async def dispatch(player: Player, raw_input: str) -> None:
"""Parse input, find command, call handler.
Args:
player: The player executing the command
raw_input: The raw input string from the player
"""
raw_input = raw_input.strip()
if not raw_input:
return
# Split into command and arguments
parts = raw_input.split(maxsplit=1)
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
# Resolve aliases (with recursion guard)
expansion_count = 0
max_expansions = 10
while command in player.aliases:
if expansion_count >= max_expansions:
player.writer.write("Too many nested aliases (max 10).\r\n")
await player.writer.drain()
return
expansion = player.aliases[command]
# Combine expansion with remaining args
raw_input = f"{expansion} {args}" if args else expansion
# Re-split to get new command and args
parts = raw_input.split(maxsplit=1)
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
expansion_count += 1
# Resolve command by exact match or prefix
result = resolve_prefix(command)
if result is None:
# No matches - try verb dispatch as fallback
if args:
from mudlib.verbs import find_object
# Split args into target name and any extra arguments
target_parts = args.split(maxsplit=1)
target_name = target_parts[0]
extra_args = target_parts[1] if len(target_parts) > 1 else ""
# Try to find the object
obj = find_object(target_name, player)
if obj is not None and obj.has_verb(command):
handler = obj.get_verb(command)
assert handler is not None # has_verb checked above
await handler(player, extra_args)
return
# Still no match - show unknown command
player.writer.write(f"Unknown command: {command}\r\n")
await player.writer.drain()
return
elif isinstance(result, list):
# Multiple matches - show disambiguation
names = result
if len(names) == 2:
msg = f"{names[0]} or {names[1]}?\r\n"
else:
prefix = ", ".join(cast(list[str], names[:-1]))
msg = f"{prefix}, or {names[-1]}?\r\n"
player.writer.write(msg)
await player.writer.drain()
return
else:
# Unique match
defn = result
# Check mode restriction
if defn.mode != "*" and defn.mode != player.mode:
player.writer.write("You can't do that right now.\r\n")
await player.writer.drain()
return
# Check admin permission
if defn.admin and not getattr(player, "is_admin", False):
player.writer.write("You don't have permission to do that.\r\n")
await player.writer.drain()
return
# Execute the handler
await defn.handler(player, args)