Compare commits
3 commits
37766ad69f
...
67781578a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 67781578a3 | |||
| 9c1c6a9e22 | |||
| 77c2e40e0e |
15 changed files with 330 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
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.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
|
||||
|
|
|
|||
|
|
@ -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
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