From 4e8459df5f17b5e7924d05bab67bed42261c0efc Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Fri, 13 Feb 2026 23:21:52 -0500 Subject: [PATCH] Convert combat resolution to POV templates --- src/mudlib/combat/commands.py | 18 +- src/mudlib/combat/encounter.py | 40 ++--- src/mudlib/combat/engine.py | 43 ++++- src/mudlib/mob_ai.py | 13 +- tests/test_combat_commands.py | 2 +- tests/test_combat_encounter.py | 32 ++-- tests/test_combat_engine.py | 4 +- tests/test_commands_list.py | 8 +- tests/test_mob_ai.py | 4 +- tests/test_three_beat.py | 314 +++++++++++++++++++++++++++++++++ 10 files changed, 422 insertions(+), 56 deletions(-) create mode 100644 tests/test_three_beat.py diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index b26727e..c87b40c 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -9,6 +9,8 @@ from mudlib.combat.engine import get_encounter, start_encounter from mudlib.combat.moves import CombatMove, load_moves from mudlib.commands import CommandDefinition, register from mudlib.player import Player, players +from mudlib.render.colors import colorize +from mudlib.render.pov import render_pov # Combat moves will be injected after loading combat_moves: dict[str, CombatMove] = {} @@ -58,7 +60,13 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: # Send telegraph to defender if they can receive messages if hasattr(target, "send") and move.telegraph: - telegraph = move.telegraph.format(attacker=player.name) + telegraph = render_pov(move.telegraph, target, player, target) + telegraph_color = move.telegraph_color + if telegraph_color: + color_depth = getattr(target, "color_depth", None) + telegraph = colorize( + f"{{{telegraph_color}}}{telegraph}{{/}}", color_depth + ) await target.send(f"{telegraph}\r\n") except ValueError as e: @@ -71,7 +79,13 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: else: defender = encounter.attacker if hasattr(defender, "send") and move.telegraph: - telegraph = move.telegraph.format(attacker=player.name) + telegraph = render_pov(move.telegraph, defender, player, defender) + telegraph_color = move.telegraph_color + if telegraph_color: + color_depth = getattr(defender, "color_depth", None) + telegraph = colorize( + f"{{{telegraph_color}}}{telegraph}{{/}}", color_depth + ) await defender.send(f"{telegraph}\r\n") # Detect switch before attack() modifies state diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py index 52c206a..93b82d4 100644 --- a/src/mudlib/combat/encounter.py +++ b/src/mudlib/combat/encounter.py @@ -28,8 +28,7 @@ IDLE_TIMEOUT = 30.0 class ResolveResult: """Result of resolving a combat exchange.""" - attacker_msg: str - defender_msg: str + resolve_template: str # POV template for resolve message damage: float countered: bool combat_ended: bool @@ -114,21 +113,16 @@ class CombatEncounter: """Resolve the combat exchange and return result. Returns: - ResolveResult with messages for both participants + ResolveResult with template for both participants """ if self.current_move is None: return ResolveResult( - attacker_msg="No active move to resolve.", - defender_msg="No active move to resolve.", + resolve_template="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 = ( self.pending_defense @@ -137,29 +131,30 @@ class CombatEncounter: if defense_succeeds: # Successful counter - no damage damage = 0.0 - attacker_msg = f"{defender_name} countered your {move_name}!" - defender_msg = f"You countered {attacker_name}'s {move_name}!" + template = ( + self.current_move.resolve_miss + if self.current_move.resolve_miss + else "{defender} counter{s} {attacker}'s attack!" + ) countered = True elif self.pending_defense: # Wrong defense - normal damage damage = self.attacker.pl * self.current_move.damage_pct self.defender.pl -= 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!" + template = ( + self.current_move.resolve_hit + if self.current_move.resolve_hit + else "{attacker}'s attack hit{s} {defender} for damage!" ) countered = False else: # No defense - increased damage damage = self.attacker.pl * self.current_move.damage_pct * 1.5 self.defender.pl -= 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!" + template = ( + self.current_move.resolve_hit + if self.current_move.resolve_hit + else "{attacker}'s attack hit{s} {defender} for damage!" ) countered = False @@ -172,8 +167,7 @@ class CombatEncounter: self.pending_defense = None return ResolveResult( - attacker_msg=attacker_msg, - defender_msg=defender_msg, + resolve_template=template, damage=damage, countered=countered, combat_ended=combat_ended, diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index a239265..3668b81 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -5,6 +5,8 @@ import time from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.entity import Entity, Mob from mudlib.gmcp import send_char_status, send_char_vitals +from mudlib.render.colors import colorize +from mudlib.render.pov import render_pov # Global list of active combat encounters active_encounters: list[CombatEncounter] = [] @@ -91,16 +93,51 @@ async def process_combat() -> None: end_encounter(encounter) continue + # Save previous state to detect transitions + previous_state = encounter.state + # Tick the state machine encounter.tick(now) + # Send announce message on TELEGRAPH → WINDOW transition + if ( + previous_state == CombatState.TELEGRAPH + and encounter.state == CombatState.WINDOW + and encounter.current_move + and encounter.current_move.announce + ): + template = encounter.current_move.announce + announce_color = encounter.current_move.announce_color + + for viewer in (encounter.attacker, encounter.defender): + msg = render_pov( + template, viewer, encounter.attacker, encounter.defender + ) + if announce_color: + color_depth = getattr(viewer, "color_depth", None) + msg = colorize(f"{{{announce_color}}}{msg}{{/}}", color_depth) + await viewer.send(msg + "\r\n") + # Auto-resolve if in RESOLVE state if encounter.state == CombatState.RESOLVE: + # Save current_move before resolve() clears it + current_move = encounter.current_move result = encounter.resolve() - # Send resolution messages to both participants - await encounter.attacker.send(result.attacker_msg + "\r\n") - await encounter.defender.send(result.defender_msg + "\r\n") + # Send resolution messages to both participants using POV + resolve_color = current_move.resolve_color if current_move else "bold" + + for viewer in (encounter.attacker, encounter.defender): + msg = render_pov( + result.resolve_template, + viewer, + encounter.attacker, + encounter.defender, + ) + if resolve_color: + color_depth = getattr(viewer, "color_depth", None) + msg = colorize(f"{{{resolve_color}}}{msg}{{/}}", color_depth) + await viewer.send(msg + "\r\n") # Send vitals update after damage resolution from mudlib.player import Player diff --git a/src/mudlib/mob_ai.py b/src/mudlib/mob_ai.py index fa5c3c0..dfe51f8 100644 --- a/src/mudlib/mob_ai.py +++ b/src/mudlib/mob_ai.py @@ -7,6 +7,8 @@ from mudlib.combat.encounter import CombatState from mudlib.combat.engine import get_encounter from mudlib.combat.moves import CombatMove from mudlib.mobs import mobs +from mudlib.render.colors import colorize +from mudlib.render.pov import render_pov # Seconds between mob actions (gives player time to read and react) MOB_ACTION_COOLDOWN = 1.0 @@ -73,8 +75,15 @@ async def _try_attack(mob, encounter, combat_moves, now): # Send telegraph to the player (the defender after role swap) if move.telegraph: - telegraph_msg = move.telegraph.format(attacker=mob.name) - await encounter.defender.send(f"{telegraph_msg}\r\n") + defender = encounter.defender + telegraph_msg = render_pov(move.telegraph, defender, mob, defender) + telegraph_color = move.telegraph_color + if telegraph_color: + color_depth = getattr(defender, "color_depth", None) + telegraph_msg = colorize( + f"{{{telegraph_color}}}{telegraph_msg}{{/}}", color_depth + ) + await defender.send(f"{telegraph_msg}\r\n") def _try_defend(mob, encounter, combat_moves, now): diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index 2f9d01e..d63641f 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -338,7 +338,7 @@ async def test_switch_attack_sends_new_telegraph( target_msgs = [call[0][0] for call in target.writer.write.call_args_list] # Defender should get a new telegraph with the new move's text - assert any("left hook" in msg.lower() for msg in target_msgs) + assert any("left arm" in msg.lower() for msg in target_msgs) # --- defense commitment tests --- diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py index fd70310..f7399fc 100644 --- a/tests/test_combat_encounter.py +++ b/tests/test_combat_encounter.py @@ -154,8 +154,7 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge): assert defender.pl == initial_pl 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 "counter" in result.resolve_template.lower() assert encounter.state == CombatState.IDLE assert result.combat_ended is False @@ -173,8 +172,7 @@ def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge): assert defender.pl == initial_pl - expected_damage 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 "hit" in result.resolve_template.lower() assert encounter.state == CombatState.IDLE assert result.combat_ended is False @@ -192,8 +190,8 @@ def test_resolve_no_defense(attacker, defender, punch): assert defender.pl == initial_pl - expected_damage assert result.damage == expected_damage assert result.countered is False - assert "slams" in result.attacker_msg.lower() - assert "slams" in result.defender_msg.lower() + # Template should indicate a hit + assert result.resolve_template != "" assert encounter.state == CombatState.IDLE assert result.combat_ended is False @@ -282,33 +280,33 @@ def test_resolve_returns_combat_continues_normally(attacker, defender, punch): assert result.combat_ended is False -def test_resolve_attacker_msg_contains_move_name(attacker, defender, punch): - """Test attacker message includes the move name.""" +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 "punch right" in result.attacker_msg.lower() + assert result.resolve_template != "" -def test_resolve_defender_msg_contains_attacker_name(attacker, defender, punch): - """Test defender message includes attacker's name.""" +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() - assert attacker.name.lower() in result.defender_msg.lower() + # Template should contain POV tags like {attacker} or {defender} + assert "{" in result.resolve_template -def test_resolve_counter_messages_contain_move_name(attacker, defender, punch, dodge): - """Test counter messages include the move name for both players.""" +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 "punch right" in result.attacker_msg.lower() - assert "punch right" in result.defender_msg.lower() + assert "counter" in result.resolve_template.lower() # --- Attack switching (feint) tests --- @@ -392,7 +390,7 @@ def test_resolve_uses_final_move(attacker, defender, punch, sweep): # 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() + assert result.resolve_template != "" # --- last_action_at tracking tests --- diff --git a/tests/test_combat_engine.py b/tests/test_combat_engine.py index 8580fd0..ae60220 100644 --- a/tests/test_combat_engine.py +++ b/tests/test_combat_engine.py @@ -326,8 +326,8 @@ async def test_process_combat_sends_messages_on_resolve(punch): 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) + assert len(attacker_msgs) > 0 + assert len(defender_msgs) > 0 # --- Idle timeout tests --- diff --git a/tests/test_commands_list.py b/tests/test_commands_list.py index 1cf560f..4ebf2c6 100644 --- a/tests/test_commands_list.py +++ b/tests/test_commands_list.py @@ -177,7 +177,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves): assert "stamina: 8.0" in output assert "timing window: 2000ms" in output assert "damage: 25%" in output - assert "{attacker} spins into a roundhouse kick!" in output + assert "{attacker} shifts {his} weight back..." in output assert "countered by: duck, parry high, parry low" in output @@ -192,11 +192,11 @@ async def test_commands_detail_variant_base(player, combat_moves): # Should show both variants assert "punch left" in output - assert "{attacker} winds up a left hook!" in output + assert "{attacker} retracts {his} left arm..." in output assert "countered by: dodge right, parry high" in output assert "punch right" in output - assert "{attacker} winds up a right hook!" in output + assert "{attacker} retracts {his} right arm..." in output assert "countered by: dodge left, parry high" in output # Should show shared properties in each variant @@ -216,7 +216,7 @@ async def test_commands_detail_specific_variant(player, combat_moves): assert "stamina: 5.0" in output assert "timing window: 1800ms" in output assert "damage: 15%" in output - assert "{attacker} winds up a left hook!" in output + assert "{attacker} retracts {his} left arm..." in output assert "countered by: dodge right, parry high" in output # Should NOT show "punch right" diff --git a/tests/test_mob_ai.py b/tests/test_mob_ai.py index 5835730..23a0e21 100644 --- a/tests/test_mob_ai.py +++ b/tests/test_mob_ai.py @@ -253,8 +253,8 @@ class TestMobAttackAI: player.writer.write.assert_called() calls = [str(call) for call in player.writer.write.call_args_list] # Check for actual telegraph patterns from the TOML files - # Goblin moves: punch left/right ("winds up"), sweep ("drops low") - telegraph_patterns = ["winds up", "drops low"] + # Goblin moves: punch left/right ("retracts"), sweep ("drops low") + telegraph_patterns = ["retracts", "drops low"] telegraph_sent = any( "goblin" in call.lower() and any(pattern in call.lower() for pattern in telegraph_patterns) diff --git a/tests/test_three_beat.py b/tests/test_three_beat.py new file mode 100644 index 0000000..ca7819e --- /dev/null +++ b/tests/test_three_beat.py @@ -0,0 +1,314 @@ +"""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)