Compare commits

..

22 commits

Author SHA1 Message Date
37766ad69f
Fix typecheck errors in prefix disambiguation 2026-02-08 13:50:26 -05:00
8c83130f67
Add test for alias exact match over prefix 2026-02-08 13:48:32 -05:00
68fa08d776
Extract shared prefix matching helper 2026-02-08 13:48:00 -05:00
7c21310dcd
Remove alias display from command and skill listings 2026-02-08 13:42:00 -05:00
6e9a89f21e
Update tests for clean command listings 2026-02-08 13:41:36 -05:00
7ae56106d6
Update tests for alias removal 2026-02-08 13:39:58 -05:00
841714d6ca
Update tests for alias removal 2026-02-08 13:37:40 -05:00
64b6308bc6
Remove hidden alias registration from combat commands 2026-02-08 13:36:45 -05:00
c385f559a3
Strip aliases from TOML content files 2026-02-08 13:36:20 -05:00
e00591b6bf
Fix test fixture to not interfere with other tests 2026-02-08 13:34:46 -05:00
20f33f45e1
Implement standalone help command 2026-02-08 13:33:46 -05:00
7c313ae307
Implement prefix matching in dispatch 2026-02-08 13:33:22 -05:00
7d3b02f6ff
Implement collision detection in register 2026-02-08 13:33:19 -05:00
5efdbaf4e6
Add help command tests 2026-02-08 13:32:50 -05:00
1a122b85e5
Add collision detection tests 2026-02-08 13:32:49 -05:00
f1e4cfa4dd
Add prefix matching tests 2026-02-08 13:32:32 -05:00
529320acb5
Split commands and skills into separate listings 2026-02-08 13:15:04 -05:00
a8917da59e
Fix typecheck warnings for nullable current_move 2026-02-08 13:05:16 -05:00
9e5003e52c
Hide variant aliases from commands listing 2026-02-08 13:04:54 -05:00
d0c33911f3
Wire edit command to open combat TOML files 2026-02-08 12:44:56 -05:00
1b63f87da7
Add detail view to commands command 2026-02-08 12:41:02 -05:00
6fb48e9ae1
Add commands command to list available commands 2026-02-08 12:35:50 -05:00
20 changed files with 1286 additions and 49 deletions

View file

@ -4,7 +4,5 @@ stamina_cost = 3.0
timing_window_ms = 800
[variants.left]
aliases = ["dl"]
[variants.right]
aliases = ["dr"]

View file

@ -1,5 +1,4 @@
name = "duck"
aliases = []
move_type = "defense"
stamina_cost = 3.0
telegraph = ""

View file

@ -1,5 +1,4 @@
name = "jump"
aliases = []
move_type = "defense"
stamina_cost = 4.0
telegraph = ""

View file

@ -4,7 +4,5 @@ stamina_cost = 4.0
timing_window_ms = 1200
[variants.high]
aliases = ["f"]
[variants.low]
aliases = ["v"]

View file

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

View file

@ -1,5 +1,4 @@
name = "roundhouse"
aliases = ["rh"]
move_type = "attack"
stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!"

View file

@ -1,5 +1,4 @@
name = "sweep"
aliases = ["sw"]
move_type = "attack"
stamina_cost = 6.0
telegraph = "{attacker} drops low for a leg sweep!"

View file

@ -1,5 +1,4 @@
name = "motd"
aliases = ["message"]
help = "display the message of the day"
mode = "*"
message = """

View file

@ -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}",
)
)

View file

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

View file

@ -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
View 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",
)
)

View file

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

View 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

View file

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

View file

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

View file

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

View 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

View 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