mud/tests/test_combat_engine.py
Jared Miller dbb976be24
Add data-driven combat system with TOML move definitions
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).
2026-02-07 21:16:12 -05:00

268 lines
7.8 KiB
Python

"""Tests for combat engine and encounter management."""
import time
import pytest
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
end_encounter,
get_encounter,
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
@pytest.fixture(autouse=True)
def clear_encounters():
"""Clear encounters before and after each test."""
active_encounters.clear()
yield
active_encounters.clear()
@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"],
)
def test_start_encounter_creates_encounter(attacker, defender):
"""Test starting an encounter creates and registers it."""
encounter = start_encounter(attacker, defender)
assert encounter is not None
assert encounter.attacker is attacker
assert encounter.defender is defender
assert encounter in active_encounters
def test_get_encounter_finds_by_attacker(attacker, defender):
"""Test get_encounter finds encounter by attacker."""
encounter = start_encounter(attacker, defender)
found = get_encounter(attacker)
assert found is encounter
def test_get_encounter_finds_by_defender(attacker, defender):
"""Test get_encounter finds encounter by defender."""
encounter = start_encounter(attacker, defender)
found = get_encounter(defender)
assert found is encounter
def test_get_encounter_returns_none_if_not_in_combat(attacker):
"""Test get_encounter returns None for entity not in combat."""
found = get_encounter(attacker)
assert found is None
def test_end_encounter_removes_from_active_list(attacker, defender):
"""Test ending encounter removes it from active list."""
encounter = start_encounter(attacker, defender)
end_encounter(encounter)
assert encounter not in active_encounters
assert get_encounter(attacker) is None
assert get_encounter(defender) is None
def test_process_combat_advances_encounters(attacker, defender, punch):
"""Test process_combat advances all active encounters."""
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Process combat should advance state from TELEGRAPH to WINDOW
time.sleep(0.31)
process_combat()
assert encounter.state == CombatState.WINDOW
def test_process_combat_handles_multiple_encounters(punch):
"""Test process_combat handles multiple simultaneous encounters."""
e1_attacker = Entity(name="A1", x=0, y=0)
e1_defender = Entity(name="D1", x=0, y=0)
e2_attacker = Entity(name="A2", x=10, y=10)
e2_defender = Entity(name="D2", x=10, y=10)
enc1 = start_encounter(e1_attacker, e1_defender)
enc2 = start_encounter(e2_attacker, e2_defender)
enc1.attack(punch)
enc2.attack(punch)
time.sleep(0.31)
process_combat()
assert enc1.state == CombatState.WINDOW
assert enc2.state == CombatState.WINDOW
def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch):
"""Test process_combat auto-resolves when window expires."""
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
initial_pl = defender.pl
# Skip past telegraph and window
time.sleep(0.31) # Telegraph
process_combat()
assert encounter.state == CombatState.WINDOW
time.sleep(0.85) # Window
process_combat()
# Should auto-resolve and return to IDLE
assert encounter.state == CombatState.IDLE
# Damage should have been applied (no defense = 1.5x damage)
assert defender.pl < initial_pl
def test_start_encounter_when_already_in_combat(attacker, defender):
"""Test starting encounter when attacker already in combat raises error."""
start_encounter(attacker, defender)
other = Entity(name="Other", x=5, y=5)
with pytest.raises(ValueError, match="already in combat"):
start_encounter(attacker, other)
def test_start_encounter_when_defender_in_combat(attacker, defender):
"""Test starting encounter when defender already in combat raises error."""
start_encounter(attacker, defender)
other = Entity(name="Other", x=5, y=5)
with pytest.raises(ValueError, match="already in combat"):
start_encounter(other, defender)
def test_active_encounters_list():
"""Test active_encounters is a list."""
assert isinstance(active_encounters, list)
def test_process_combat_with_no_encounters():
"""Test process_combat handles empty encounter list."""
process_combat() # Should not raise
def test_encounter_cleanup_after_resolution(attacker, defender, punch):
"""Test encounter can be ended after resolution."""
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance to resolution
time.sleep(0.31)
process_combat()
time.sleep(0.85)
process_combat()
# Resolve
encounter.resolve()
# Now end it
end_encounter(encounter)
assert get_encounter(attacker) is None
def test_process_combat_ends_encounter_on_knockout(punch):
"""Test process_combat ends encounter when defender is knocked out."""
from mudlib.player import Player
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0)
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0)
# Push combat mode onto both stacks
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance to resolution
time.sleep(0.31)
process_combat()
time.sleep(0.85)
process_combat()
# Combat should have ended and been cleaned up
assert get_encounter(attacker) is None
assert get_encounter(defender) is None
# Mode stacks should have combat popped
assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal"]
def test_process_combat_ends_encounter_on_exhaustion(punch):
"""Test process_combat ends encounter when attacker is exhausted."""
from mudlib.player import Player
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=punch.stamina_cost)
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0)
# Push combat mode onto both stacks
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance to resolution
time.sleep(0.31)
process_combat()
time.sleep(0.85)
process_combat()
# Combat should have ended
assert get_encounter(attacker) is None
assert get_encounter(defender) is None
assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal"]
def test_process_combat_continues_with_resources(punch):
"""Test process_combat continues encounter when both have resources."""
from mudlib.player import Player
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0)
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0)
# Push combat mode onto both stacks
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance to resolution
time.sleep(0.31)
process_combat()
time.sleep(0.85)
process_combat()
# Combat should still be active (but in IDLE state)
assert get_encounter(attacker) is encounter
assert get_encounter(defender) is encounter
assert attacker.mode_stack == ["normal", "combat"]
assert defender.mode_stack == ["normal", "combat"]