From 77c2e40e0ef5931d80878bc1886ed1c0b43d45e4 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 14:14:46 -0500 Subject: [PATCH] Add reload command for hot-reloading TOML content --- src/mudlib/combat/commands.py | 42 ++++++---- src/mudlib/commands/__init__.py | 24 ++++++ src/mudlib/commands/reload.py | 143 ++++++++++++++++++++++++++++++++ src/mudlib/server.py | 1 + tests/test_combat_commands.py | 10 +-- tests/test_reload.py | 113 +++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 21 deletions(-) create mode 100644 src/mudlib/commands/reload.py create mode 100644 tests/test_reload.py diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 5f329a1..de9d26c 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -122,7 +122,7 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None: await player.send(f"You {move.command} the air!\r\n") -def _make_direct_handler(move: CombatMove, handler_fn): +def make_direct_handler(move: CombatMove, handler_fn): """Create a handler bound to a specific move. Used for simple moves (roundhouse) and variant aliases (pl, pr). @@ -135,7 +135,7 @@ def _make_direct_handler(move: CombatMove, handler_fn): return handler -def _make_variant_handler( +def make_variant_handler( base_name: str, variant_moves: dict[str, CombatMove], handler_fn ): """Create a handler for a move with directional variants. @@ -165,26 +165,18 @@ def _make_variant_handler( return handler -def register_combat_commands(content_dir: Path) -> None: - """Load and register all combat moves as commands. +def register_moves(moves: list[CombatMove]) -> None: + """Register a list of combat moves as commands. Args: - content_dir: Path to directory containing combat move TOML files + moves: List of combat moves to register """ - 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) - # Group variant moves by their base command variant_groups: dict[str, dict[str, CombatMove]] = defaultdict(dict) simple_moves: list[CombatMove] = [] registered_names: set[str] = set() - for move in combat_moves.values(): + for move in moves: if move.name in registered_names: continue registered_names.add(move.name) @@ -202,7 +194,7 @@ def register_combat_commands(content_dir: Path) -> None: register( CommandDefinition( name=move.name, - handler=_make_direct_handler(move, handler_fn), + handler=make_direct_handler(move, handler_fn), aliases=[], mode=mode, help=f"{action} with {move.name}", @@ -221,9 +213,27 @@ def register_combat_commands(content_dir: Path) -> None: register( CommandDefinition( name=base_name, - handler=_make_variant_handler(base_name, variants, handler_fn), + handler=make_variant_handler(base_name, variants, handler_fn), aliases=[], mode=mode, help=f"{action} with {base_name}", ) ) + + +def register_combat_commands(content_dir: Path) -> None: + """Load and register all combat moves as commands. + + Args: + content_dir: Path to directory containing combat move TOML files + """ + 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) + + # Register all loaded moves + register_moves(list(combat_moves.values())) diff --git a/src/mudlib/commands/__init__.py b/src/mudlib/commands/__init__.py index 4484b4d..4f84bad 100644 --- a/src/mudlib/commands/__init__.py +++ b/src/mudlib/commands/__init__.py @@ -29,6 +29,30 @@ class CommandDefinition: _registry: dict[str, CommandDefinition] = {} +def unregister(name: str) -> None: + """Unregister a command and all its aliases. + + Args: + name: The main command name to unregister + """ + # Find the definition by name + defn = _registry.get(name) + if defn is None: + return + + # Remove main name + _registry.pop(name, None) + + # Remove all aliases + for alias in defn.aliases: + _registry.pop(alias, None) + + # Also remove any other keys pointing to this same definition + keys_to_remove = [k for k, v in _registry.items() if v is defn] + for key in keys_to_remove: + del _registry[key] + + def register(defn: CommandDefinition) -> None: """Register a command definition with its aliases. diff --git a/src/mudlib/commands/reload.py b/src/mudlib/commands/reload.py new file mode 100644 index 0000000..124357e --- /dev/null +++ b/src/mudlib/commands/reload.py @@ -0,0 +1,143 @@ +"""Reload command for hot-reloading TOML content definitions.""" + +import logging +from pathlib import Path + +from mudlib.commands import CommandDefinition, register +from mudlib.player import Player + +log = logging.getLogger(__name__) + + +async def cmd_reload(player: Player, args: str) -> None: + """Reload a TOML content definition by name. + + Args: + player: The player executing the command + args: Name of the content to reload (e.g. "punch", "motd") + """ + args = args.strip() + + if not args: + await player.send("Usage: reload \r\n") + return + + # Try combat move first + combat_reloaded = await _try_reload_combat(player, args) + if combat_reloaded: + return + + # Try content command + command_reloaded = await _try_reload_command(player, args) + if command_reloaded: + return + + # Not found in either location + await player.send(f"No content found with name: {args}\r\n") + + +async def _try_reload_combat(player: Player, name: str) -> bool: + """Try to reload a combat move TOML. + + Args: + player: The player executing the reload + name: Base name of the move (e.g. "punch") + + Returns: + True if the reload succeeded, False if the file doesn't exist + """ + from mudlib.combat.commands import combat_content_dir, combat_moves, register_moves + from mudlib.combat.moves import load_move + from mudlib.commands import unregister + + if combat_content_dir is None: + return False + + toml_path = combat_content_dir / f"{name}.toml" + if not toml_path.exists(): + return False + + try: + # Load the new move definitions + new_moves = load_move(toml_path) + + # Unregister old command entries from registry + # For variant moves, unregister the base command (e.g. "punch") + # For simple moves, unregister the move name (e.g. "roundhouse") + if new_moves and new_moves[0].variant: + # Variant move - unregister base command + unregister(new_moves[0].command) + else: + # Simple move - unregister each move name + for move in new_moves: + unregister(move.name) + + # Remove old entries from combat_moves dict + # Need to remove all variants and aliases + keys_to_remove = [] + for key, move in combat_moves.items(): + if move.command == name: + keys_to_remove.append(key) + + for key in keys_to_remove: + del combat_moves[key] + + # Add new moves to combat_moves dict + for move in new_moves: + combat_moves[move.name] = move + for alias in move.aliases: + combat_moves[alias] = move + + # Re-register commands using shared logic + register_moves(new_moves) + + await player.send(f"Reloaded combat move: {name}\r\n") + return True + + except Exception as e: + log.exception("Failed to reload combat move %s", name) + await player.send(f"Failed to reload {name}: {e}\r\n") + return True + + +async def _try_reload_command(player: Player, name: str) -> bool: + """Try to reload a content command TOML. + + Args: + player: The player executing the reload + name: Name of the command (e.g. "motd") + + Returns: + True if the reload succeeded, False if the file doesn't exist + """ + from mudlib.commands import unregister + from mudlib.content import load_command + + # Find content directory + content_dir = Path(__file__).resolve().parents[2] / "content" / "commands" + toml_path = content_dir / f"{name}.toml" + + if not toml_path.exists(): + return False + + try: + # Load the new command definition + cmd_def = load_command(toml_path) + + # Unregister old command entry before re-registering + unregister(cmd_def.name) + + # Register the new definition + register(cmd_def) + + await player.send(f"Reloaded command: {name}\r\n") + return True + + except Exception as e: + log.exception("Failed to reload command %s", name) + await player.send(f"Failed to reload {name}: {e}\r\n") + return True + + +# Register the reload command +register(CommandDefinition("reload", cmd_reload, mode="normal", help="reload a TOML")) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 2dbf1a3..52ec5f8 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -17,6 +17,7 @@ import mudlib.commands.help import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit +import mudlib.commands.reload from mudlib.caps import parse_mtts from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index e9bd98c..2dd2f5f 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -219,7 +219,7 @@ async def test_variant_handler_parses_direction(player, target, moves): "left": moves["punch left"], "right": moves["punch right"], } - handler = combat_commands._make_variant_handler( + handler = combat_commands.make_variant_handler( "punch", variant_moves, combat_commands.do_attack ) @@ -238,7 +238,7 @@ async def test_variant_handler_no_direction(player, moves): "left": moves["punch left"], "right": moves["punch right"], } - handler = combat_commands._make_variant_handler( + handler = combat_commands.make_variant_handler( "punch", variant_moves, combat_commands.do_attack ) @@ -256,7 +256,7 @@ async def test_variant_handler_bad_direction(player, moves): "left": moves["punch left"], "right": moves["punch right"], } - handler = combat_commands._make_variant_handler( + handler = combat_commands.make_variant_handler( "punch", variant_moves, combat_commands.do_attack ) @@ -273,7 +273,7 @@ async def test_variant_handler_bad_direction(player, moves): @pytest.mark.asyncio async def test_direct_handler_passes_move(player, target, punch_right): """Test the direct handler passes the bound move through.""" - handler = combat_commands._make_direct_handler( + handler = combat_commands.make_direct_handler( punch_right, combat_commands.do_attack ) @@ -288,7 +288,7 @@ async def test_direct_handler_passes_move(player, target, punch_right): @pytest.mark.asyncio async def test_direct_handler_alias_for_variant(player, target, punch_right): """Test direct handler works for variant moves.""" - handler = combat_commands._make_direct_handler( + handler = combat_commands.make_direct_handler( punch_right, combat_commands.do_attack ) diff --git a/tests/test_reload.py b/tests/test_reload.py new file mode 100644 index 0000000..8718a33 --- /dev/null +++ b/tests/test_reload.py @@ -0,0 +1,113 @@ +"""Tests for the reload command.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.combat import commands as combat_commands +from mudlib.combat.moves import load_moves +from mudlib.commands.reload import cmd_reload +from mudlib.player import Player, players + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear players before and after each test.""" + players.clear() + yield + players.clear() + + +@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): + """Create a test player.""" + p = Player(name="Tester", x=0, y=0, reader=mock_reader, writer=mock_writer) + players[p.name] = p + return p + + +@pytest.fixture +def moves(): + """Load combat moves from content directory.""" + content_dir = Path(__file__).parent.parent / "content" / "combat" + return load_moves(content_dir) + + +@pytest.fixture(autouse=True) +def inject_moves(moves): + """Inject loaded moves into combat commands module.""" + combat_commands.combat_moves = moves + # Set the combat content dir so reload can find files + combat_commands.combat_content_dir = ( + Path(__file__).parent.parent / "content" / "combat" + ) + yield + combat_commands.combat_moves = {} + combat_commands.combat_content_dir = None + + +@pytest.mark.asyncio +async def test_reload_no_args(player, mock_writer): + """Test reload command with no arguments shows usage.""" + await cmd_reload(player, "") + assert mock_writer.write.called + written_text = mock_writer.write.call_args[0][0] + assert "Usage: reload " in written_text + + +@pytest.mark.asyncio +async def test_reload_nonexistent(player, mock_writer): + """Test reload command with nonexistent content.""" + await cmd_reload(player, "nonexistent_file_xyz") + assert mock_writer.write.called + written_text = mock_writer.write.call_args[0][0] + assert "No content found with name: nonexistent_file_xyz" in written_text + + +@pytest.mark.asyncio +async def test_reload_combat_move(player, mock_writer): + """Test reloading a combat move TOML.""" + # Reload roundhouse (simple move) + await cmd_reload(player, "roundhouse") + + # Verify success message + assert mock_writer.write.called + written_text = mock_writer.write.call_args[0][0] + assert "Reloaded combat move: roundhouse" in written_text + + # Verify it's in the combat_moves dict + from mudlib.combat.commands import combat_moves + + assert "roundhouse" in combat_moves + + +@pytest.mark.asyncio +async def test_reload_variant_move(player, mock_writer): + """Test reloading a variant combat move (e.g. punch).""" + # Reload punch + await cmd_reload(player, "punch") + + # Verify success message + assert mock_writer.write.called + written_text = mock_writer.write.call_args[0][0] + assert "Reloaded combat move: punch" in written_text + + # Verify variants are in combat_moves + from mudlib.combat.commands import combat_moves + + assert "punch left" in combat_moves + assert "punch right" in combat_moves