mud/tests/test_combat_commands.py
Jared Miller 6344c09275
Restructure combat moves: single-word commands with variant args
The DREAMBOOK always described "punch right/left [target]" as one command
with a direction argument, but the implementation had separate TOML files
and multi-word command names that the dispatcher couldn't reach (it only
matches the first word). Aliases like "pr" also couldn't pass targets
because the shared handler tried to re-derive the move from args.

Changes:
- Merge punch_left/right, dodge_left/right, parry_high/low into single
  TOML files with [variants] sections
- Add command/variant fields to CombatMove for tracking move families
- load_move() now returns list[CombatMove], expanding variants
- Handlers bound to moves via closures at registration time:
  variant handler for base commands (punch → parses direction from args),
  direct handler for aliases and simple moves (pr → move already known)
- Core logic in do_attack/do_defend takes a resolved move
- Combat doc rewritten as rst with architecture details
- Simplify mud.tin aliases (pr/pl/etc are built-in MUD commands now)
2026-02-08 00:20:52 -05:00

290 lines
8.2 KiB
Python

"""Tests for combat commands."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves
from mudlib.player import Player, players
@pytest.fixture(autouse=True)
def clear_state():
"""Clear encounters and players before and after each test."""
active_encounters.clear()
players.clear()
yield
active_encounters.clear()
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):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def target(mock_reader, mock_writer):
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[t.name] = t
return t
@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
yield
combat_commands.combat_moves = {}
@pytest.fixture
def punch_right(moves):
"""Get the punch right move."""
return moves["punch right"]
@pytest.fixture
def punch_left(moves):
"""Get the punch left move."""
return moves["punch left"]
@pytest.fixture
def dodge_left(moves):
"""Get the dodge left move."""
return moves["dodge left"]
# --- do_attack tests ---
@pytest.mark.asyncio
async def test_attack_starts_combat_with_target(player, target, punch_right):
"""Test do_attack with target starts combat encounter."""
await combat_commands.do_attack(player, "Vegeta", punch_right)
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is target
assert player.mode == "combat"
@pytest.mark.asyncio
async def test_attack_without_target_when_not_in_combat(player, punch_right):
"""Test do_attack without target when not in combat gives error."""
await combat_commands.do_attack(player, "", punch_right)
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "need a target" in message.lower()
@pytest.mark.asyncio
async def test_attack_without_target_when_in_combat(
player, target, punch_right, punch_left
):
"""Test do_attack without target when in combat uses implicit target."""
# Start combat first
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Reset mock to track new calls
player.writer.write.reset_mock()
# Attack without target should work
await combat_commands.do_attack(player, "", punch_left)
encounter = get_encounter(player)
assert encounter is not None
assert encounter.current_move is not None
assert encounter.current_move.name == "punch left"
@pytest.mark.asyncio
async def test_attack_insufficient_stamina(player, target, punch_right):
"""Test do_attack with insufficient stamina gives error."""
player.stamina = 1.0 # Not enough for punch (costs 5)
await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "stamina" in message.lower()
@pytest.mark.asyncio
async def test_attack_sends_telegraph_to_defender(player, target, punch_right):
"""Test do_attack sends telegraph message to defender."""
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Check that encounter has the move
encounter = get_encounter(player)
assert encounter is not None
assert encounter.current_move is not None
assert encounter.current_move.name == "punch right"
# --- do_defend tests ---
@pytest.mark.asyncio
async def test_defense_only_in_combat(player, dodge_left):
"""Test do_defend only works in combat mode."""
await combat_commands.do_defend(player, "", dodge_left)
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "not in combat" in message.lower()
@pytest.mark.asyncio
async def test_defense_records_pending_defense(player, target, punch_right, dodge_left):
"""Test do_defend records the defense move."""
# Start combat
await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.reset_mock()
# Switch to defender's perspective
target.writer = player.writer
target.mode_stack = ["combat"]
# Defend
await combat_commands.do_defend(target, "", dodge_left)
encounter = get_encounter(target)
assert encounter is not None
assert encounter.pending_defense is not None
assert encounter.pending_defense.name == "dodge left"
@pytest.mark.asyncio
async def test_defense_insufficient_stamina(player, target, punch_right, dodge_left):
"""Test do_defend with insufficient stamina gives error."""
# Start combat
await combat_commands.do_attack(player, "Vegeta", punch_right)
target.writer = player.writer
target.mode_stack = ["combat"]
target.stamina = 1.0 # Not enough for dodge (costs 3)
player.writer.write.reset_mock()
await combat_commands.do_defend(target, "", dodge_left)
target.writer.write.assert_called()
message = target.writer.write.call_args[0][0]
assert "stamina" in message.lower()
# --- variant handler tests ---
@pytest.mark.asyncio
async def test_variant_handler_parses_direction(player, target, moves):
"""Test the variant handler parses direction from args."""
variant_moves = {
"left": moves["punch left"],
"right": moves["punch right"],
}
handler = combat_commands._make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "right Vegeta")
encounter = get_encounter(player)
assert encounter is not None
assert encounter.current_move.name == "punch right"
@pytest.mark.asyncio
async def test_variant_handler_no_direction(player, moves):
"""Test the variant handler prompts when no direction given."""
variant_moves = {
"left": moves["punch left"],
"right": moves["punch right"],
}
handler = combat_commands._make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "")
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "which way" in message.lower()
@pytest.mark.asyncio
async def test_variant_handler_bad_direction(player, moves):
"""Test the variant handler rejects invalid direction."""
variant_moves = {
"left": moves["punch left"],
"right": moves["punch right"],
}
handler = combat_commands._make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "up Vegeta")
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "unknown" in message.lower()
# --- direct handler tests ---
@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(
punch_right, combat_commands.do_attack
)
await handler(player, "Vegeta")
encounter = get_encounter(player)
assert encounter is not None
assert encounter.current_move.name == "punch right"
@pytest.mark.asyncio
async def test_direct_handler_alias_for_variant(player, target, punch_right):
"""Test alias handler (e.g. pr) works for variant moves."""
handler = combat_commands._make_direct_handler(
punch_right, combat_commands.do_attack
)
await handler(player, "Vegeta")
encounter = get_encounter(player)
assert encounter is not None
assert encounter.current_move.name == "punch right"
assert encounter.attacker is player
assert encounter.defender is target