mud/tests/test_combat_zaxis.py
Jared Miller 4da8d41b45
Add z-axis altitude check for starting combat
Players must be at same altitude (both flying or both grounded) to initiate combat. Attack fails with 'You can't reach them from here!' if altitude differs.
2026-02-14 01:00:37 -05:00

239 lines
6.3 KiB
Python

"""Tests for z-axis altitude checks in combat."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import active_encounters, get_encounter, start_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 dodge_left(moves):
"""Get the dodge left move."""
return moves["dodge left"]
# --- Z-axis altitude check tests for starting combat ---
@pytest.mark.asyncio
async def test_attack_fails_when_attacker_flying_defender_grounded(
player, target, punch_right
):
"""Test attack fails when attacker is flying and defender is grounded."""
player.flying = True
target.flying = False
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should not start
encounter = get_encounter(player)
assert encounter is None
# Player should get error message
player.writer.write.assert_called()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("can't reach" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_attack_fails_when_attacker_grounded_defender_flying(
player, target, punch_right
):
"""Test attack fails when attacker is grounded and defender is flying."""
player.flying = False
target.flying = True
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should not start
encounter = get_encounter(player)
assert encounter is None
# Player should get error message
player.writer.write.assert_called()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("can't reach" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_attack_succeeds_when_both_flying(player, target, punch_right):
"""Test attack succeeds when both are flying."""
player.flying = True
target.flying = True
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should start
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is target
@pytest.mark.asyncio
async def test_attack_succeeds_when_both_grounded(player, target, punch_right):
"""Test attack succeeds when both are grounded."""
player.flying = False
target.flying = False
await combat_commands.do_attack(player, "Vegeta", punch_right)
# Combat should start
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is target
# --- Flying dodge tests (altitude mismatch at resolve time) ---
@pytest.mark.asyncio
async def test_flying_during_window_causes_miss(player, target, punch_right):
"""Test flying during window phase causes attack to miss at resolve time."""
# Both grounded, start combat
player.flying = False
target.flying = False
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
# Defender flies during window
target.flying = True
# Resolve
result = encounter.resolve()
# Attack should miss (countered or zero damage)
assert result.countered is True or result.damage == 0.0
@pytest.mark.asyncio
async def test_both_flying_at_resolve_attack_lands(player, target, punch_right):
"""Test both flying at resolve time — attack lands normally."""
# Both flying, start combat
player.flying = True
target.flying = True
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase (no altitude change)
encounter.state = CombatState.WINDOW
# Resolve
result = encounter.resolve()
# Attack should land (damage > 0 unless defended)
# Since there's no defense, damage should be > 0
assert result.damage > 0.0
@pytest.mark.asyncio
async def test_attacker_flies_during_window_causes_miss(player, target, punch_right):
"""Test attacker flying during window phase causes attack to miss."""
# Both grounded, start combat
player.flying = False
target.flying = False
encounter = start_encounter(player, target)
encounter.attack(punch_right)
# Advance to WINDOW phase
encounter.state = CombatState.WINDOW
# Attacker flies during window
player.flying = True
# Resolve
result = encounter.resolve()
# Attack should miss
assert result.countered is True or result.damage == 0.0