Convert combat resolution to POV templates

This commit is contained in:
Jared Miller 2026-02-13 23:21:52 -05:00
parent 15cc0d1ae0
commit 4e8459df5f
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
10 changed files with 422 additions and 56 deletions

View file

@ -9,6 +9,8 @@ from mudlib.combat.engine import get_encounter, start_encounter
from mudlib.combat.moves import CombatMove, load_moves from mudlib.combat.moves import CombatMove, load_moves
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players 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 will be injected after loading
combat_moves: dict[str, CombatMove] = {} 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 # Send telegraph to defender if they can receive messages
if hasattr(target, "send") and move.telegraph: 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") await target.send(f"{telegraph}\r\n")
except ValueError as e: except ValueError as e:
@ -71,7 +79,13 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
else: else:
defender = encounter.attacker defender = encounter.attacker
if hasattr(defender, "send") and move.telegraph: 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") await defender.send(f"{telegraph}\r\n")
# Detect switch before attack() modifies state # Detect switch before attack() modifies state

View file

@ -28,8 +28,7 @@ IDLE_TIMEOUT = 30.0
class ResolveResult: class ResolveResult:
"""Result of resolving a combat exchange.""" """Result of resolving a combat exchange."""
attacker_msg: str resolve_template: str # POV template for resolve message
defender_msg: str
damage: float damage: float
countered: bool countered: bool
combat_ended: bool combat_ended: bool
@ -114,21 +113,16 @@ class CombatEncounter:
"""Resolve the combat exchange and return result. """Resolve the combat exchange and return result.
Returns: Returns:
ResolveResult with messages for both participants ResolveResult with template for both participants
""" """
if self.current_move is None: if self.current_move is None:
return ResolveResult( return ResolveResult(
attacker_msg="No active move to resolve.", resolve_template="No active move to resolve.",
defender_msg="No active move to resolve.",
damage=0.0, damage=0.0,
countered=False, countered=False,
combat_ended=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 # Check if defense counters attack
defense_succeeds = ( defense_succeeds = (
self.pending_defense self.pending_defense
@ -137,29 +131,30 @@ class CombatEncounter:
if defense_succeeds: if defense_succeeds:
# Successful counter - no damage # Successful counter - no damage
damage = 0.0 damage = 0.0
attacker_msg = f"{defender_name} countered your {move_name}!" template = (
defender_msg = f"You countered {attacker_name}'s {move_name}!" self.current_move.resolve_miss
if self.current_move.resolve_miss
else "{defender} counter{s} {attacker}'s attack!"
)
countered = True countered = True
elif self.pending_defense: elif self.pending_defense:
# Wrong defense - normal damage # Wrong defense - normal damage
damage = self.attacker.pl * self.current_move.damage_pct damage = self.attacker.pl * self.current_move.damage_pct
self.defender.pl -= damage self.defender.pl -= damage
attacker_msg = ( template = (
f"Your {move_name} hits {defender_name} for {damage:.1f} damage!" self.current_move.resolve_hit
) if self.current_move.resolve_hit
defender_msg = ( else "{attacker}'s attack hit{s} {defender} for damage!"
f"{attacker_name}'s {move_name} hits you for {damage:.1f} damage!"
) )
countered = False countered = False
else: else:
# No defense - increased damage # No defense - increased damage
damage = self.attacker.pl * self.current_move.damage_pct * 1.5 damage = self.attacker.pl * self.current_move.damage_pct * 1.5
self.defender.pl -= damage self.defender.pl -= damage
attacker_msg = ( template = (
f"Your {move_name} slams {defender_name} for {damage:.1f} damage!" self.current_move.resolve_hit
) if self.current_move.resolve_hit
defender_msg = ( else "{attacker}'s attack hit{s} {defender} for damage!"
f"{attacker_name}'s {move_name} slams you for {damage:.1f} damage!"
) )
countered = False countered = False
@ -172,8 +167,7 @@ class CombatEncounter:
self.pending_defense = None self.pending_defense = None
return ResolveResult( return ResolveResult(
attacker_msg=attacker_msg, resolve_template=template,
defender_msg=defender_msg,
damage=damage, damage=damage,
countered=countered, countered=countered,
combat_ended=combat_ended, combat_ended=combat_ended,

View file

@ -5,6 +5,8 @@ import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.entity import Entity, Mob from mudlib.entity import Entity, Mob
from mudlib.gmcp import send_char_status, send_char_vitals 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 # Global list of active combat encounters
active_encounters: list[CombatEncounter] = [] active_encounters: list[CombatEncounter] = []
@ -91,16 +93,51 @@ async def process_combat() -> None:
end_encounter(encounter) end_encounter(encounter)
continue continue
# Save previous state to detect transitions
previous_state = encounter.state
# Tick the state machine # Tick the state machine
encounter.tick(now) 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 # Auto-resolve if in RESOLVE state
if encounter.state == CombatState.RESOLVE: if encounter.state == CombatState.RESOLVE:
# Save current_move before resolve() clears it
current_move = encounter.current_move
result = encounter.resolve() result = encounter.resolve()
# Send resolution messages to both participants # Send resolution messages to both participants using POV
await encounter.attacker.send(result.attacker_msg + "\r\n") resolve_color = current_move.resolve_color if current_move else "bold"
await encounter.defender.send(result.defender_msg + "\r\n")
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 # Send vitals update after damage resolution
from mudlib.player import Player from mudlib.player import Player

View file

@ -7,6 +7,8 @@ from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter from mudlib.combat.engine import get_encounter
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
from mudlib.mobs import mobs 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) # Seconds between mob actions (gives player time to read and react)
MOB_ACTION_COOLDOWN = 1.0 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) # Send telegraph to the player (the defender after role swap)
if move.telegraph: if move.telegraph:
telegraph_msg = move.telegraph.format(attacker=mob.name) defender = encounter.defender
await encounter.defender.send(f"{telegraph_msg}\r\n") 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): def _try_defend(mob, encounter, combat_moves, now):

View file

@ -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] 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 # 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 --- # --- defense commitment tests ---

View file

@ -154,8 +154,7 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge):
assert defender.pl == initial_pl assert defender.pl == initial_pl
assert result.damage == 0.0 assert result.damage == 0.0
assert result.countered is True assert result.countered is True
assert "countered" in result.attacker_msg.lower() assert "counter" in result.resolve_template.lower()
assert "countered" in result.defender_msg.lower()
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
assert result.combat_ended is False 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 defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage assert result.damage == expected_damage
assert result.countered is False assert result.countered is False
assert "hits" in result.attacker_msg.lower() assert "hit" in result.resolve_template.lower()
assert "hits" in result.defender_msg.lower()
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
assert result.combat_ended is False 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 defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage assert result.damage == expected_damage
assert result.countered is False assert result.countered is False
assert "slams" in result.attacker_msg.lower() # Template should indicate a hit
assert "slams" in result.defender_msg.lower() assert result.resolve_template != ""
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
assert result.combat_ended is False 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 assert result.combat_ended is False
def test_resolve_attacker_msg_contains_move_name(attacker, defender, punch): def test_resolve_template_not_empty(attacker, defender, punch):
"""Test attacker message includes the move name.""" """Test resolve returns a template."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
result = encounter.resolve() 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): def test_resolve_template_uses_pov_tags(attacker, defender, punch):
"""Test defender message includes attacker's name.""" """Test resolve template uses POV tags."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
result = encounter.resolve() 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): def test_resolve_counter_template_indicates_counter(attacker, defender, punch, dodge):
"""Test counter messages include the move name for both players.""" """Test counter template indicates a successful counter."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
encounter.defend(dodge) encounter.defend(dodge)
result = encounter.resolve() result = encounter.resolve()
assert "punch right" in result.attacker_msg.lower() assert "counter" in result.resolve_template.lower()
assert "punch right" in result.defender_msg.lower()
# --- Attack switching (feint) tests --- # --- 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) # Sweep does 0.20 * 1.5 = 0.30 of PL (no defense)
expected_damage = attacker.pl * sweep.damage_pct * 1.5 expected_damage = attacker.pl * sweep.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage assert defender.pl == initial_pl - expected_damage
assert "sweep" in result.attacker_msg.lower() assert result.resolve_template != ""
# --- last_action_at tracking tests --- # --- last_action_at tracking tests ---

View file

@ -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] 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] 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 len(attacker_msgs) > 0
assert any("punch right" in msg.lower() for msg in defender_msgs) assert len(defender_msgs) > 0
# --- Idle timeout tests --- # --- Idle timeout tests ---

View file

@ -177,7 +177,7 @@ async def test_commands_detail_simple_combat_move(player, combat_moves):
assert "stamina: 8.0" in output assert "stamina: 8.0" in output
assert "timing window: 2000ms" in output assert "timing window: 2000ms" in output
assert "damage: 25%" 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 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 # Should show both variants
assert "punch left" in output 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 "countered by: dodge right, parry high" in output
assert "punch right" 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 assert "countered by: dodge left, parry high" in output
# Should show shared properties in each variant # 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 "stamina: 5.0" in output
assert "timing window: 1800ms" in output assert "timing window: 1800ms" in output
assert "damage: 15%" 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 assert "countered by: dodge right, parry high" in output
# Should NOT show "punch right" # Should NOT show "punch right"

View file

@ -253,8 +253,8 @@ class TestMobAttackAI:
player.writer.write.assert_called() player.writer.write.assert_called()
calls = [str(call) for call in player.writer.write.call_args_list] calls = [str(call) for call in player.writer.write.call_args_list]
# Check for actual telegraph patterns from the TOML files # Check for actual telegraph patterns from the TOML files
# Goblin moves: punch left/right ("winds up"), sweep ("drops low") # Goblin moves: punch left/right ("retracts"), sweep ("drops low")
telegraph_patterns = ["winds up", "drops low"] telegraph_patterns = ["retracts", "drops low"]
telegraph_sent = any( telegraph_sent = any(
"goblin" in call.lower() "goblin" in call.lower()
and any(pattern in call.lower() for pattern in telegraph_patterns) and any(pattern in call.lower() for pattern in telegraph_patterns)

314
tests/test_three_beat.py Normal file
View file

@ -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)