diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index d0b4135..4a5e693 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -3,6 +3,7 @@ from collections import defaultdict from pathlib import Path +from mudlib.combat.encounter import CombatState from mudlib.combat.engine import get_encounter, start_encounter from mudlib.combat.moves import CombatMove, load_moves from mudlib.commands import CommandDefinition, register @@ -63,9 +64,18 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: telegraph = move.telegraph.format(attacker=player.name) await defender.send(f"{telegraph}\r\n") + # Detect switch before attack() modifies state + switching = encounter.state in ( + CombatState.TELEGRAPH, + CombatState.WINDOW, + ) + # Execute the attack encounter.attack(move) - await player.send(f"You use {move.name}!\r\n") + if switching: + await player.send(f"You switch to {move.name}!\r\n") + else: + await player.send(f"You use {move.name}!\r\n") async def do_defend(player: Player, _args: str, move: CombatMove) -> None: diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py index 53a6076..e65413d 100644 --- a/src/mudlib/combat/encounter.py +++ b/src/mudlib/combat/encounter.py @@ -44,17 +44,29 @@ class CombatEncounter: pending_defense: CombatMove | None = None def attack(self, move: CombatMove) -> None: - """Initiate an attack move. + """Initiate or switch an attack move. + + If called during TELEGRAPH or WINDOW, switches to the new move + without resetting the timer. Refunds old move's stamina cost. Args: move: The attack move to execute """ - self.current_move = move - self.state = CombatState.TELEGRAPH - self.move_started_at = time.monotonic() + if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW): + # Switching — refund old cost, keep timer + if self.current_move: + self.attacker.stamina = min( + self.attacker.stamina + self.current_move.stamina_cost, + self.attacker.max_stamina, + ) + else: + # First attack — start timer + self.move_started_at = time.monotonic() - # Apply stamina cost + self.current_move = move self.attacker.stamina -= move.stamina_cost + if self.state == CombatState.IDLE: + self.state = CombatState.TELEGRAPH def defend(self, move: CombatMove) -> None: """Queue a defense move. diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index 23db5a5..263e57f 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -288,3 +288,35 @@ async def test_direct_handler_alias_for_variant(player, target, punch_right): assert encounter.current_move.name == "punch right" assert encounter.attacker is player assert encounter.defender is target + + +# --- attack switching tests --- + + +@pytest.mark.asyncio +async def test_switch_attack_shows_switch_message( + player, target, punch_right, punch_left +): + """Test switching attack says 'switch to' instead of 'use'.""" + await combat_commands.do_attack(player, "Vegeta", punch_right) + player.writer.write.reset_mock() + + await combat_commands.do_attack(player, "", punch_left) + + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert any("switch to" in msg.lower() for msg in messages) + + +@pytest.mark.asyncio +async def test_switch_attack_sends_new_telegraph( + player, target, punch_right, punch_left +): + """Test switching attack sends new telegraph to defender.""" + await combat_commands.do_attack(player, "Vegeta", punch_right) + target.writer.write.reset_mock() + + await combat_commands.do_attack(player, "", punch_left) + + target_msgs = [call[0][0] for call in target.writer.write.call_args_list] + # Defender should get a new telegraph + assert len(target_msgs) > 0 diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py index 96c9adc..7b96466 100644 --- a/tests/test_combat_encounter.py +++ b/tests/test_combat_encounter.py @@ -51,6 +51,18 @@ def wrong_dodge(): ) +@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) @@ -297,3 +309,87 @@ def test_resolve_counter_messages_contain_move_name(attacker, defender, punch, d 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()