"""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, ) @pytest.fixture def sweep(): return CombatMove( name="sweep", move_type="attack", stamina_cost=8.0, timing_window_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_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_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_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() # --- Attack switching (feint) tests --- def test_switch_attack_during_telegraph(attacker, defender, punch, sweep): """Test attack during TELEGRAPH replaces move and keeps timer.""" encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) original_start = encounter.move_started_at assert encounter.state == CombatState.TELEGRAPH # Switch to sweep during telegraph encounter.attack(sweep) assert encounter.current_move is sweep assert encounter.state == CombatState.TELEGRAPH # Timer should NOT restart assert encounter.move_started_at == original_start def test_switch_attack_during_window(attacker, defender, punch, sweep): """Test attack during WINDOW replaces move and keeps timer.""" encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) original_start = encounter.move_started_at # Advance to WINDOW time.sleep(0.31) encounter.tick(time.monotonic()) assert encounter.state == CombatState.WINDOW # Switch to sweep during window encounter.attack(sweep) assert encounter.current_move is sweep assert encounter.state == CombatState.WINDOW # Timer should NOT restart 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 "sweep" in result.attacker_msg.lower()