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