Movement commands now access the zone through player.location instead of a module-level world variable. send_nearby_message uses zone.contents_near() to find nearby entities, eliminating the need for the global players dict and manual distance calculations. Tests updated to create zones and add entities via location assignment.
384 lines
11 KiB
Python
384 lines
11 KiB
Python
"""Tests for combat commands."""
|
|
|
|
import time
|
|
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
|
|
from mudlib.zone import Zone
|
|
|
|
|
|
@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 test_zone():
|
|
"""Create a test zone for players."""
|
|
terrain = [["." for _ in range(256)] for _ in range(256)]
|
|
zone = Zone(
|
|
name="testzone",
|
|
width=256,
|
|
height=256,
|
|
toroidal=True,
|
|
terrain=terrain,
|
|
impassable=set(),
|
|
)
|
|
return zone
|
|
|
|
|
|
@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, test_zone):
|
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
|
p.location = test_zone
|
|
test_zone._contents.append(p)
|
|
players[p.name] = p
|
|
return p
|
|
|
|
|
|
@pytest.fixture
|
|
def target(mock_reader, mock_writer, test_zone):
|
|
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
|
t.location = test_zone
|
|
test_zone._contents.append(t)
|
|
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, 'the air' message)."""
|
|
initial_stamina = player.stamina
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
|
|
assert player.stamina == initial_stamina - dodge_left.stamina_cost
|
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
|
assert any("dodge the air" in msg.lower() for msg in messages)
|
|
|
|
|
|
@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 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 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 direct handler 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 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 with the new move's text
|
|
assert any("left hook" in msg.lower() for msg in target_msgs)
|
|
|
|
|
|
# --- defense commitment tests ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defense_blocks_for_timing_window(player, dodge_left):
|
|
"""Test defense sleeps for timing_window_ms (commitment via blocking)."""
|
|
before = time.monotonic()
|
|
await combat_commands.do_defend(player, "", dodge_left)
|
|
elapsed = time.monotonic() - before
|
|
|
|
expected = dodge_left.timing_window_ms / 1000.0
|
|
assert elapsed >= expected - 0.05
|
|
|
|
|
|
@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 shows move name."""
|
|
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
|
|
# In combat: full move name (not "the air")
|
|
messages = [call[0][0] for call in target.writer.write.call_args_list]
|
|
assert any("dodge left" in msg.lower() for msg in messages)
|
|
|
|
|
|
@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)
|