Convert combat resolution to POV templates
This commit is contained in:
parent
15cc0d1ae0
commit
4e8459df5f
10 changed files with 422 additions and 56 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
314
tests/test_three_beat.py
Normal file
314
tests/test_three_beat.py
Normal 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)
|
||||
Loading…
Reference in a new issue