Add reload command for hot-reloading TOML content
This commit is contained in:
parent
37766ad69f
commit
77c2e40e0e
6 changed files with 312 additions and 21 deletions
|
|
@ -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")
|
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.
|
"""Create a handler bound to a specific move.
|
||||||
|
|
||||||
Used for simple moves (roundhouse) and variant aliases (pl, pr).
|
Used for simple moves (roundhouse) and variant aliases (pl, pr).
|
||||||
|
|
@ -135,7 +135,7 @@ def _make_direct_handler(move: CombatMove, handler_fn):
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
def _make_variant_handler(
|
def make_variant_handler(
|
||||||
base_name: str, variant_moves: dict[str, CombatMove], handler_fn
|
base_name: str, variant_moves: dict[str, CombatMove], handler_fn
|
||||||
):
|
):
|
||||||
"""Create a handler for a move with directional variants.
|
"""Create a handler for a move with directional variants.
|
||||||
|
|
@ -165,26 +165,18 @@ def _make_variant_handler(
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
def register_combat_commands(content_dir: Path) -> None:
|
def register_moves(moves: list[CombatMove]) -> None:
|
||||||
"""Load and register all combat moves as commands.
|
"""Register a list of combat moves as commands.
|
||||||
|
|
||||||
Args:
|
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
|
# Group variant moves by their base command
|
||||||
variant_groups: dict[str, dict[str, CombatMove]] = defaultdict(dict)
|
variant_groups: dict[str, dict[str, CombatMove]] = defaultdict(dict)
|
||||||
simple_moves: list[CombatMove] = []
|
simple_moves: list[CombatMove] = []
|
||||||
registered_names: set[str] = set()
|
registered_names: set[str] = set()
|
||||||
|
|
||||||
for move in combat_moves.values():
|
for move in moves:
|
||||||
if move.name in registered_names:
|
if move.name in registered_names:
|
||||||
continue
|
continue
|
||||||
registered_names.add(move.name)
|
registered_names.add(move.name)
|
||||||
|
|
@ -202,7 +194,7 @@ def register_combat_commands(content_dir: Path) -> None:
|
||||||
register(
|
register(
|
||||||
CommandDefinition(
|
CommandDefinition(
|
||||||
name=move.name,
|
name=move.name,
|
||||||
handler=_make_direct_handler(move, handler_fn),
|
handler=make_direct_handler(move, handler_fn),
|
||||||
aliases=[],
|
aliases=[],
|
||||||
mode=mode,
|
mode=mode,
|
||||||
help=f"{action} with {move.name}",
|
help=f"{action} with {move.name}",
|
||||||
|
|
@ -221,9 +213,27 @@ def register_combat_commands(content_dir: Path) -> None:
|
||||||
register(
|
register(
|
||||||
CommandDefinition(
|
CommandDefinition(
|
||||||
name=base_name,
|
name=base_name,
|
||||||
handler=_make_variant_handler(base_name, variants, handler_fn),
|
handler=make_variant_handler(base_name, variants, handler_fn),
|
||||||
aliases=[],
|
aliases=[],
|
||||||
mode=mode,
|
mode=mode,
|
||||||
help=f"{action} with {base_name}",
|
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()))
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,30 @@ class CommandDefinition:
|
||||||
_registry: dict[str, 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:
|
def register(defn: CommandDefinition) -> None:
|
||||||
"""Register a command definition with its aliases.
|
"""Register a command definition with its aliases.
|
||||||
|
|
||||||
|
|
|
||||||
143
src/mudlib/commands/reload.py
Normal file
143
src/mudlib/commands/reload.py
Normal file
|
|
@ -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 <name>\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"))
|
||||||
|
|
@ -17,6 +17,7 @@ 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
|
||||||
|
import mudlib.commands.reload
|
||||||
from mudlib.caps import parse_mtts
|
from mudlib.caps import parse_mtts
|
||||||
from mudlib.combat.commands import register_combat_commands
|
from mudlib.combat.commands import register_combat_commands
|
||||||
from mudlib.combat.engine import process_combat
|
from mudlib.combat.engine import process_combat
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ async def test_variant_handler_parses_direction(player, target, moves):
|
||||||
"left": moves["punch left"],
|
"left": moves["punch left"],
|
||||||
"right": moves["punch right"],
|
"right": moves["punch right"],
|
||||||
}
|
}
|
||||||
handler = combat_commands._make_variant_handler(
|
handler = combat_commands.make_variant_handler(
|
||||||
"punch", variant_moves, combat_commands.do_attack
|
"punch", variant_moves, combat_commands.do_attack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -238,7 +238,7 @@ async def test_variant_handler_no_direction(player, moves):
|
||||||
"left": moves["punch left"],
|
"left": moves["punch left"],
|
||||||
"right": moves["punch right"],
|
"right": moves["punch right"],
|
||||||
}
|
}
|
||||||
handler = combat_commands._make_variant_handler(
|
handler = combat_commands.make_variant_handler(
|
||||||
"punch", variant_moves, combat_commands.do_attack
|
"punch", variant_moves, combat_commands.do_attack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@ async def test_variant_handler_bad_direction(player, moves):
|
||||||
"left": moves["punch left"],
|
"left": moves["punch left"],
|
||||||
"right": moves["punch right"],
|
"right": moves["punch right"],
|
||||||
}
|
}
|
||||||
handler = combat_commands._make_variant_handler(
|
handler = combat_commands.make_variant_handler(
|
||||||
"punch", variant_moves, combat_commands.do_attack
|
"punch", variant_moves, combat_commands.do_attack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -273,7 +273,7 @@ async def test_variant_handler_bad_direction(player, moves):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_direct_handler_passes_move(player, target, punch_right):
|
async def test_direct_handler_passes_move(player, target, punch_right):
|
||||||
"""Test the direct handler passes the bound move through."""
|
"""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
|
punch_right, combat_commands.do_attack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -288,7 +288,7 @@ async def test_direct_handler_passes_move(player, target, 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 direct handler 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
113
tests/test_reload.py
Normal file
113
tests/test_reload.py
Normal file
|
|
@ -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 <name>" 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
|
||||||
Loading…
Reference in a new issue