"""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 "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 → 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_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_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 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