mud/tests/test_combat_encounter.py
Jared Miller 9054962f5d
Add combat resolution messages with both-POV feedback
resolve() returns ResolveResult dataclass with attacker_msg, defender_msg,
damage, countered, and combat_ended fields. process_combat is now async
and sends messages to both participants on resolve. Counter, hit, and
slam messages give each player their own perspective on what happened.
2026-02-08 12:28:17 -05:00

299 lines
9.2 KiB
Python

"""Tests for combat encounter and state machine."""
import time
import pytest
from mudlib.combat.encounter import CombatEncounter, CombatState, ResolveResult
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
@pytest.fixture
def attacker():
return Entity(name="Goku", x=0, y=0, pl=100.0, stamina=50.0)
@pytest.fixture
def defender():
return Entity(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0)
@pytest.fixture
def punch():
return CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=["dodge left", "parry high"],
)
@pytest.fixture
def dodge():
return CombatMove(
name="dodge left",
move_type="defense",
stamina_cost=3.0,
timing_window_ms=800,
)
@pytest.fixture
def wrong_dodge():
return CombatMove(
name="dodge right",
move_type="defense",
stamina_cost=3.0,
timing_window_ms=800,
)
def test_combat_encounter_initial_state(attacker, defender):
"""Test encounter starts in IDLE state."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
assert encounter.state == CombatState.IDLE
assert encounter.current_move is None
assert encounter.pending_defense is None
assert encounter.move_started_at == 0.0
def test_attack_transitions_to_telegraph(attacker, defender, punch):
"""Test attacking transitions to TELEGRAPH state."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
assert encounter.state == CombatState.TELEGRAPH
assert encounter.current_move is punch
assert encounter.move_started_at > 0.0
def test_attack_applies_stamina_cost(attacker, defender, punch):
"""Test attacking costs stamina."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
initial_stamina = attacker.stamina
encounter.attack(punch)
assert attacker.stamina == initial_stamina - punch.stamina_cost
def test_defend_records_pending_defense(attacker, defender, punch, dodge):
"""Test defend records the defense move."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(dodge)
assert encounter.pending_defense is dodge
def test_defend_applies_stamina_cost(attacker, defender, punch, dodge):
"""Test defending costs stamina."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
initial_stamina = defender.stamina
encounter.defend(dodge)
assert defender.stamina == initial_stamina - dodge.stamina_cost
def test_tick_telegraph_to_window(attacker, defender, punch):
"""Test tick advances from TELEGRAPH to WINDOW after brief delay."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
# Wait for telegraph phase (300ms)
time.sleep(0.31)
now = time.monotonic()
encounter.tick(now)
assert encounter.state == CombatState.WINDOW
def test_tick_window_to_resolve(attacker, defender, punch):
"""Test tick advances from WINDOW to RESOLVE after timing window."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
# Skip to WINDOW state
time.sleep(0.31)
encounter.tick(time.monotonic())
# Wait for timing window to expire (800ms)
time.sleep(0.85)
now = time.monotonic()
encounter.tick(now)
assert encounter.state == CombatState.RESOLVE
def test_resolve_successful_counter(attacker, defender, punch, dodge):
"""Test resolve with successful counter does no damage."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(dodge)
initial_pl = defender.pl
result = encounter.resolve()
assert isinstance(result, ResolveResult)
assert defender.pl == initial_pl
assert result.damage == 0.0
assert result.countered is True
assert "countered" in result.attacker_msg.lower()
assert "countered" in result.defender_msg.lower()
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge):
"""Test resolve with wrong defense move deals damage."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(wrong_dodge)
initial_pl = defender.pl
result = encounter.resolve()
expected_damage = attacker.pl * punch.damage_pct
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
assert "hits" in result.attacker_msg.lower()
assert "hits" in result.defender_msg.lower()
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
def test_resolve_no_defense(attacker, defender, punch):
"""Test resolve with no defense deals increased damage."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
initial_pl = defender.pl
result = encounter.resolve()
# No defense = 1.5x damage
expected_damage = attacker.pl * punch.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
assert "slams" in result.attacker_msg.lower()
assert "slams" in result.defender_msg.lower()
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
def test_resolve_clears_pending_defense(attacker, defender, punch, dodge):
"""Test resolve clears pending defense."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(dodge)
encounter.resolve()
assert encounter.pending_defense is None
assert encounter.current_move is None
def test_full_state_machine_cycle(attacker, defender, punch):
"""Test complete state machine cycle from IDLE to IDLE."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# IDLE → TELEGRAPH
encounter.attack(punch)
assert encounter.state == CombatState.TELEGRAPH
# TELEGRAPH → WINDOW
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# WINDOW → RESOLVE
time.sleep(0.85)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.RESOLVE
# RESOLVE → IDLE
encounter.resolve()
assert encounter.state == CombatState.IDLE
def test_combat_state_enum():
"""Test CombatState enum values."""
assert CombatState.IDLE.value == "idle"
assert CombatState.TELEGRAPH.value == "telegraph"
assert CombatState.WINDOW.value == "window"
assert CombatState.RESOLVE.value == "resolve"
def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
"""Test resolve returns combat_ended=True when defender PL <= 0."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set defender to low PL so attack will knock them out
defender.pl = 10.0
encounter.attack(punch)
result = encounter.resolve()
assert defender.pl <= 0
assert result.combat_ended is True
assert result.damage > 0
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
"""Test resolve returns combat_ended=True when attacker stamina <= 0."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set attacker stamina to exactly the cost so attack depletes it
attacker.stamina = punch.stamina_cost
encounter.attack(punch)
result = encounter.resolve()
assert attacker.stamina <= 0
assert result.combat_ended is True
def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
"""Test resolve returns combat_ended=False when both have resources."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert attacker.stamina > 0
assert defender.pl > 0
assert result.combat_ended is False
def test_resolve_attacker_msg_contains_move_name(attacker, defender, punch):
"""Test attacker message includes the move name."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert "punch right" in result.attacker_msg.lower()
def test_resolve_defender_msg_contains_attacker_name(attacker, defender, punch):
"""Test defender message includes attacker's name."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert attacker.name.lower() in result.defender_msg.lower()
def test_resolve_counter_messages_contain_move_name(attacker, defender, punch, dodge):
"""Test counter messages include the move name for both players."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.defend(dodge)
result = encounter.resolve()
assert "punch right" in result.attacker_msg.lower()
assert "punch right" in result.defender_msg.lower()