Combat moves defined as TOML content files in content/combat/, not engine code. State machine (IDLE > TELEGRAPH > WINDOW > RESOLVE) processes timing-based exchanges. Counter relationships, stamina costs, damage formulas all tunable from data files. Moves: punch right/left, roundhouse, sweep, dodge right/left, parry high/low, duck, jump. Combat ends on knockout (PL <= 0) or exhaustion (stamina <= 0).
227 lines
7 KiB
Python
227 lines
7 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.mark.asyncio
|
|
async def test_attack_starts_combat_with_target(player, target):
|
|
"""Test attack command with target starts combat encounter."""
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
|
|
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):
|
|
"""Test attack without target when not in combat gives error."""
|
|
await combat_commands.cmd_attack(player, "punch right")
|
|
|
|
player.writer.write.assert_called()
|
|
message = player.writer.write.call_args[0][0]
|
|
assert "not in combat" in message.lower() or "need a target" in message.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attack_without_target_when_in_combat(player, target):
|
|
"""Test attack without target when in combat uses implicit target."""
|
|
# Start combat first
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
|
|
# Reset mock to track new calls
|
|
player.writer.write.reset_mock()
|
|
|
|
# Attack without target should work
|
|
await combat_commands.cmd_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_unknown_move(player, target):
|
|
"""Test attack with unknown move name gives error."""
|
|
await combat_commands.cmd_attack(player, "kamehameha Vegeta")
|
|
|
|
player.writer.write.assert_called()
|
|
message = player.writer.write.call_args[0][0]
|
|
assert "unknown" in message.lower() or "don't know" in message.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attack_insufficient_stamina(player, target):
|
|
"""Test attack with insufficient stamina gives error."""
|
|
player.stamina = 1.0 # Not enough for punch (costs 5)
|
|
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
|
|
player.writer.write.assert_called()
|
|
message = player.writer.write.call_args[0][0]
|
|
assert "stamina" in message.lower() or "exhausted" in message.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attack_sends_telegraph_to_defender(player, target):
|
|
"""Test attack sends telegraph message to defender."""
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
|
|
# 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"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_only_in_combat(player):
|
|
"""Test defense command only works in combat mode."""
|
|
await combat_commands.cmd_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):
|
|
"""Test defense command records the defense move."""
|
|
# Start combat
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
player.writer.write.reset_mock()
|
|
|
|
# Switch to defender's perspective
|
|
target.writer = player.writer
|
|
target.mode_stack = ["combat"]
|
|
|
|
# Defend
|
|
await combat_commands.cmd_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_unknown_move(player, target):
|
|
"""Test defense with unknown move gives error."""
|
|
# Start combat
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
target.writer = player.writer
|
|
target.mode_stack = ["combat"]
|
|
|
|
player.writer.write.reset_mock()
|
|
await combat_commands.cmd_defend(target, "teleport")
|
|
|
|
target.writer.write.assert_called()
|
|
message = target.writer.write.call_args[0][0]
|
|
assert "unknown" in message.lower() or "don't know" in message.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_insufficient_stamina(player, target):
|
|
"""Test defense with insufficient stamina gives error."""
|
|
# Start combat
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
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.cmd_defend(target, "dodge left")
|
|
|
|
target.writer.write.assert_called()
|
|
message = target.writer.write.call_args[0][0]
|
|
assert "stamina" in message.lower() or "exhausted" in message.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attack_alias_works(player, target):
|
|
"""Test attack using alias (pr for punch right)."""
|
|
await combat_commands.cmd_attack(player, "pr Vegeta")
|
|
|
|
encounter = get_encounter(player)
|
|
assert encounter is not None
|
|
assert encounter.current_move is not None
|
|
assert encounter.current_move.name == "punch right"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_alias_works(player, target):
|
|
"""Test defense using alias (dl for dodge left)."""
|
|
# Start combat
|
|
await combat_commands.cmd_attack(player, "punch right Vegeta")
|
|
target.writer = player.writer
|
|
target.mode_stack = ["combat"]
|
|
|
|
await combat_commands.cmd_defend(target, "dl")
|
|
|
|
encounter = get_encounter(target)
|
|
assert encounter is not None
|
|
assert encounter.pending_defense is not None
|
|
assert encounter.pending_defense.name == "dodge left"
|