Compare commits

..

No commits in common. "37766ad69ff7f705aa51ba6d38a5aeef42f684c7" and "e9378bb6fa5929b750788a16894a1c2157bf5b53" have entirely different histories.

20 changed files with 49 additions and 1286 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -5,9 +5,11 @@ timing_window_ms = 1800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]
aliases = ["pl"]
telegraph = "{attacker} winds up a left hook!" telegraph = "{attacker} winds up a left hook!"
countered_by = ["dodge right", "parry high"] countered_by = ["dodge right", "parry high"]
[variants.right] [variants.right]
aliases = ["pr"]
telegraph = "{attacker} winds up a right hook!" telegraph = "{attacker} winds up a right hook!"
countered_by = ["dodge left", "parry high"] countered_by = ["dodge left", "parry high"]

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
"""Combat command handlers.""" """Combat command handlers."""
import asyncio import asyncio
import time
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
@ -12,7 +13,6 @@ from mudlib.player import Player, players
# Combat moves will be injected after loading # Combat moves will be injected after loading
combat_moves: dict[str, CombatMove] = {} combat_moves: dict[str, CombatMove] = {}
combat_content_dir: Path | None = None
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
@ -171,10 +171,7 @@ def register_combat_commands(content_dir: Path) -> None:
Args: Args:
content_dir: Path to directory containing combat move TOML files content_dir: Path to directory containing combat move TOML files
""" """
global combat_moves, combat_content_dir global combat_moves
# Save content directory for use by edit command
combat_content_dir = content_dir
# Load all moves from content directory # Load all moves from content directory
combat_moves = load_moves(content_dir) combat_moves = load_moves(content_dir)
@ -203,7 +200,7 @@ def register_combat_commands(content_dir: Path) -> None:
CommandDefinition( CommandDefinition(
name=move.name, name=move.name,
handler=_make_direct_handler(move, handler_fn), handler=_make_direct_handler(move, handler_fn),
aliases=[], aliases=move.aliases,
mode=mode, mode=mode,
help=f"{action} with {move.name}", help=f"{action} with {move.name}",
) )
@ -216,6 +213,11 @@ def register_combat_commands(content_dir: Path) -> None:
handler_fn = do_attack if first_variant.move_type == "attack" else do_defend handler_fn = do_attack if first_variant.move_type == "attack" else do_defend
mode = "*" 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") # Register base command with variant handler (e.g. "punch")
action = "Attack" if first_variant.move_type == "attack" else "Defend" action = "Attack" if first_variant.move_type == "attack" else "Defend"
register( register(
@ -227,3 +229,17 @@ def register_combat_commands(content_dir: Path) -> None:
help=f"{action} with {base_name}", 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,14 +1,10 @@
"""Command registry and dispatcher.""" """Command registry and dispatcher."""
import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import cast
from mudlib.player import Player from mudlib.player import Player
logger = logging.getLogger(__name__)
# Type alias for command handlers # Type alias for command handlers
CommandHandler = Callable[[Player, str], Awaitable[None]] CommandHandler = Callable[[Player, str], Awaitable[None]]
@ -22,7 +18,6 @@ class CommandDefinition:
aliases: list[str] = field(default_factory=list) aliases: list[str] = field(default_factory=list)
mode: str = "normal" mode: str = "normal"
help: str = "" help: str = ""
hidden: bool = False
# Registry maps command names to definitions # Registry maps command names to definitions
@ -35,71 +30,11 @@ def register(defn: CommandDefinition) -> None:
Args: Args:
defn: The command definition to register 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 _registry[defn.name] = defn
# Check for collisions on each alias
for alias in defn.aliases: 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 _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: async def dispatch(player: Player, raw_input: str) -> None:
"""Parse input, find command, call handler. """Parse input, find command, call handler.
@ -117,28 +52,13 @@ async def dispatch(player: Player, raw_input: str) -> None:
command = parts[0].lower() command = parts[0].lower()
args = parts[1] if len(parts) > 1 else "" args = parts[1] if len(parts) > 1 else ""
# Resolve command by exact match or prefix # Look up the definition
result = resolve_prefix(command) defn = _registry.get(command)
if result is None: if defn is None:
# No matches
player.writer.write(f"Unknown command: {command}\r\n") player.writer.write(f"Unknown command: {command}\r\n")
await player.writer.drain() await player.writer.drain()
return 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 # Check mode restriction
if defn.mode != "*" and defn.mode != player.mode: if defn.mode != "*" and defn.mode != player.mode:

View file

@ -1,7 +1,5 @@
"""Edit command for entering the text editor.""" """Edit command for entering the text editor."""
from pathlib import Path
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.editor import Editor from mudlib.editor import Editor
from mudlib.player import Player from mudlib.player import Player
@ -11,80 +9,21 @@ async def cmd_edit(player: Player, args: str) -> None:
"""Enter the text editor. """Enter the text editor.
Args: Args:
player: The player entering the editor player: The player executing the command
args: Optional argument - combat move name to edit args: Command arguments (unused for now)
""" """
args = args.strip()
# Default: blank editor async def save_callback(content: str) -> None:
initial_content = "" await player.send("Content saved.\r\n")
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( player.editor = Editor(
save_callback=save_callback_fn, save_callback=save_callback,
content_type=content_type, content_type="text",
color_depth=player.color_depth, color_depth=player.color_depth,
initial_content=initial_content,
) )
player.mode_stack.append("editor") player.mode_stack.append("editor")
await player.send("Entering editor. Type :h for help.\r\n") 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 the edit command
register(CommandDefinition("edit", cmd_edit, mode="normal")) register(CommandDefinition("edit", cmd_edit, mode="normal"))

View file

@ -1,303 +0,0 @@
"""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,7 +13,6 @@ from telnetlib3.server_shell import readline2
import mudlib.commands import mudlib.commands
import mudlib.commands.edit import mudlib.commands.edit
import mudlib.commands.fly import mudlib.commands.fly
import mudlib.commands.help
import mudlib.commands.look import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
import mudlib.commands.quit import mudlib.commands.quit

View file

@ -1,124 +0,0 @@
"""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,7 +227,6 @@ async def test_variant_handler_parses_direction(player, target, moves):
encounter = get_encounter(player) encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
assert encounter.current_move is not None
assert encounter.current_move.name == "punch right" assert encounter.current_move.name == "punch right"
@ -281,13 +280,12 @@ async def test_direct_handler_passes_move(player, target, punch_right):
encounter = get_encounter(player) encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
assert encounter.current_move is not None
assert encounter.current_move.name == "punch right" assert encounter.current_move.name == "punch right"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_direct_handler_alias_for_variant(player, target, punch_right): async def test_direct_handler_alias_for_variant(player, target, punch_right):
"""Test direct handler works for variant moves.""" """Test alias handler (e.g. pr) works for variant moves."""
handler = combat_commands._make_direct_handler( handler = combat_commands._make_direct_handler(
punch_right, combat_commands.do_attack punch_right, combat_commands.do_attack
) )
@ -296,7 +294,6 @@ async def test_direct_handler_alias_for_variant(player, target, punch_right):
encounter = get_encounter(player) encounter = get_encounter(player)
assert encounter is not None assert encounter is not None
assert encounter.current_move is not None
assert encounter.current_move.name == "punch right" assert encounter.current_move.name == "punch right"
assert encounter.attacker is player assert encounter.attacker is player
assert encounter.defender is target assert encounter.defender is target

View file

@ -476,3 +476,9 @@ def test_load_content_combat_directory():
assert "sweep" in moves assert "sweep" in moves
assert "duck" in moves assert "duck" in moves
assert "jump" 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"]

View file

@ -1,243 +0,0 @@
"""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,139 +165,3 @@ async def test_editor_prompt_uses_cursor(player):
# This test verifies the cursor field exists and can be used for prompts # This test verifies the cursor field exists and can be used for prompts
assert player.editor.cursor == 1 assert player.editor.cursor == 1
# Shell loop prompt: f" {player.editor.cursor + 1}> " = " 2> " # 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

@ -1,84 +0,0 @@
"""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

@ -1,235 +0,0 @@
"""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