"""Tests for three-beat combat output system.""" import time from unittest.mock import AsyncMock, MagicMock import pytest from mudlib.caps import ClientCaps from mudlib.combat.encounter import CombatState from mudlib.combat.engine import ( active_encounters, process_combat, start_encounter, ) from mudlib.combat.moves import CombatMove from mudlib.player import Player def _mock_writer(): writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() return writer @pytest.fixture(autouse=True) def clear_encounters(): """Clear encounters before and after each test.""" active_encounters.clear() yield active_encounters.clear() @pytest.fixture def punch(): return CombatMove( name="punch left", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, countered_by=["dodge right"], telegraph="{attacker} retracts {his} left arm...", announce="{attacker} throw{s} a left hook at {defender}!", resolve_hit="{attacker} connect{s} with {his} left hook!", resolve_miss="{defender} counter{s} the left hook!", telegraph_color="dim", announce_color="", resolve_color="bold", ) @pytest.mark.asyncio async def test_announce_sent_on_telegraph_to_window_transition(punch): """Test announce message sent when TELEGRAPH→WINDOW transition occurs.""" atk_writer = _mock_writer() def_writer = _mock_writer() attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer ) encounter = start_encounter(attacker, defender) encounter.attack(punch) # Should be in TELEGRAPH state assert encounter.state == CombatState.TELEGRAPH # Reset mocks to ignore previous messages atk_writer.write.reset_mock() def_writer.write.reset_mock() # Wait for telegraph phase and process time.sleep(0.31) await process_combat() # Should have transitioned to WINDOW assert encounter.state == CombatState.WINDOW # Check announce message was sent atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] def_msgs = [call[0][0] for call in def_writer.write.call_args_list] # Attacker should see "You throw a left hook at Vegeta!" assert any("throw" in msg.lower() and "hook" in msg.lower() for msg in atk_msgs) # Defender should see "Goku throws a left hook at you!" assert any("throws" in msg.lower() and "hook" in msg.lower() for msg in def_msgs) @pytest.mark.asyncio async def test_announce_uses_pov_templates(punch): """Test announce messages use POV templates correctly.""" atk_writer = _mock_writer() def_writer = _mock_writer() attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer ) encounter = start_encounter(attacker, defender) encounter.attack(punch) atk_writer.write.reset_mock() def_writer.write.reset_mock() time.sleep(0.31) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] def_msgs = [call[0][0] for call in def_writer.write.call_args_list] # Attacker POV: "You throw a left hook at Vegeta!" assert any("You throw" in msg for msg in atk_msgs) # Defender POV: "Goku throws a left hook at You!" assert any("Goku throws" in msg and "at You" in msg for msg in def_msgs) @pytest.mark.asyncio async def test_resolve_uses_pov_templates(punch): """Test resolve messages use POV templates correctly.""" atk_writer = _mock_writer() def_writer = _mock_writer() attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer ) encounter = start_encounter(attacker, defender) encounter.attack(punch) # Advance to window time.sleep(0.31) await process_combat() atk_writer.write.reset_mock() def_writer.write.reset_mock() # Advance to resolve time.sleep(0.85) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] def_msgs = [call[0][0] for call in def_writer.write.call_args_list] # Attacker POV: "You connect with your left hook!" assert any("You connect" in msg and "your" in msg for msg in atk_msgs) # Defender POV: "Goku connects with his left hook!" assert any("Goku connects" in msg and "his" in msg for msg in def_msgs) @pytest.mark.asyncio async def test_resolve_uses_resolve_miss_on_counter(punch): """Test resolve uses resolve_miss template when countered.""" atk_writer = _mock_writer() def_writer = _mock_writer() attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer ) dodge = CombatMove( name="dodge right", move_type="defense", stamina_cost=3.0, timing_window_ms=800, ) encounter = start_encounter(attacker, defender) encounter.attack(punch) encounter.defend(dodge) # Advance to resolve time.sleep(0.31) await process_combat() atk_writer.write.reset_mock() def_writer.write.reset_mock() time.sleep(0.85) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] def_msgs = [call[0][0] for call in def_writer.write.call_args_list] # Should use resolve_miss template # Attacker POV: "Vegeta counters your left hook!" assert any("counter" in msg.lower() for msg in atk_msgs) # Defender POV: "You counter Goku's left hook!" assert any("You counter" in msg for msg in def_msgs) @pytest.mark.asyncio async def test_announce_color_applied(punch): """Test announce messages have no color wrap (default).""" atk_writer = _mock_writer() def_writer = _mock_writer() # Set color depth to enable colors via caps attacker = Player( name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer, caps=ClientCaps(ansi=True), ) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer, caps=ClientCaps(ansi=True), ) encounter = start_encounter(attacker, defender) encounter.attack(punch) atk_writer.write.reset_mock() def_writer.write.reset_mock() time.sleep(0.31) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] # Announce has announce_color="" so should NOT have ANSI codes # (just plain text) announce_msgs = [msg for msg in atk_msgs if "throw" in msg.lower()] assert len(announce_msgs) > 0 # Should not contain ANSI escape sequences for colors # (no \033[ sequences for colors, may have for other reasons) @pytest.mark.asyncio async def test_resolve_bold_color_applied(punch): """Test resolve messages have bold color applied.""" atk_writer = _mock_writer() def_writer = _mock_writer() attacker = Player( name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer, caps=ClientCaps(ansi=True), ) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer, caps=ClientCaps(ansi=True), ) encounter = start_encounter(attacker, defender) encounter.attack(punch) # Advance to resolve time.sleep(0.31) await process_combat() atk_writer.write.reset_mock() def_writer.write.reset_mock() time.sleep(0.85) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] # Resolve has resolve_color="bold" so should have ANSI bold codes resolve_msgs = [msg for msg in atk_msgs if "connect" in msg.lower()] assert len(resolve_msgs) > 0 # Should contain ANSI bold sequence assert any("\033[1m" in msg for msg in resolve_msgs) @pytest.mark.asyncio async def test_no_announce_on_idle_to_telegraph(): """Test no announce message sent on IDLE→TELEGRAPH transition.""" atk_writer = _mock_writer() def_writer = _mock_writer() attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=atk_writer) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=def_writer ) punch = CombatMove( name="punch left", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, announce="{attacker} throw{s} a left hook at {defender}!", ) encounter = start_encounter(attacker, defender) atk_writer.write.reset_mock() def_writer.write.reset_mock() encounter.attack(punch) # Process combat immediately (still in TELEGRAPH) await process_combat() atk_msgs = [call[0][0] for call in atk_writer.write.call_args_list] # Should NOT see announce message yet assert not any("throw" in msg.lower() for msg in atk_msgs)