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).
268 lines
7.8 KiB
Python
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"]
|