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.
299 lines
9.2 KiB
Python
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()
|