Compare commits

..

3 commits

15 changed files with 330 additions and 21 deletions

View file

@ -1,4 +1,5 @@
name = "dodge"
description = "a quick sidestep to evade incoming attacks"
move_type = "defense"
stamina_cost = 3.0
timing_window_ms = 800

View file

@ -1,4 +1,5 @@
name = "duck"
description = "crouch down to avoid high attacks, leaving you vulnerable to low strikes"
move_type = "defense"
stamina_cost = 3.0
telegraph = ""

View file

@ -1,4 +1,5 @@
name = "jump"
description = "leap upward to evade low attacks, exposing you to high strikes"
move_type = "defense"
stamina_cost = 4.0
telegraph = ""

View file

@ -1,4 +1,5 @@
name = "parry"
description = "deflect an attack with precise timing, redirecting force rather than absorbing it"
move_type = "defense"
stamina_cost = 4.0
timing_window_ms = 1200

View file

@ -1,4 +1,5 @@
name = "punch"
description = "a close-range strike with the fist, quick but predictable"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 1800

View file

@ -1,4 +1,5 @@
name = "roundhouse"
description = "a spinning kick that sacrifices speed for devastating power"
move_type = "attack"
stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!"

View file

@ -1,4 +1,5 @@
name = "sweep"
description = "a low kick targeting the legs, designed to take an opponent off balance"
move_type = "attack"
stamina_cost = 6.0
telegraph = "{attacker} drops low for a leg sweep!"

View file

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

View file

@ -29,6 +29,7 @@ class CombatMove:
command: str = ""
# variant key ("left", "right", "" for simple moves)
variant: str = ""
description: str = ""
def load_move(path: Path) -> list[CombatMove]:
@ -83,6 +84,7 @@ def load_move(path: Path) -> list[CombatMove]:
handler=None,
command=base_name,
variant=variant_key,
description=data.get("description", ""),
)
)
return moves
@ -101,6 +103,7 @@ def load_move(path: Path) -> list[CombatMove]:
handler=None,
command=base_name,
variant="",
description=data.get("description", ""),
)
]

View file

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

View file

@ -86,6 +86,10 @@ async def _show_single_command(
lines = [defn.name]
# Show description first for combat moves (most important context)
if move is not None and move.description:
lines.append(f" {move.description}")
# Always show aliases
if defn.aliases:
aliases_str = ", ".join(defn.aliases)
@ -135,6 +139,10 @@ async def _show_variant_overview(
lines = [defn.name]
# Show description from first variant (they all share the same one)
if variants and variants[0].description:
lines.append(f" {variants[0].description}")
# Show type from first variant
if variants:
lines.append(f" type: {variants[0].move_type}")

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

View file

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

View file

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

113
tests/test_reload.py Normal file
View 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