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.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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
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