653 lines
19 KiB
Python
653 lines
19 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,
|
|
hit_time_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,
|
|
active_ms=800,
|
|
recovery_ms=2700,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def wrong_dodge():
|
|
return CombatMove(
|
|
name="dodge right",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=800,
|
|
recovery_ms=2700,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sweep():
|
|
return CombatMove(
|
|
name="sweep",
|
|
move_type="attack",
|
|
stamina_cost=8.0,
|
|
hit_time_ms=600,
|
|
damage_pct=0.20,
|
|
countered_by=["jump"],
|
|
)
|
|
|
|
|
|
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_pending(attacker, defender, punch):
|
|
"""Test attacking transitions to PENDING state."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
|
|
assert encounter.state == CombatState.PENDING
|
|
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 and activates it."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
encounter.defend(dodge)
|
|
|
|
assert encounter.pending_defense is dodge
|
|
assert encounter.defense_activated_at is not None
|
|
|
|
|
|
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
|
|
"""Test encounter.defend() does not deduct stamina (command layer does)."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
|
|
initial_stamina = defender.stamina
|
|
encounter.defend(dodge)
|
|
|
|
assert defender.stamina == initial_stamina
|
|
|
|
|
|
def test_tick_pending_to_resolve(attacker, defender, punch):
|
|
"""Test tick advances from PENDING to RESOLVE after hit time."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
|
|
# Wait for hit_time_ms (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 "counter" in result.resolve_template.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 "hit" in result.resolve_template.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
|
|
# Template should indicate a hit
|
|
assert result.resolve_template != ""
|
|
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 → PENDING
|
|
encounter.attack(punch)
|
|
assert encounter.state == CombatState.PENDING
|
|
|
|
# PENDING → RESOLVE (after hit_time_ms)
|
|
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.PENDING.value == "pending"
|
|
assert CombatState.RESOLVE.value == "resolve"
|
|
|
|
|
|
def test_resolve_knockout_does_not_end_combat(attacker, defender, punch):
|
|
"""KO should not end combat by itself."""
|
|
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 False
|
|
assert result.damage > 0
|
|
|
|
|
|
def test_resolve_exhaustion_does_not_end_combat(attacker, defender, punch):
|
|
"""Exhaustion should not end combat by itself."""
|
|
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 False
|
|
|
|
|
|
def test_resolve_exhausted_defender_does_not_end_combat(attacker, defender, punch):
|
|
"""Exhausted defender still does not auto-end encounter."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
defender.stamina = 0.0
|
|
|
|
encounter.attack(punch)
|
|
result = encounter.resolve()
|
|
|
|
assert defender.stamina <= 0
|
|
assert result.combat_ended is False
|
|
|
|
|
|
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_template_not_empty(attacker, defender, punch):
|
|
"""Test resolve returns a template."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
result = encounter.resolve()
|
|
|
|
assert result.resolve_template != ""
|
|
|
|
|
|
def test_resolve_template_uses_pov_tags(attacker, defender, punch):
|
|
"""Test resolve template uses POV tags."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
result = encounter.resolve()
|
|
|
|
# Template should contain POV tags like {attacker} or {defender}
|
|
assert "{" in result.resolve_template
|
|
|
|
|
|
def test_resolve_counter_template_indicates_counter(attacker, defender, punch, dodge):
|
|
"""Test counter template indicates a successful counter."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
encounter.defend(dodge)
|
|
result = encounter.resolve()
|
|
|
|
assert "counter" in result.resolve_template.lower()
|
|
|
|
|
|
# --- Attack switching (feint) tests ---
|
|
|
|
|
|
def test_switch_attack_during_pending(attacker, defender, punch, sweep):
|
|
"""Test attack during PENDING replaces move and restarts timer."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
original_start = encounter.move_started_at
|
|
|
|
assert encounter.state == CombatState.PENDING
|
|
|
|
# Switch to sweep during pending
|
|
time.sleep(0.1) # Small delay to ensure timer would differ
|
|
encounter.attack(sweep)
|
|
|
|
assert encounter.current_move is sweep
|
|
assert encounter.state == CombatState.PENDING
|
|
# Timer should restart on switch
|
|
assert encounter.move_started_at > original_start
|
|
|
|
|
|
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
|
|
"""Test switching refunds old move's cost and charges new."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
initial_stamina = attacker.stamina
|
|
|
|
encounter.attack(punch)
|
|
assert attacker.stamina == initial_stamina - punch.stamina_cost
|
|
|
|
# Switch to sweep — should refund punch, charge sweep
|
|
encounter.attack(sweep)
|
|
assert attacker.stamina == initial_stamina - sweep.stamina_cost
|
|
|
|
|
|
def test_switch_stamina_clamped_to_max(attacker, defender, punch, sweep):
|
|
"""Test stamina refund clamped to max_stamina."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
|
|
# Set stamina so refund would exceed max
|
|
attacker.stamina = attacker.max_stamina
|
|
encounter.attack(punch)
|
|
|
|
# Manually set stamina high so refund would exceed max
|
|
attacker.stamina = attacker.max_stamina - 1
|
|
encounter.attack(sweep)
|
|
|
|
# Refund of punch (5) would push past max, should clamp
|
|
assert attacker.stamina <= attacker.max_stamina
|
|
|
|
|
|
def test_resolve_uses_final_move(attacker, defender, punch, sweep):
|
|
"""Test resolve uses the switched-to move, not the original."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
encounter.attack(sweep) # Switch
|
|
|
|
initial_pl = defender.pl
|
|
result = encounter.resolve()
|
|
|
|
# Sweep does 0.20 * 1.5 = 0.30 of PL (no defense)
|
|
expected_damage = attacker.pl * sweep.damage_pct * 1.5
|
|
assert defender.pl == initial_pl - expected_damage
|
|
assert result.resolve_template != ""
|
|
|
|
|
|
# --- last_action_at (last landed damage) tracking tests ---
|
|
|
|
|
|
def test_last_action_at_not_updated_on_attack(attacker, defender, punch):
|
|
"""Attack startup should not reset timeout until damage lands."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
assert encounter.last_action_at == 0.0
|
|
|
|
encounter.attack(punch)
|
|
|
|
assert encounter.last_action_at == 0.0
|
|
|
|
|
|
def test_last_action_at_not_updated_on_defend(attacker, defender, punch, dodge):
|
|
"""Defense input should not reset timeout without landed damage."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(punch)
|
|
assert encounter.last_action_at == 0.0
|
|
encounter.defend(dodge)
|
|
|
|
assert encounter.last_action_at == 0.0
|
|
|
|
|
|
def test_last_action_at_updates_when_damage_lands(attacker, defender, punch):
|
|
"""Landed damage should refresh timeout timestamp."""
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
assert encounter.last_action_at == 0.0
|
|
encounter.attack(punch)
|
|
before = time.monotonic()
|
|
encounter.resolve()
|
|
assert encounter.last_action_at >= before
|
|
|
|
|
|
def test_last_action_at_unchanged_when_attack_is_countered(
|
|
attacker, defender, punch, dodge
|
|
):
|
|
"""No damage (successful counter) should not refresh timeout timestamp."""
|
|
encounter = CombatEncounter(
|
|
attacker=attacker, defender=defender, last_action_at=10.0
|
|
)
|
|
encounter.attack(punch)
|
|
encounter.defend(dodge)
|
|
encounter.resolve()
|
|
assert encounter.last_action_at == 10.0
|
|
|
|
|
|
# --- Defense active/recovery window tests ---
|
|
|
|
|
|
def test_defense_expired_before_resolve(attacker, defender):
|
|
"""Defense activates, active_ms passes, attack resolves.
|
|
|
|
Defense should NOT counter (hit lands).
|
|
"""
|
|
# Create attack with 800ms hit time
|
|
quick_attack = CombatMove(
|
|
name="quick punch",
|
|
move_type="attack",
|
|
stamina_cost=5.0,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=["quick dodge"],
|
|
)
|
|
|
|
# Create defense with short active window (200ms)
|
|
quick_dodge = CombatMove(
|
|
name="quick dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=200,
|
|
recovery_ms=300,
|
|
)
|
|
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(quick_attack)
|
|
encounter.defend(quick_dodge)
|
|
|
|
# Wait for defense to expire (200ms) but before attack lands (800ms)
|
|
time.sleep(0.31)
|
|
encounter.tick(time.monotonic())
|
|
|
|
# Now resolve — defense should be expired
|
|
initial_pl = defender.pl
|
|
result = encounter.resolve()
|
|
|
|
# Defense expired, so attack should hit for full damage
|
|
expected_damage = attacker.pl * quick_attack.damage_pct * 1.5
|
|
assert defender.pl == initial_pl - expected_damage
|
|
assert result.damage == expected_damage
|
|
assert result.countered is False
|
|
|
|
|
|
def test_defense_active_during_resolve(attacker, defender):
|
|
"""Defense activates within active_ms of resolve — defense SHOULD counter."""
|
|
# Create attack with 800ms hit time
|
|
quick_attack = CombatMove(
|
|
name="quick punch",
|
|
move_type="attack",
|
|
stamina_cost=5.0,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=["quick dodge"],
|
|
)
|
|
|
|
# Create defense with long active window (1000ms)
|
|
quick_dodge = CombatMove(
|
|
name="quick dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=1000,
|
|
recovery_ms=300,
|
|
)
|
|
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(quick_attack)
|
|
encounter.defend(quick_dodge)
|
|
|
|
# Immediately resolve — defense is still active
|
|
initial_pl = defender.pl
|
|
result = encounter.resolve()
|
|
|
|
# Defense is active, should counter successfully
|
|
assert defender.pl == initial_pl
|
|
assert result.damage == 0.0
|
|
assert result.countered is True
|
|
|
|
|
|
def test_defense_queuing_during_recovery(attacker, defender):
|
|
"""defend() while in recovery should set queued_defense, not pending_defense."""
|
|
quick_dodge = CombatMove(
|
|
name="quick dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=200,
|
|
recovery_ms=300,
|
|
)
|
|
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
|
|
# First defend — activates immediately
|
|
encounter.defend(quick_dodge)
|
|
assert encounter.pending_defense is quick_dodge
|
|
assert encounter.defense_activated_at is not None
|
|
|
|
# Wait for defense to expire and enter recovery
|
|
time.sleep(0.31)
|
|
encounter.tick(time.monotonic())
|
|
|
|
assert encounter.pending_defense is None
|
|
assert encounter.defense_recovery_until is not None
|
|
|
|
# Try to defend during recovery — should queue
|
|
second_dodge = CombatMove(
|
|
name="second dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=200,
|
|
recovery_ms=300,
|
|
)
|
|
encounter.defend(second_dodge)
|
|
|
|
# Should be queued, not pending
|
|
assert encounter.pending_defense is None
|
|
assert encounter.queued_defense is second_dodge
|
|
|
|
|
|
def test_queued_defense_activates_after_recovery(attacker, defender):
|
|
"""After recovery_ms passes, tick() should activate the queued defense."""
|
|
quick_dodge = CombatMove(
|
|
name="quick dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=200,
|
|
recovery_ms=300,
|
|
)
|
|
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
|
|
# First defend
|
|
encounter.defend(quick_dodge)
|
|
|
|
# Wait for defense to expire
|
|
time.sleep(0.31)
|
|
encounter.tick(time.monotonic())
|
|
|
|
# Queue second defense during recovery
|
|
second_dodge = CombatMove(
|
|
name="second dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=200,
|
|
recovery_ms=300,
|
|
)
|
|
encounter.defend(second_dodge)
|
|
|
|
assert encounter.queued_defense is second_dodge
|
|
assert encounter.pending_defense is None
|
|
|
|
# Wait for recovery to finish
|
|
time.sleep(0.31)
|
|
encounter.tick(time.monotonic())
|
|
|
|
# Queued defense should now be active
|
|
assert encounter.pending_defense is second_dodge
|
|
assert encounter.queued_defense is None
|
|
assert encounter.defense_activated_at is not None
|
|
|
|
|
|
def test_recovery_persists_after_resolve(attacker, defender):
|
|
"""resolve() should NOT clear defense_recovery_until.
|
|
|
|
Recovery carries across attacks.
|
|
"""
|
|
quick_attack = CombatMove(
|
|
name="quick punch",
|
|
move_type="attack",
|
|
stamina_cost=5.0,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=["quick dodge"],
|
|
)
|
|
|
|
quick_dodge = CombatMove(
|
|
name="quick dodge",
|
|
move_type="defense",
|
|
stamina_cost=3.0,
|
|
active_ms=200,
|
|
recovery_ms=300,
|
|
)
|
|
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
|
|
# Defend, then wait for defense to expire and enter recovery
|
|
encounter.defend(quick_dodge)
|
|
time.sleep(0.31)
|
|
encounter.tick(time.monotonic())
|
|
|
|
recovery_until = encounter.defense_recovery_until
|
|
assert recovery_until is not None
|
|
|
|
# Attack and resolve during recovery period
|
|
encounter.attack(quick_attack)
|
|
encounter.resolve()
|
|
|
|
# Recovery should persist after resolve
|
|
assert encounter.defense_recovery_until == recovery_until
|
|
|
|
|
|
def test_attack_switch_restarts_timer(attacker, defender):
|
|
"""attack(), then attack() again with different move.
|
|
|
|
move_started_at should reset.
|
|
"""
|
|
first_attack = CombatMove(
|
|
name="first punch",
|
|
move_type="attack",
|
|
stamina_cost=5.0,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=[],
|
|
)
|
|
|
|
second_attack = CombatMove(
|
|
name="second punch",
|
|
move_type="attack",
|
|
stamina_cost=5.0,
|
|
hit_time_ms=800,
|
|
damage_pct=0.15,
|
|
countered_by=[],
|
|
)
|
|
|
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
|
encounter.attack(first_attack)
|
|
first_start = encounter.move_started_at
|
|
|
|
# Small delay to ensure time difference
|
|
time.sleep(0.1)
|
|
|
|
# Switch to second attack
|
|
encounter.attack(second_attack)
|
|
second_start = encounter.move_started_at
|
|
|
|
# Timer should have restarted
|
|
assert second_start > first_start
|
|
assert encounter.current_move is second_attack
|