Defenses now work outside combat mode with stamina cost, recovery lock (based on timing_window_ms), and broadcast to nearby players. Lock prevents spamming defenses — you commit to the move. Stamina deduction moved from encounter.defend() to do_defend command layer. Defense commands registered with mode="*" instead of "combat".
402 lines
12 KiB
Python
402 lines
12 KiB
Python
"""Tests for combat commands."""
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import mudlib.commands.movement as movement_mod
|
|
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(autouse=True)
|
|
def mock_world():
|
|
"""Inject a mock world for send_nearby_message."""
|
|
fake_world = MagicMock()
|
|
fake_world.width = 256
|
|
fake_world.height = 256
|
|
old = movement_mod.world
|
|
movement_mod.world = fake_world
|
|
yield fake_world
|
|
movement_mod.world = old
|
|
|
|
|
|
@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_works_outside_combat(player, dodge_left):
|
|
"""Test do_defend works outside combat (costs stamina, applies lock)."""
|
|
initial_stamina = player.stamina
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
assert player.stamina == initial_stamina - dodge_left.stamina_cost
|
|
assert player.defense_locked_until > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_records_pending_defense(player, target, punch_right, dodge_left):
|
|
"""Test do_defend queues defense on encounter and costs stamina."""
|
|
# Start combat
|
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
|
player.writer.write.reset_mock()
|
|
|
|
initial_stamina = target.stamina
|
|
|
|
# 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"
|
|
assert target.stamina == initial_stamina - dodge_left.stamina_cost
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_insufficient_stamina(player, dodge_left):
|
|
"""Test do_defend with insufficient stamina gives error."""
|
|
player.stamina = 1.0 # Not enough for dodge (costs 3)
|
|
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
player.writer.write.assert_called()
|
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
|
assert any("stamina" in msg.lower() for msg in messages)
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
# --- attack switching tests ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_switch_attack_shows_switch_message(
|
|
player, target, punch_right, punch_left
|
|
):
|
|
"""Test switching attack says 'switch to' instead of 'use'."""
|
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
|
player.writer.write.reset_mock()
|
|
|
|
await combat_commands.do_attack(player, "", punch_left)
|
|
|
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
|
assert any("switch to" in msg.lower() for msg in messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_switch_attack_sends_new_telegraph(
|
|
player, target, punch_right, punch_left
|
|
):
|
|
"""Test switching attack sends new telegraph to defender."""
|
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
|
target.writer.write.reset_mock()
|
|
|
|
await combat_commands.do_attack(player, "", punch_left)
|
|
|
|
target_msgs = [call[0][0] for call in target.writer.write.call_args_list]
|
|
# Defender should get a new telegraph
|
|
assert len(target_msgs) > 0
|
|
|
|
|
|
# --- defense commitment tests ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_rejected_while_locked(player, dodge_left):
|
|
"""Test defense rejected during recovery lock."""
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
player.writer.write.reset_mock()
|
|
|
|
# Immediately try again — should be locked
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
|
assert any("still recovering" in msg.lower() for msg in messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_works_after_lock_expires(player, dodge_left):
|
|
"""Test defense works after recovery lock expires."""
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
# Fast-forward past the lock
|
|
player.defense_locked_until = time.monotonic() - 1.0
|
|
player.writer.write.reset_mock()
|
|
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
|
# Should succeed, not say "still recovering"
|
|
assert not any("still recovering" in msg.lower() for msg in messages)
|
|
assert any("dodge left" in msg.lower() for msg in messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_lock_uses_timing_window(player, dodge_left, moves):
|
|
"""Test defense lock duration matches move's timing_window_ms."""
|
|
before = time.monotonic()
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
expected_lock = dodge_left.timing_window_ms / 1000.0
|
|
# Lock should be roughly now + timing_window
|
|
assert player.defense_locked_until >= before + expected_lock - 0.1
|
|
assert player.defense_locked_until <= before + expected_lock + 0.5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_in_combat_queues_on_encounter(
|
|
player, target, punch_right, dodge_left
|
|
):
|
|
"""Test defense in combat queues on encounter AND applies lock."""
|
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
|
|
|
await combat_commands.do_defend(target, "", dodge_left)
|
|
|
|
encounter = get_encounter(target)
|
|
assert encounter is not None
|
|
assert encounter.pending_defense is dodge_left
|
|
assert target.defense_locked_until > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_broadcast_to_nearby(player, target, dodge_left):
|
|
"""Test defense broadcasts to nearby players."""
|
|
target.writer.write.reset_mock()
|
|
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
# Target is at same coords, should get broadcast
|
|
target_msgs = [call[0][0] for call in target.writer.write.call_args_list]
|
|
assert any(player.name in msg for msg in target_msgs)
|