diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py index 5f853cc..53a6076 100644 --- a/src/mudlib/combat/encounter.py +++ b/src/mudlib/combat/encounter.py @@ -21,6 +21,17 @@ class CombatState(Enum): TELEGRAPH_DURATION = 0.3 +@dataclass +class ResolveResult: + """Result of resolving a combat exchange.""" + + attacker_msg: str + defender_msg: str + damage: float + countered: bool + combat_ended: bool + + @dataclass class CombatEncounter: """Represents an active combat encounter between two entities.""" @@ -80,14 +91,24 @@ class CombatEncounter: if elapsed >= total_time: self.state = CombatState.RESOLVE - def resolve(self) -> tuple[str, bool]: - """Resolve the combat exchange and return result message. + def resolve(self) -> ResolveResult: + """Resolve the combat exchange and return result. Returns: - Tuple of (result message, combat_ended flag) + ResolveResult with messages for both participants """ if self.current_move is None: - return ("No active move to resolve.", False) + return ResolveResult( + attacker_msg="No active move to resolve.", + defender_msg="No active move to resolve.", + damage=0.0, + countered=False, + combat_ended=False, + ) + + move_name = self.current_move.name + attacker_name = self.attacker.name + defender_name = self.defender.name # Check if defense counters attack defense_succeeds = ( @@ -96,22 +117,32 @@ class CombatEncounter: ) if defense_succeeds: # Successful counter - no damage - result = f"{self.defender.name} countered the attack!" + damage = 0.0 + attacker_msg = f"{defender_name} countered your {move_name}!" + defender_msg = f"You countered {attacker_name}'s {move_name}!" + countered = True elif self.pending_defense: # Wrong defense - normal damage damage = self.attacker.pl * self.current_move.damage_pct self.defender.pl -= damage - result = ( - f"{self.attacker.name} hit {self.defender.name} " - f"for {damage:.1f} damage!" + attacker_msg = ( + f"Your {move_name} hits {defender_name} for {damage:.1f} damage!" ) + defender_msg = ( + f"{attacker_name}'s {move_name} hits you for {damage:.1f} damage!" + ) + countered = False else: # No defense - increased damage damage = self.attacker.pl * self.current_move.damage_pct * 1.5 self.defender.pl -= damage - result = ( - f"{self.defender.name} took the hit full force for {damage:.1f} damage!" + attacker_msg = ( + f"Your {move_name} slams {defender_name} for {damage:.1f} damage!" ) + defender_msg = ( + f"{attacker_name}'s {move_name} slams you for {damage:.1f} damage!" + ) + countered = False # Check for combat end conditions combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0 @@ -121,4 +152,10 @@ class CombatEncounter: self.current_move = None self.pending_defense = None - return (result, combat_ended) + return ResolveResult( + attacker_msg=attacker_msg, + defender_msg=defender_msg, + damage=damage, + countered=countered, + combat_ended=combat_ended, + ) diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 4000c18..b27e75f 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -63,7 +63,7 @@ def end_encounter(encounter: CombatEncounter) -> None: active_encounters.remove(encounter) -def process_combat() -> None: +async def process_combat() -> None: """Process all active combat encounters. This should be called each game loop tick to advance combat state machines. @@ -76,9 +76,13 @@ def process_combat() -> None: # Auto-resolve if in RESOLVE state if encounter.state == CombatState.RESOLVE: - _result, combat_ended = encounter.resolve() + result = encounter.resolve() - if combat_ended: + # Send resolution messages to both participants + await encounter.attacker.send(result.attacker_msg + "\r\n") + await encounter.defender.send(result.defender_msg + "\r\n") + + if result.combat_ended: # Pop combat mode from both entities if they're Players from mudlib.player import Player diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 7e7dc24..1578868 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -61,7 +61,7 @@ async def game_loop() -> None: while True: t0 = asyncio.get_event_loop().time() clear_expired() - process_combat() + await process_combat() # Periodic auto-save (every 60 seconds) current_time = time.monotonic() diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py index 70ee9ce..96c9adc 100644 --- a/tests/test_combat_encounter.py +++ b/tests/test_combat_encounter.py @@ -4,7 +4,7 @@ import time import pytest -from mudlib.combat.encounter import CombatEncounter, CombatState +from mudlib.combat.encounter import CombatEncounter, CombatState, ResolveResult from mudlib.combat.moves import CombatMove from mudlib.entity import Entity @@ -136,12 +136,16 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge): encounter.defend(dodge) initial_pl = defender.pl - result, combat_ended = encounter.resolve() + result = encounter.resolve() + assert isinstance(result, ResolveResult) assert defender.pl == initial_pl - assert "countered" in result.lower() + 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 combat_ended is False + assert result.combat_ended is False def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge): @@ -151,13 +155,16 @@ def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge): encounter.defend(wrong_dodge) initial_pl = defender.pl - result, combat_ended = encounter.resolve() + result = encounter.resolve() expected_damage = attacker.pl * punch.damage_pct assert defender.pl == initial_pl - expected_damage - assert "hit" in result.lower() + 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 combat_ended is False + assert result.combat_ended is False def test_resolve_no_defense(attacker, defender, punch): @@ -166,14 +173,17 @@ def test_resolve_no_defense(attacker, defender, punch): encounter.attack(punch) initial_pl = defender.pl - result, combat_ended = encounter.resolve() + 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 "full force" in result.lower() + 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 combat_ended is False + assert result.combat_ended is False def test_resolve_clears_pending_defense(attacker, defender, punch, dodge): @@ -182,7 +192,7 @@ def test_resolve_clears_pending_defense(attacker, defender, punch, dodge): encounter.attack(punch) encounter.defend(dodge) - _result, _combat_ended = encounter.resolve() + encounter.resolve() assert encounter.pending_defense is None assert encounter.current_move is None @@ -207,7 +217,7 @@ def test_full_state_machine_cycle(attacker, defender, punch): assert encounter.state == CombatState.RESOLVE # RESOLVE → IDLE - _result, _combat_ended = encounter.resolve() + encounter.resolve() assert encounter.state == CombatState.IDLE @@ -227,11 +237,11 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch): defender.pl = 10.0 encounter.attack(punch) - result, combat_ended = encounter.resolve() + result = encounter.resolve() assert defender.pl <= 0 - assert combat_ended is True - assert "damage" in result.lower() + assert result.combat_ended is True + assert result.damage > 0 def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): @@ -242,10 +252,10 @@ def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): attacker.stamina = punch.stamina_cost encounter.attack(punch) - result, combat_ended = encounter.resolve() + result = encounter.resolve() assert attacker.stamina <= 0 - assert combat_ended is True + assert result.combat_ended is True def test_resolve_returns_combat_continues_normally(attacker, defender, punch): @@ -253,8 +263,37 @@ def test_resolve_returns_combat_continues_normally(attacker, defender, punch): encounter = CombatEncounter(attacker=attacker, defender=defender) encounter.attack(punch) - result, combat_ended = encounter.resolve() + result = encounter.resolve() assert attacker.stamina > 0 assert defender.pl > 0 - assert combat_ended is False + 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() diff --git a/tests/test_combat_engine.py b/tests/test_combat_engine.py index 4554182..dcdc28c 100644 --- a/tests/test_combat_engine.py +++ b/tests/test_combat_engine.py @@ -1,6 +1,7 @@ """Tests for combat engine and encounter management.""" import time +from unittest.mock import AsyncMock, MagicMock import pytest @@ -14,6 +15,14 @@ from mudlib.combat.engine import ( ) from mudlib.combat.moves import CombatMove from mudlib.entity import Entity +from mudlib.player import Player + + +def _mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer @pytest.fixture(autouse=True) @@ -88,19 +97,21 @@ def test_end_encounter_removes_from_active_list(attacker, defender): assert get_encounter(defender) is None -def test_process_combat_advances_encounters(attacker, defender, punch): +@pytest.mark.asyncio +async def test_process_combat_advances_encounters(attacker, defender, punch): """Test process_combat advances all active encounters.""" encounter = start_encounter(attacker, defender) encounter.attack(punch) # Process combat should advance state from TELEGRAPH to WINDOW time.sleep(0.31) - process_combat() + await process_combat() assert encounter.state == CombatState.WINDOW -def test_process_combat_handles_multiple_encounters(punch): +@pytest.mark.asyncio +async def test_process_combat_handles_multiple_encounters(punch): """Test process_combat handles multiple simultaneous encounters.""" e1_attacker = Entity(name="A1", x=0, y=0) e1_defender = Entity(name="D1", x=0, y=0) @@ -114,13 +125,14 @@ def test_process_combat_handles_multiple_encounters(punch): enc2.attack(punch) time.sleep(0.31) - process_combat() + await process_combat() assert enc1.state == CombatState.WINDOW assert enc2.state == CombatState.WINDOW -def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch): +@pytest.mark.asyncio +async def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch): """Test process_combat auto-resolves when window expires.""" encounter = start_encounter(attacker, defender) encounter.attack(punch) @@ -128,11 +140,11 @@ def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch) # Skip past telegraph and window time.sleep(0.31) # Telegraph - process_combat() + await process_combat() assert encounter.state == CombatState.WINDOW time.sleep(0.85) # Window - process_combat() + await process_combat() # Should auto-resolve and return to IDLE assert encounter.state == CombatState.IDLE # Damage should have been applied (no defense = 1.5x damage) @@ -162,21 +174,23 @@ def test_active_encounters_list(): assert isinstance(active_encounters, list) -def test_process_combat_with_no_encounters(): +@pytest.mark.asyncio +async def test_process_combat_with_no_encounters(): """Test process_combat handles empty encounter list.""" - process_combat() # Should not raise + await process_combat() # Should not raise -def test_encounter_cleanup_after_resolution(attacker, defender, punch): +@pytest.mark.asyncio +async def test_encounter_cleanup_after_resolution(attacker, defender, punch): """Test encounter can be ended after resolution.""" encounter = start_encounter(attacker, defender) encounter.attack(punch) # Advance to resolution time.sleep(0.31) - process_combat() + await process_combat() time.sleep(0.85) - process_combat() + await process_combat() # Resolve encounter.resolve() @@ -186,12 +200,12 @@ def test_encounter_cleanup_after_resolution(attacker, defender, punch): assert get_encounter(attacker) is None -def test_process_combat_ends_encounter_on_knockout(punch): +@pytest.mark.asyncio +async def test_process_combat_ends_encounter_on_knockout(punch): """Test process_combat ends encounter when defender is knocked out.""" - from mudlib.player import Player - - attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) - defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0) + w = _mock_writer + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w()) + defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w()) # Push combat mode onto both stacks attacker.mode_stack.append("combat") @@ -202,9 +216,9 @@ def test_process_combat_ends_encounter_on_knockout(punch): # Advance to resolution time.sleep(0.31) - process_combat() + await process_combat() time.sleep(0.85) - process_combat() + await process_combat() # Combat should have ended and been cleaned up assert get_encounter(attacker) is None @@ -214,12 +228,26 @@ def test_process_combat_ends_encounter_on_knockout(punch): assert defender.mode_stack == ["normal"] -def test_process_combat_ends_encounter_on_exhaustion(punch): +@pytest.mark.asyncio +async def test_process_combat_ends_encounter_on_exhaustion(punch): """Test process_combat ends encounter when attacker is exhausted.""" - from mudlib.player import Player - - attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=punch.stamina_cost) - defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) + w = _mock_writer + attacker = Player( + name="Goku", + x=0, + y=0, + pl=100.0, + stamina=punch.stamina_cost, + writer=w(), + ) + defender = Player( + name="Vegeta", + x=0, + y=0, + pl=100.0, + stamina=50.0, + writer=w(), + ) # Push combat mode onto both stacks attacker.mode_stack.append("combat") @@ -230,9 +258,9 @@ def test_process_combat_ends_encounter_on_exhaustion(punch): # Advance to resolution time.sleep(0.31) - process_combat() + await process_combat() time.sleep(0.85) - process_combat() + await process_combat() # Combat should have ended assert get_encounter(attacker) is None @@ -241,12 +269,12 @@ def test_process_combat_ends_encounter_on_exhaustion(punch): assert defender.mode_stack == ["normal"] -def test_process_combat_continues_with_resources(punch): +@pytest.mark.asyncio +async def test_process_combat_continues_with_resources(punch): """Test process_combat continues encounter when both have resources.""" - from mudlib.player import Player - - attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) - defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) + w = _mock_writer + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w()) + defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=w()) # Push combat mode onto both stacks attacker.mode_stack.append("combat") @@ -257,12 +285,46 @@ def test_process_combat_continues_with_resources(punch): # Advance to resolution time.sleep(0.31) - process_combat() + await process_combat() time.sleep(0.85) - process_combat() + await process_combat() # Combat should still be active (but in IDLE state) assert get_encounter(attacker) is encounter assert get_encounter(defender) is encounter assert attacker.mode_stack == ["normal", "combat"] assert defender.mode_stack == ["normal", "combat"] + + +@pytest.mark.asyncio +async def test_process_combat_sends_messages_on_resolve(punch): + """Test process_combat sends resolution messages to both players.""" + mock_writer = _mock_writer() + attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=mock_writer) + defender_writer = _mock_writer() + defender = Player( + name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=defender_writer + ) + + attacker.mode_stack.append("combat") + defender.mode_stack.append("combat") + + encounter = start_encounter(attacker, defender) + encounter.attack(punch) + + # Advance past telegraph and window + time.sleep(0.31) + await process_combat() + time.sleep(0.85) + + mock_writer.write.reset_mock() + defender_writer.write.reset_mock() + + await process_combat() + + # Both players should have received messages + attacker_msgs = [call[0][0] for call in mock_writer.write.call_args_list] + defender_msgs = [call[0][0] for call in defender_writer.write.call_args_list] + + assert any("punch right" in msg.lower() for msg in attacker_msgs) + assert any("punch right" in msg.lower() for msg in defender_msgs)