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