Compare commits
22 commits
e9378bb6fa
...
37766ad69f
| Author | SHA1 | Date | |
|---|---|---|---|
| 37766ad69f | |||
| 8c83130f67 | |||
| 68fa08d776 | |||
| 7c21310dcd | |||
| 6e9a89f21e | |||
| 7ae56106d6 | |||
| 841714d6ca | |||
| 64b6308bc6 | |||
| c385f559a3 | |||
| e00591b6bf | |||
| 20f33f45e1 | |||
| 7c313ae307 | |||
| 7d3b02f6ff | |||
| 5efdbaf4e6 | |||
| 1a122b85e5 | |||
| f1e4cfa4dd | |||
| 529320acb5 | |||
| a8917da59e | |||
| 9e5003e52c | |||
| d0c33911f3 | |||
| 1b63f87da7 | |||
| 6fb48e9ae1 |
20 changed files with 1286 additions and 49 deletions
|
|
@ -4,7 +4,5 @@ stamina_cost = 3.0
|
|||
timing_window_ms = 800
|
||||
|
||||
[variants.left]
|
||||
aliases = ["dl"]
|
||||
|
||||
[variants.right]
|
||||
aliases = ["dr"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
name = "duck"
|
||||
aliases = []
|
||||
move_type = "defense"
|
||||
stamina_cost = 3.0
|
||||
telegraph = ""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
name = "jump"
|
||||
aliases = []
|
||||
move_type = "defense"
|
||||
stamina_cost = 4.0
|
||||
telegraph = ""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,5 @@ stamina_cost = 4.0
|
|||
timing_window_ms = 1200
|
||||
|
||||
[variants.high]
|
||||
aliases = ["f"]
|
||||
|
||||
[variants.low]
|
||||
aliases = ["v"]
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ timing_window_ms = 1800
|
|||
damage_pct = 0.15
|
||||
|
||||
[variants.left]
|
||||
aliases = ["pl"]
|
||||
telegraph = "{attacker} winds up a left hook!"
|
||||
countered_by = ["dodge right", "parry high"]
|
||||
|
||||
[variants.right]
|
||||
aliases = ["pr"]
|
||||
telegraph = "{attacker} winds up a right hook!"
|
||||
countered_by = ["dodge left", "parry high"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
name = "roundhouse"
|
||||
aliases = ["rh"]
|
||||
move_type = "attack"
|
||||
stamina_cost = 8.0
|
||||
telegraph = "{attacker} spins into a roundhouse kick!"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
name = "sweep"
|
||||
aliases = ["sw"]
|
||||
move_type = "attack"
|
||||
stamina_cost = 6.0
|
||||
telegraph = "{attacker} drops low for a leg sweep!"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
name = "motd"
|
||||
aliases = ["message"]
|
||||
help = "display the message of the day"
|
||||
mode = "*"
|
||||
message = """
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Combat command handlers."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -13,6 +12,7 @@ from mudlib.player import Player, players
|
|||
|
||||
# Combat moves will be injected after loading
|
||||
combat_moves: dict[str, CombatMove] = {}
|
||||
combat_content_dir: Path | None = None
|
||||
|
||||
|
||||
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||
|
|
@ -171,7 +171,10 @@ def register_combat_commands(content_dir: Path) -> None:
|
|||
Args:
|
||||
content_dir: Path to directory containing combat move TOML files
|
||||
"""
|
||||
global combat_moves
|
||||
global combat_moves, combat_content_dir
|
||||
|
||||
# Save content directory for use by edit command
|
||||
combat_content_dir = content_dir
|
||||
|
||||
# Load all moves from content directory
|
||||
combat_moves = load_moves(content_dir)
|
||||
|
|
@ -200,7 +203,7 @@ def register_combat_commands(content_dir: Path) -> None:
|
|||
CommandDefinition(
|
||||
name=move.name,
|
||||
handler=_make_direct_handler(move, handler_fn),
|
||||
aliases=move.aliases,
|
||||
aliases=[],
|
||||
mode=mode,
|
||||
help=f"{action} with {move.name}",
|
||||
)
|
||||
|
|
@ -213,11 +216,6 @@ def register_combat_commands(content_dir: Path) -> None:
|
|||
handler_fn = do_attack if first_variant.move_type == "attack" else do_defend
|
||||
mode = "*"
|
||||
|
||||
# Collect all variant aliases for the base command
|
||||
all_aliases = []
|
||||
for move in variants.values():
|
||||
all_aliases.extend(move.aliases)
|
||||
|
||||
# Register base command with variant handler (e.g. "punch")
|
||||
action = "Attack" if first_variant.move_type == "attack" else "Defend"
|
||||
register(
|
||||
|
|
@ -229,17 +227,3 @@ def register_combat_commands(content_dir: Path) -> None:
|
|||
help=f"{action} with {base_name}",
|
||||
)
|
||||
)
|
||||
|
||||
# Register each variant's aliases as direct commands (e.g. "pl" → punch left)
|
||||
for move in variants.values():
|
||||
for alias in move.aliases:
|
||||
action = "Attack" if move.move_type == "attack" else "Defend"
|
||||
register(
|
||||
CommandDefinition(
|
||||
name=alias,
|
||||
handler=_make_direct_handler(move, handler_fn),
|
||||
aliases=[],
|
||||
mode=mode,
|
||||
help=f"{action} with {move.name}",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
"""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]]
|
||||
|
||||
|
|
@ -18,6 +22,7 @@ class CommandDefinition:
|
|||
aliases: list[str] = field(default_factory=list)
|
||||
mode: str = "normal"
|
||||
help: str = ""
|
||||
hidden: bool = False
|
||||
|
||||
|
||||
# Registry maps command names to definitions
|
||||
|
|
@ -30,11 +35,71 @@ def register(defn: CommandDefinition) -> None:
|
|||
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.
|
||||
|
||||
|
|
@ -52,13 +117,28 @@ async def dispatch(player: Player, raw_input: str) -> None:
|
|||
command = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Look up the definition
|
||||
defn = _registry.get(command)
|
||||
# Resolve command by exact match or prefix
|
||||
result = resolve_prefix(command)
|
||||
|
||||
if defn is None:
|
||||
if result is None:
|
||||
# No matches
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Edit command for entering the text editor."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.editor import Editor
|
||||
from mudlib.player import Player
|
||||
|
|
@ -9,21 +11,80 @@ async def cmd_edit(player: Player, args: str) -> None:
|
|||
"""Enter the text editor.
|
||||
|
||||
Args:
|
||||
player: The player executing the command
|
||||
args: Command arguments (unused for now)
|
||||
player: The player entering the editor
|
||||
args: Optional argument - combat move name to edit
|
||||
"""
|
||||
args = args.strip()
|
||||
|
||||
async def save_callback(content: str) -> None:
|
||||
await player.send("Content saved.\r\n")
|
||||
# Default: blank editor
|
||||
initial_content = ""
|
||||
content_type = "text"
|
||||
save_callback_fn = _make_default_save_callback(player)
|
||||
toml_path: Path | None = None
|
||||
|
||||
# If args provided, try to load combat move TOML
|
||||
if args:
|
||||
from mudlib.combat.commands import combat_content_dir, combat_moves
|
||||
|
||||
if combat_content_dir is None:
|
||||
await player.send("Combat content not loaded.\r\n")
|
||||
return
|
||||
|
||||
# Look up the move - could be by name or alias
|
||||
move = combat_moves.get(args)
|
||||
|
||||
# If not found, try to find by command name (for variant bases)
|
||||
if move is None:
|
||||
for m in combat_moves.values():
|
||||
if m.command == args:
|
||||
move = m
|
||||
break
|
||||
|
||||
if move is None:
|
||||
await player.send(f"Unknown content: {args}\r\n")
|
||||
return
|
||||
|
||||
# Get the base command name to find the TOML file
|
||||
toml_filename = f"{move.command}.toml"
|
||||
toml_path = combat_content_dir / toml_filename
|
||||
|
||||
if not toml_path.exists():
|
||||
await player.send(f"TOML file not found: {toml_filename}\r\n")
|
||||
return
|
||||
|
||||
# Read the file content
|
||||
initial_content = toml_path.read_text()
|
||||
content_type = "toml"
|
||||
save_callback_fn = _make_toml_save_callback(player, toml_path)
|
||||
|
||||
player.editor = Editor(
|
||||
save_callback=save_callback,
|
||||
content_type="text",
|
||||
save_callback=save_callback_fn,
|
||||
content_type=content_type,
|
||||
color_depth=player.color_depth,
|
||||
initial_content=initial_content,
|
||||
)
|
||||
player.mode_stack.append("editor")
|
||||
await player.send("Entering editor. Type :h for help.\r\n")
|
||||
|
||||
|
||||
def _make_default_save_callback(player: Player):
|
||||
"""Create default save callback for blank editor."""
|
||||
|
||||
async def save_callback(content: str) -> None:
|
||||
await player.send("Content saved.\r\n")
|
||||
|
||||
return save_callback
|
||||
|
||||
|
||||
def _make_toml_save_callback(player: Player, toml_path: Path):
|
||||
"""Create save callback for TOML file editing."""
|
||||
|
||||
async def save_callback(content: str) -> None:
|
||||
toml_path.write_text(content)
|
||||
await player.send(f"Saved {toml_path.name}\r\n")
|
||||
|
||||
return save_callback
|
||||
|
||||
|
||||
# Register the edit command
|
||||
register(CommandDefinition("edit", cmd_edit, mode="normal"))
|
||||
|
|
|
|||
303
src/mudlib/commands/help.py
Normal file
303
src/mudlib/commands/help.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""Help and command listing commands."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from mudlib.commands import CommandDefinition, _registry, register, resolve_prefix
|
||||
from mudlib.commands.movement import DIRECTIONS
|
||||
from mudlib.player import Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mudlib.combat.moves import CombatMove
|
||||
|
||||
|
||||
async def _show_command_detail(player: Player, command_name: str) -> None:
|
||||
"""Show detailed information about a specific command.
|
||||
|
||||
Args:
|
||||
player: The player requesting details
|
||||
command_name: Name or alias of the command to show
|
||||
"""
|
||||
from mudlib.combat.commands import combat_moves
|
||||
|
||||
# Resolve command by exact match or prefix
|
||||
result = resolve_prefix(command_name)
|
||||
|
||||
if 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"
|
||||
await player.send(msg)
|
||||
return
|
||||
else:
|
||||
defn = result
|
||||
|
||||
# If still not in registry, check if it's a combat move name (like "punch left")
|
||||
if defn is None:
|
||||
move = combat_moves.get(command_name)
|
||||
if move is not None:
|
||||
# Create a synthetic defn for display purposes
|
||||
defn = CommandDefinition(
|
||||
name=move.name,
|
||||
handler=lambda p, a: None,
|
||||
aliases=move.aliases,
|
||||
mode="*",
|
||||
help="",
|
||||
)
|
||||
await _show_single_command(player, defn, move)
|
||||
return
|
||||
|
||||
await player.send(f"Unknown command: {command_name}\r\n")
|
||||
return
|
||||
|
||||
# Try to get combat move data
|
||||
move = combat_moves.get(defn.name)
|
||||
|
||||
# Check if this is a variant base command (like "punch")
|
||||
# by looking for variants in combat_moves
|
||||
variants = []
|
||||
if move is None:
|
||||
for _move_name, m in combat_moves.items():
|
||||
if m.command == defn.name and m.variant:
|
||||
variants.append(m)
|
||||
|
||||
# If we found variants, show variant overview
|
||||
if variants:
|
||||
await _show_variant_overview(player, defn, variants)
|
||||
return
|
||||
|
||||
# Otherwise show single command detail
|
||||
await _show_single_command(player, defn, move)
|
||||
|
||||
|
||||
async def _show_single_command(
|
||||
player: Player, defn: CommandDefinition, move: "CombatMove | None"
|
||||
) -> None:
|
||||
"""Show details for a single command or combat move.
|
||||
|
||||
Args:
|
||||
player: The player requesting details
|
||||
defn: Command definition
|
||||
move: Combat move data if available
|
||||
"""
|
||||
|
||||
lines = [defn.name]
|
||||
|
||||
# Always show aliases
|
||||
if defn.aliases:
|
||||
aliases_str = ", ".join(defn.aliases)
|
||||
lines.append(f" aliases: {aliases_str}")
|
||||
|
||||
# Show type for combat moves
|
||||
if move is not None:
|
||||
lines.append(f" type: {move.move_type}")
|
||||
|
||||
# Show mode for non-wildcard modes
|
||||
if defn.mode != "*":
|
||||
lines.append(f" mode: {defn.mode}")
|
||||
|
||||
# Combat move specific details
|
||||
if move is not None:
|
||||
lines.append(f" stamina: {move.stamina_cost}")
|
||||
lines.append(f" timing window: {move.timing_window_ms}ms")
|
||||
if move.damage_pct > 0:
|
||||
damage_pct = int(move.damage_pct * 100)
|
||||
lines.append(f" damage: {damage_pct}%")
|
||||
if move.telegraph:
|
||||
lines.append(f" telegraph: {move.telegraph}")
|
||||
if move.countered_by:
|
||||
counters = ", ".join(move.countered_by)
|
||||
lines.append(f" countered by: {counters}")
|
||||
else:
|
||||
# Show help text for non-combat commands
|
||||
if defn.help:
|
||||
lines.append(f" help: {defn.help}")
|
||||
|
||||
await player.send("\r\n".join(lines) + "\r\n")
|
||||
|
||||
|
||||
async def _show_variant_overview(
|
||||
player: Player, defn: CommandDefinition, variants: list["CombatMove"]
|
||||
) -> None:
|
||||
"""Show overview of a variant command with all its variants.
|
||||
|
||||
Args:
|
||||
player: The player requesting details
|
||||
defn: Command definition for the base command
|
||||
variants: List of variant moves
|
||||
"""
|
||||
|
||||
# Sort variants by name for consistent display
|
||||
variants = sorted(variants, key=lambda m: m.name)
|
||||
|
||||
lines = [defn.name]
|
||||
|
||||
# Show type from first variant
|
||||
if variants:
|
||||
lines.append(f" type: {variants[0].move_type}")
|
||||
|
||||
# Show each variant
|
||||
for move in variants:
|
||||
lines.append("")
|
||||
lines.append(f" {move.name}")
|
||||
|
||||
if move.aliases:
|
||||
aliases_str = ", ".join(move.aliases)
|
||||
lines.append(f" aliases: {aliases_str}")
|
||||
|
||||
lines.append(f" stamina: {move.stamina_cost}")
|
||||
lines.append(f" timing window: {move.timing_window_ms}ms")
|
||||
|
||||
if move.damage_pct > 0:
|
||||
damage_pct = int(move.damage_pct * 100)
|
||||
lines.append(f" damage: {damage_pct}%")
|
||||
|
||||
if move.telegraph:
|
||||
lines.append(f" telegraph: {move.telegraph}")
|
||||
|
||||
if move.countered_by:
|
||||
counters = ", ".join(move.countered_by)
|
||||
lines.append(f" countered by: {counters}")
|
||||
|
||||
await player.send("\r\n".join(lines) + "\r\n")
|
||||
|
||||
|
||||
async def cmd_commands(player: Player, args: str) -> None:
|
||||
"""List all available commands grouped by type, or show detail for one.
|
||||
|
||||
Args:
|
||||
player: The player executing the command
|
||||
args: Command arguments (command name for detail view, empty for list)
|
||||
"""
|
||||
# If args provided, show detail view for that command
|
||||
if args.strip():
|
||||
await _show_command_detail(player, args.strip())
|
||||
return
|
||||
|
||||
# Otherwise, show full list
|
||||
# Collect unique commands by CommandDefinition id (avoids alias duplication)
|
||||
seen: set[int] = set()
|
||||
unique_commands: list[CommandDefinition] = []
|
||||
|
||||
for defn in _registry.values():
|
||||
defn_id = id(defn)
|
||||
if defn_id not in seen and not defn.hidden:
|
||||
seen.add(defn_id)
|
||||
unique_commands.append(defn)
|
||||
|
||||
# Group commands by type (excluding combat moves)
|
||||
movement: list[CommandDefinition] = []
|
||||
other: list[CommandDefinition] = []
|
||||
|
||||
for defn in unique_commands:
|
||||
# Check if it's a movement command
|
||||
if defn.name in DIRECTIONS:
|
||||
movement.append(defn)
|
||||
# Skip combat attacks and defenses
|
||||
elif defn.help.startswith("Attack with") or defn.help.startswith("Defend with"):
|
||||
continue
|
||||
# Everything else
|
||||
else:
|
||||
other.append(defn)
|
||||
|
||||
all_commands = movement + other
|
||||
all_commands.sort(key=lambda d: d.name)
|
||||
names = [d.name for d in all_commands]
|
||||
|
||||
await player.send(" ".join(names) + "\r\n")
|
||||
|
||||
|
||||
async def cmd_skills(player: Player, args: str) -> None:
|
||||
"""List all combat skills (attacks and defenses), or show detail for one.
|
||||
|
||||
Args:
|
||||
player: The player executing the command
|
||||
args: Command arguments (skill name for detail view, empty for list)
|
||||
"""
|
||||
# If args provided, show detail view for that skill
|
||||
if args.strip():
|
||||
await _show_command_detail(player, args.strip())
|
||||
return
|
||||
|
||||
# Otherwise, show full list
|
||||
# Collect unique commands by CommandDefinition id (avoids alias duplication)
|
||||
seen: set[int] = set()
|
||||
unique_commands: list[CommandDefinition] = []
|
||||
|
||||
for defn in _registry.values():
|
||||
defn_id = id(defn)
|
||||
if defn_id not in seen and not defn.hidden:
|
||||
seen.add(defn_id)
|
||||
unique_commands.append(defn)
|
||||
|
||||
# Group combat moves
|
||||
attacks: list[CommandDefinition] = []
|
||||
defenses: list[CommandDefinition] = []
|
||||
|
||||
for defn in unique_commands:
|
||||
# Check if it's a combat attack
|
||||
if defn.help.startswith("Attack with"):
|
||||
attacks.append(defn)
|
||||
# Check if it's a combat defense
|
||||
elif defn.help.startswith("Defend with"):
|
||||
defenses.append(defn)
|
||||
|
||||
all_skills = attacks + defenses
|
||||
all_skills.sort(key=lambda d: d.name)
|
||||
names = [d.name for d in all_skills]
|
||||
|
||||
await player.send(" ".join(names) + "\r\n")
|
||||
|
||||
|
||||
# Register the commands command
|
||||
register(
|
||||
CommandDefinition(
|
||||
"commands",
|
||||
cmd_commands,
|
||||
aliases=["cmds"],
|
||||
mode="*",
|
||||
help="list available commands",
|
||||
)
|
||||
)
|
||||
|
||||
# Register the skills command
|
||||
register(
|
||||
CommandDefinition(
|
||||
"skills",
|
||||
cmd_skills,
|
||||
aliases=[],
|
||||
mode="*",
|
||||
help="list combat skills",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def cmd_help(player: Player, args: str) -> None:
|
||||
"""Show help for a command or skill.
|
||||
|
||||
Args:
|
||||
player: The player executing the command
|
||||
args: Command name to get help for
|
||||
"""
|
||||
args = args.strip()
|
||||
if not args:
|
||||
await player.send(
|
||||
"type help <command> for details. see also: commands, skills\r\n"
|
||||
)
|
||||
return
|
||||
await _show_command_detail(player, args)
|
||||
|
||||
|
||||
# Register the help command
|
||||
register(
|
||||
CommandDefinition(
|
||||
"help",
|
||||
cmd_help,
|
||||
aliases=[],
|
||||
mode="*",
|
||||
help="show help for a command",
|
||||
)
|
||||
)
|
||||
|
|
@ -13,6 +13,7 @@ from telnetlib3.server_shell import readline2
|
|||
import mudlib.commands
|
||||
import mudlib.commands.edit
|
||||
import mudlib.commands.fly
|
||||
import mudlib.commands.help
|
||||
import mudlib.commands.look
|
||||
import mudlib.commands.movement
|
||||
import mudlib.commands.quit
|
||||
|
|
|
|||
124
tests/test_collision_detection.py
Normal file
124
tests/test_collision_detection.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Test collision detection when registering commands."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands import CommandDefinition, _registry, register
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry():
|
||||
"""Save and restore registry around each test."""
|
||||
saved = dict(_registry)
|
||||
_registry.clear()
|
||||
yield
|
||||
_registry.clear()
|
||||
_registry.update(saved)
|
||||
|
||||
|
||||
async def dummy_handler(player, args):
|
||||
"""Placeholder command handler."""
|
||||
pass
|
||||
|
||||
|
||||
async def other_handler(player, args):
|
||||
"""Another placeholder command handler."""
|
||||
pass
|
||||
|
||||
|
||||
def test_registering_command_adds_to_registry():
|
||||
"""Baseline: registering a command adds it to registry."""
|
||||
defn = CommandDefinition(name="test", handler=dummy_handler)
|
||||
register(defn)
|
||||
assert "test" in _registry
|
||||
assert _registry["test"] is defn
|
||||
|
||||
|
||||
def test_registering_same_command_again_does_not_warn(caplog):
|
||||
"""Registering same command definition again does not warn."""
|
||||
defn = CommandDefinition(name="test", handler=dummy_handler)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
register(defn)
|
||||
register(defn)
|
||||
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
def test_registering_different_command_with_same_name_warns(caplog):
|
||||
"""Registering different command with same name warns."""
|
||||
defn1 = CommandDefinition(name="test", handler=dummy_handler)
|
||||
defn2 = CommandDefinition(name="test", handler=other_handler)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
register(defn1)
|
||||
register(defn2)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert "collision" in caplog.records[0].message.lower()
|
||||
assert "test" in caplog.records[0].message
|
||||
assert _registry["test"] is defn2 # last-registered wins
|
||||
|
||||
|
||||
def test_command_alias_collides_with_existing_command_name(caplog):
|
||||
"""Registering command whose alias collides with existing name warns."""
|
||||
defn1 = CommandDefinition(name="north", handler=dummy_handler)
|
||||
defn2 = CommandDefinition(name="navigate", handler=other_handler, aliases=["north"])
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
register(defn1)
|
||||
register(defn2)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert "collision" in caplog.records[0].message.lower()
|
||||
assert "north" in caplog.records[0].message
|
||||
|
||||
|
||||
def test_command_alias_collides_with_different_command_alias(caplog):
|
||||
"""Registering command whose alias collides with different command's alias warns."""
|
||||
defn1 = CommandDefinition(name="southwest", handler=dummy_handler, aliases=["sw"])
|
||||
defn2 = CommandDefinition(name="sweep", handler=other_handler, aliases=["sw"])
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
register(defn1)
|
||||
register(defn2)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert "collision" in caplog.records[0].message.lower()
|
||||
assert "sw" in caplog.records[0].message
|
||||
|
||||
|
||||
def test_warning_message_includes_both_command_names(caplog):
|
||||
"""Warning message includes both the existing and new command names."""
|
||||
defn1 = CommandDefinition(name="existing", handler=dummy_handler)
|
||||
defn2 = CommandDefinition(name="newcomer", handler=other_handler)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
register(defn1)
|
||||
register(defn2) # both have name "newcomer" but defn2 overwrites
|
||||
|
||||
# Re-register to trigger collision
|
||||
defn3 = CommandDefinition(name="existing", handler=other_handler)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
caplog.clear()
|
||||
register(defn3)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
record = caplog.records[0].message
|
||||
assert "existing" in record
|
||||
# Should show both the existing command name and the new one
|
||||
assert "existing" in record
|
||||
|
||||
|
||||
def test_same_definition_multiple_aliases_no_warning(caplog):
|
||||
"""Same definition registering multiple aliases to itself doesn't warn."""
|
||||
defn = CommandDefinition(name="test", handler=dummy_handler, aliases=["t", "tst"])
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
register(defn)
|
||||
|
||||
assert len(caplog.records) == 0
|
||||
assert _registry["test"] is defn
|
||||
assert _registry["t"] is defn
|
||||
assert _registry["tst"] is defn
|
||||
|
|
@ -227,6 +227,7 @@ async def test_variant_handler_parses_direction(player, target, moves):
|
|||
|
||||
encounter = get_encounter(player)
|
||||
assert encounter is not None
|
||||
assert encounter.current_move is not None
|
||||
assert encounter.current_move.name == "punch right"
|
||||
|
||||
|
||||
|
|
@ -280,12 +281,13 @@ async def test_direct_handler_passes_move(player, target, punch_right):
|
|||
|
||||
encounter = get_encounter(player)
|
||||
assert encounter is not None
|
||||
assert encounter.current_move is not None
|
||||
assert encounter.current_move.name == "punch right"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_handler_alias_for_variant(player, target, punch_right):
|
||||
"""Test alias handler (e.g. pr) works for variant moves."""
|
||||
"""Test direct handler works for variant moves."""
|
||||
handler = combat_commands._make_direct_handler(
|
||||
punch_right, combat_commands.do_attack
|
||||
)
|
||||
|
|
@ -294,6 +296,7 @@ async def test_direct_handler_alias_for_variant(player, target, punch_right):
|
|||
|
||||
encounter = get_encounter(player)
|
||||
assert encounter is not None
|
||||
assert encounter.current_move is not None
|
||||
assert encounter.current_move.name == "punch right"
|
||||
assert encounter.attacker is player
|
||||
assert encounter.defender is target
|
||||
|
|
|
|||
|
|
@ -476,9 +476,3 @@ def test_load_content_combat_directory():
|
|||
assert "sweep" in moves
|
||||
assert "duck" in moves
|
||||
assert "jump" in moves
|
||||
|
||||
# Verify aliases
|
||||
assert "pl" in moves
|
||||
assert moves["pl"] is moves["punch left"]
|
||||
assert "pr" in moves
|
||||
assert moves["pr"] is moves["punch right"]
|
||||
|
|
|
|||
243
tests/test_commands_list.py
Normal file
243
tests/test_commands_list.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""Tests for the commands listing command."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
|
||||
# Import command modules to register their commands
|
||||
from mudlib.commands import (
|
||||
edit, # noqa: F401
|
||||
fly, # noqa: F401
|
||||
help, # noqa: F401
|
||||
look, # noqa: F401
|
||||
movement, # noqa: F401
|
||||
quit, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
from mudlib.player import Player
|
||||
|
||||
return Player(
|
||||
name="TestPlayer",
|
||||
x=5,
|
||||
y=5,
|
||||
reader=mock_reader,
|
||||
writer=mock_writer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def combat_moves():
|
||||
"""Load and register combat moves from content directory."""
|
||||
from mudlib.combat.commands import register_combat_commands
|
||||
|
||||
combat_dir = Path(__file__).resolve().parents[1] / "content" / "combat"
|
||||
register_combat_commands(combat_dir)
|
||||
|
||||
from mudlib.combat import commands as combat_cmds
|
||||
|
||||
yield combat_cmds.combat_moves
|
||||
|
||||
# Clean up
|
||||
combat_cmds.combat_moves = {}
|
||||
# Note: commands stay registered in _registry, but that's ok for tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_command_exists():
|
||||
"""Test that commands command is registered."""
|
||||
assert "commands" in commands._registry
|
||||
assert "cmds" in commands._registry
|
||||
assert commands._registry["commands"] is commands._registry["cmds"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_has_wildcard_mode():
|
||||
"""Test that commands works from any mode."""
|
||||
assert commands._registry["commands"].mode == "*"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_lists_all_commands(player):
|
||||
"""Test that commands command lists all unique commands."""
|
||||
# Execute the command
|
||||
await commands.dispatch(player, "commands")
|
||||
|
||||
# Collect all output
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should show some specific commands with their primary names
|
||||
assert "north" in output
|
||||
assert "look" in output
|
||||
assert "quit" in output
|
||||
assert "commands" in output
|
||||
|
||||
# Should not contain combat moves
|
||||
assert "punch" not in output
|
||||
assert "roundhouse" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_clean_names_no_parens(player):
|
||||
"""Test that command names are shown cleanly without alias parens."""
|
||||
await commands.dispatch(player, "commands")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Commands should appear without parens
|
||||
assert "north" in output
|
||||
assert "look" in output
|
||||
assert "quit" in output
|
||||
assert "commands" in output
|
||||
|
||||
# Should NOT show aliases in parens
|
||||
assert "north(n)" not in output
|
||||
assert "look(l)" not in output
|
||||
assert "quit(q)" not in output
|
||||
assert "commands(cmds)" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_deduplicates_entries(player):
|
||||
"""Test that aliases don't create duplicate command entries."""
|
||||
await commands.dispatch(player, "commands")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Count how many times "north" appears as a primary command
|
||||
# It should appear once in the movement group, not multiple times
|
||||
# (Note: it might appear in parens as part of another command, but
|
||||
# we're checking it doesn't appear as a standalone entry multiple times)
|
||||
lines_with_north_command = [
|
||||
line for line in output.split("\n") if line.strip().startswith("north")
|
||||
]
|
||||
# Should only have one line starting with "north" as a command
|
||||
assert len(lines_with_north_command) <= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_sorted_alphabetically(player):
|
||||
"""Test that commands are sorted alphabetically."""
|
||||
await commands.dispatch(player, "commands")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# All commands should appear in a single sorted list
|
||||
assert "north" in output
|
||||
assert "east" in output
|
||||
assert "quit" in output
|
||||
assert "look" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_via_alias(player):
|
||||
"""Test that the cmds alias works."""
|
||||
await commands.dispatch(player, "cmds")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should produce the same output as commands
|
||||
assert "north" in output
|
||||
assert "look" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_detail_regular_command(player):
|
||||
"""Test commands detail view for a regular command."""
|
||||
await commands.dispatch(player, "commands look")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "look" in output
|
||||
assert "aliases: l" in output
|
||||
assert "mode: normal" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_detail_simple_combat_move(player, combat_moves):
|
||||
"""Test commands detail view for a simple combat move."""
|
||||
await commands.dispatch(player, "commands roundhouse")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "roundhouse" in output
|
||||
assert "type: attack" in output
|
||||
assert "stamina: 8.0" in output
|
||||
assert "timing window: 2000ms" in output
|
||||
assert "damage: 25%" in output
|
||||
assert "{attacker} spins into a roundhouse kick!" in output
|
||||
assert "countered by: duck, parry high, parry low" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_detail_variant_base(player, combat_moves):
|
||||
"""Test commands detail view for a variant base command."""
|
||||
await commands.dispatch(player, "commands punch")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "punch" in output
|
||||
assert "type: attack" in output
|
||||
|
||||
# Should show both variants
|
||||
assert "punch left" in output
|
||||
assert "{attacker} winds up a left hook!" in output
|
||||
assert "countered by: dodge right, parry high" in output
|
||||
|
||||
assert "punch right" in output
|
||||
assert "{attacker} winds up a right hook!" in output
|
||||
assert "countered by: dodge left, parry high" in output
|
||||
|
||||
# Should show shared properties in each variant
|
||||
assert "stamina: 5.0" in output
|
||||
assert "timing window: 1800ms" in output
|
||||
assert "damage: 15%" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_detail_specific_variant(player, combat_moves):
|
||||
"""Test commands detail view for a specific variant."""
|
||||
await commands.dispatch(player, "commands punch left")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "punch left" in output
|
||||
assert "type: attack" in output
|
||||
assert "stamina: 5.0" in output
|
||||
assert "timing window: 1800ms" in output
|
||||
assert "damage: 15%" in output
|
||||
assert "{attacker} winds up a left hook!" in output
|
||||
assert "countered by: dodge right, parry high" in output
|
||||
|
||||
# Should NOT show "punch right"
|
||||
assert "punch right" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_detail_unknown_command(player):
|
||||
"""Test commands detail view for an unknown command."""
|
||||
await commands.dispatch(player, "commands nonexistent")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "Unknown command: nonexistent" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_detail_via_prefix(player, combat_moves):
|
||||
"""Test commands detail view via prefix matching."""
|
||||
await commands.dispatch(player, "commands ro")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should show the full command details via prefix match
|
||||
assert "roundhouse" in output
|
||||
assert "type: attack" in output
|
||||
|
|
@ -165,3 +165,139 @@ async def test_editor_prompt_uses_cursor(player):
|
|||
# This test verifies the cursor field exists and can be used for prompts
|
||||
assert player.editor.cursor == 1
|
||||
# Shell loop prompt: f" {player.editor.cursor + 1}> " = " 2> "
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_no_args_opens_blank_editor(player):
|
||||
"""Test that edit with no args opens a blank editor."""
|
||||
await cmd_edit(player, "")
|
||||
|
||||
assert player.editor is not None
|
||||
assert player.editor.buffer == []
|
||||
assert player.mode == "editor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_combat_move_opens_toml(player, tmp_path):
|
||||
"""Test that edit roundhouse opens the TOML file for editing."""
|
||||
from mudlib.combat import commands as combat_commands
|
||||
|
||||
# Create a test TOML file
|
||||
toml_content = """name = "roundhouse"
|
||||
aliases = ["rh"]
|
||||
move_type = "attack"
|
||||
stamina_cost = 8.0
|
||||
timing_window_ms = 2000
|
||||
"""
|
||||
toml_file = tmp_path / "roundhouse.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
# Set up combat moves
|
||||
combat_commands.combat_moves = {"roundhouse": MagicMock(command="roundhouse")}
|
||||
combat_commands.combat_content_dir = tmp_path
|
||||
|
||||
await cmd_edit(player, "roundhouse")
|
||||
|
||||
assert player.editor is not None
|
||||
assert player.mode == "editor"
|
||||
assert toml_content in "\n".join(player.editor.buffer)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_combat_move_saves_to_disk(player, tmp_path, mock_writer):
|
||||
"""Test that saving in editor writes back to the TOML file."""
|
||||
from mudlib.combat import commands as combat_commands
|
||||
|
||||
# Create a test TOML file
|
||||
original_content = """name = "roundhouse"
|
||||
aliases = ["rh"]
|
||||
move_type = "attack"
|
||||
"""
|
||||
toml_file = tmp_path / "roundhouse.toml"
|
||||
toml_file.write_text(original_content)
|
||||
|
||||
# Set up combat moves
|
||||
combat_commands.combat_moves = {"roundhouse": MagicMock(command="roundhouse")}
|
||||
combat_commands.combat_content_dir = tmp_path
|
||||
|
||||
await cmd_edit(player, "roundhouse")
|
||||
mock_writer.reset_mock()
|
||||
|
||||
# Modify the buffer
|
||||
player.editor.buffer = [
|
||||
'name = "roundhouse"',
|
||||
'aliases = ["rh"]',
|
||||
'move_type = "attack"',
|
||||
"stamina_cost = 9.0",
|
||||
]
|
||||
|
||||
# Save
|
||||
await player.editor.handle_input(":w")
|
||||
|
||||
# Check that file was written
|
||||
saved_content = toml_file.read_text()
|
||||
assert "stamina_cost = 9.0" in saved_content
|
||||
assert mock_writer.write.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_variant_base_opens_toml(player, tmp_path):
|
||||
"""Test that edit punch opens punch.toml (variant base name)."""
|
||||
from mudlib.combat import commands as combat_commands
|
||||
|
||||
# Create punch.toml with variants
|
||||
toml_content = """name = "punch"
|
||||
move_type = "attack"
|
||||
|
||||
[variants.left]
|
||||
aliases = ["pl"]
|
||||
"""
|
||||
toml_file = tmp_path / "punch.toml"
|
||||
toml_file.write_text(toml_content)
|
||||
|
||||
# Set up combat moves with variant
|
||||
combat_commands.combat_moves = {
|
||||
"punch left": MagicMock(command="punch", variant="left")
|
||||
}
|
||||
combat_commands.combat_content_dir = tmp_path
|
||||
|
||||
await cmd_edit(player, "punch")
|
||||
|
||||
assert player.editor is not None
|
||||
assert player.mode == "editor"
|
||||
assert "[variants.left]" in "\n".join(player.editor.buffer)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_unknown_content_shows_error(player, mock_writer, tmp_path):
|
||||
"""Test that edit nonexistent shows an error."""
|
||||
from mudlib.combat import commands as combat_commands
|
||||
|
||||
combat_commands.combat_moves = {}
|
||||
combat_commands.combat_content_dir = tmp_path
|
||||
|
||||
await cmd_edit(player, "nonexistent")
|
||||
|
||||
assert player.editor is None
|
||||
assert player.mode == "normal"
|
||||
assert mock_writer.write.called
|
||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||
assert "unknown" in output.lower()
|
||||
assert "nonexistent" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_combat_move_uses_toml_content_type(player, tmp_path):
|
||||
"""Test that editor for combat moves uses toml content type."""
|
||||
from mudlib.combat import commands as combat_commands
|
||||
|
||||
toml_file = tmp_path / "roundhouse.toml"
|
||||
toml_file.write_text("name = 'roundhouse'\n")
|
||||
|
||||
combat_commands.combat_moves = {"roundhouse": MagicMock(command="roundhouse")}
|
||||
combat_commands.combat_content_dir = tmp_path
|
||||
|
||||
await cmd_edit(player, "roundhouse")
|
||||
|
||||
assert player.editor is not None
|
||||
assert player.editor.content_type == "toml"
|
||||
|
|
|
|||
84
tests/test_help_command.py
Normal file
84
tests/test_help_command.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Tests for the standalone help command."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
|
||||
# Import command modules to register their commands
|
||||
from mudlib.commands import (
|
||||
help, # noqa: F401
|
||||
look, # noqa: F401
|
||||
movement, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
from mudlib.player import Player
|
||||
|
||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_command_is_registered():
|
||||
"""The help command should be registered in the command registry."""
|
||||
assert "help" in commands._registry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_has_wildcard_mode():
|
||||
"""Help should work from any mode."""
|
||||
cmd_def = commands._registry["help"]
|
||||
assert cmd_def.mode == "*"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_no_args_shows_usage(player):
|
||||
"""help with no args shows usage hint."""
|
||||
await commands.dispatch(player, "help")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "help <command>" in output
|
||||
assert "commands" in output
|
||||
assert "skills" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_known_command_shows_detail(player):
|
||||
"""help <known command> shows detail view."""
|
||||
await commands.dispatch(player, "help look")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "look" in output.lower()
|
||||
assert "mode:" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_unknown_command_shows_error(player):
|
||||
"""help <unknown> shows error message."""
|
||||
await commands.dispatch(player, "help nonexistent")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
assert "nonexistent" in output.lower()
|
||||
assert "unknown" in output.lower() or "not found" in output.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_and_commands_both_exist():
|
||||
"""Both help and commands should be registered independently."""
|
||||
assert "help" in commands._registry
|
||||
assert "commands" in commands._registry
|
||||
# They should be different functions
|
||||
assert commands._registry["help"].handler != commands._registry["commands"].handler
|
||||
235
tests/test_prefix_matching.py
Normal file
235
tests/test_prefix_matching.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Tests for prefix matching in command dispatch."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib import commands
|
||||
from mudlib.commands import CommandDefinition
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
return writer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reader():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(mock_reader, mock_writer):
|
||||
from mudlib.player import Player
|
||||
|
||||
return Player(
|
||||
name="TestPlayer",
|
||||
x=5,
|
||||
y=5,
|
||||
reader=mock_reader,
|
||||
writer=mock_writer,
|
||||
)
|
||||
|
||||
|
||||
async def dummy_handler(player, args):
|
||||
"""A simple handler for testing."""
|
||||
player.writer.write(f"Handler called with args: {args}\r\n")
|
||||
await player.writer.drain()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_registry():
|
||||
"""Clear registry before each test."""
|
||||
original_registry = commands._registry.copy()
|
||||
commands._registry.clear()
|
||||
yield
|
||||
commands._registry.clear()
|
||||
commands._registry.update(original_registry)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_match_still_works(player, clean_registry):
|
||||
"""Test that exact matches work as before."""
|
||||
commands.register(
|
||||
CommandDefinition(name="look", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "look")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "Handler called" in output
|
||||
assert "Unknown command" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_match_resolves_unique_prefix(player, clean_registry):
|
||||
"""Test that a unique prefix resolves to the full command."""
|
||||
commands.register(
|
||||
CommandDefinition(name="sweep", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "swe")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "Handler called" in output
|
||||
assert "Unknown command" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_prefix_two_matches(player, clean_registry):
|
||||
"""Test that ambiguous prefix with 2 matches shows 'A or B?' message."""
|
||||
commands.register(
|
||||
CommandDefinition(name="swoop", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="swallow", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "sw")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Check for either order
|
||||
assert "swallow or swoop?" in output or "swoop or swallow?" in output
|
||||
assert "Handler called" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_prefix_three_plus_matches(player, clean_registry):
|
||||
"""Test that ambiguous prefix with 3+ matches shows comma-separated."""
|
||||
commands.register(
|
||||
CommandDefinition(name="send", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="set", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="settings", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "se")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should contain all three with commas and "or"
|
||||
assert "send" in output
|
||||
assert "set" in output
|
||||
assert "settings" in output
|
||||
assert ", or " in output
|
||||
assert "Handler called" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_shows_unknown_command(player, clean_registry):
|
||||
"""Test that no match shows unknown command message."""
|
||||
commands.register(
|
||||
CommandDefinition(name="look", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "xyz")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "Unknown command: xyz" in output
|
||||
assert "Handler called" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_alias_match_wins_over_prefix(player, clean_registry):
|
||||
"""Test that exact match on alias takes priority over prefix match."""
|
||||
commands.register(
|
||||
CommandDefinition(
|
||||
name="southwest",
|
||||
aliases=["sw"],
|
||||
handler=dummy_handler,
|
||||
mode="normal",
|
||||
)
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="sweep", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "sw")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should call handler (exact match on alias), not show ambiguous
|
||||
assert "Handler called" in output
|
||||
assert "or" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_char_ambiguous_prefix(player, clean_registry):
|
||||
"""Test that single-char ambiguous prefix shows disambiguation."""
|
||||
commands.register(
|
||||
CommandDefinition(name="quit", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="query", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "q")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "quit or query?" in output or "query or quit?" in output
|
||||
assert "Handler called" not in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_match_deduplicates_aliases(player, clean_registry):
|
||||
"""Test that aliases to the same command don't create multiple matches."""
|
||||
commands.register(
|
||||
CommandDefinition(
|
||||
name="long",
|
||||
aliases=["l", "lon"],
|
||||
handler=dummy_handler,
|
||||
mode="normal",
|
||||
)
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="lock", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "lo")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should show just "lock or long?" not multiple entries for long's aliases
|
||||
assert "lock" in output
|
||||
assert "long" in output
|
||||
# Should not list long multiple times
|
||||
output_lower = output.lower()
|
||||
# Count occurrences of the word "long" (not as substring like "lock")
|
||||
# Looking for "long" as a standalone word in disambiguation
|
||||
assert output_lower.count("long") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_match_with_args(player, clean_registry):
|
||||
"""Test that prefix matching preserves arguments."""
|
||||
commands.register(
|
||||
CommandDefinition(name="sweep", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "swe the floor")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
assert "Handler called with args: the floor" in output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alias_exact_match_over_prefix_collision(player, clean_registry):
|
||||
"""Test alias exact match wins over prefix when otherwise ambiguous."""
|
||||
commands.register(
|
||||
CommandDefinition(
|
||||
name="look", aliases=["l"], handler=dummy_handler, mode="normal"
|
||||
)
|
||||
)
|
||||
commands.register(
|
||||
CommandDefinition(name="list", handler=dummy_handler, mode="normal")
|
||||
)
|
||||
|
||||
await commands.dispatch(player, "l")
|
||||
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||
|
||||
# Should call handler (exact match on alias), not show ambiguous
|
||||
assert "Handler called" in output
|
||||
assert "or" not in output
|
||||
Loading…
Reference in a new issue