Combat moves defined as TOML content files in content/combat/, not engine code. State machine (IDLE > TELEGRAPH > WINDOW > RESOLVE) processes timing-based exchanges. Counter relationships, stamina costs, damage formulas all tunable from data files. Moves: punch right/left, roundhouse, sweep, dodge right/left, parry high/low, duck, jump. Combat ends on knockout (PL <= 0) or exhaustion (stamina <= 0).
260 lines
7.7 KiB
Python
260 lines
7.7 KiB
Python
"""Tests for combat encounter and state machine."""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from mudlib.combat.encounter import CombatEncounter, CombatState
|
|
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, combat_ended = encounter.resolve()
|
|
|
|
assert defender.pl == initial_pl
|
|
assert "countered" in result.lower()
|
|
assert encounter.state == CombatState.IDLE
|
|
assert 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, combat_ended = encounter.resolve()
|
|
|
|
expected_damage = attacker.pl * punch.damage_pct
|
|
assert defender.pl == initial_pl - expected_damage
|
|
assert "hit" in result.lower()
|
|
assert encounter.state == CombatState.IDLE
|
|
assert 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, combat_ended = encounter.resolve()
|
|
|
|
# No defense = 1.5x damage
|
|
expected_damage = attacker.pl * punch.damage_pct * 1.5
|
|
assert defender.pl == initial_pl - expected_damage
|
|
assert "full force" in result.lower()
|
|
assert encounter.state == CombatState.IDLE
|
|
assert 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)
|
|
|
|
_result, _combat_ended = 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
|
|
_result, _combat_ended = 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, combat_ended = encounter.resolve()
|
|
|
|
assert defender.pl <= 0
|
|
assert combat_ended is True
|
|
assert "damage" in result.lower()
|
|
|
|
|
|
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, combat_ended = encounter.resolve()
|
|
|
|
assert attacker.stamina <= 0
|
|
assert 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, combat_ended = encounter.resolve()
|
|
|
|
assert attacker.stamina > 0
|
|
assert defender.pl > 0
|
|
assert combat_ended is False
|