Add combat resolution messages with both-POV feedback

resolve() returns ResolveResult dataclass with attacker_msg, defender_msg,
damage, countered, and combat_ended fields. process_combat is now async
and sends messages to both participants on resolve. Counter, hit, and
slam messages give each player their own perspective on what happened.
This commit is contained in:
Jared Miller 2026-02-08 11:32:47 -05:00
parent 588227bcd0
commit 9054962f5d
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 209 additions and 67 deletions

View file

@ -21,6 +21,17 @@ class CombatState(Enum):
TELEGRAPH_DURATION = 0.3 TELEGRAPH_DURATION = 0.3
@dataclass
class ResolveResult:
"""Result of resolving a combat exchange."""
attacker_msg: str
defender_msg: str
damage: float
countered: bool
combat_ended: bool
@dataclass @dataclass
class CombatEncounter: class CombatEncounter:
"""Represents an active combat encounter between two entities.""" """Represents an active combat encounter between two entities."""
@ -80,14 +91,24 @@ class CombatEncounter:
if elapsed >= total_time: if elapsed >= total_time:
self.state = CombatState.RESOLVE self.state = CombatState.RESOLVE
def resolve(self) -> tuple[str, bool]: def resolve(self) -> ResolveResult:
"""Resolve the combat exchange and return result message. """Resolve the combat exchange and return result.
Returns: Returns:
Tuple of (result message, combat_ended flag) ResolveResult with messages for both participants
""" """
if self.current_move is None: if self.current_move is None:
return ("No active move to resolve.", False) return ResolveResult(
attacker_msg="No active move to resolve.",
defender_msg="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 # Check if defense counters attack
defense_succeeds = ( defense_succeeds = (
@ -96,22 +117,32 @@ class CombatEncounter:
) )
if defense_succeeds: if defense_succeeds:
# Successful counter - no damage # Successful counter - no damage
result = f"{self.defender.name} countered the attack!" damage = 0.0
attacker_msg = f"{defender_name} countered your {move_name}!"
defender_msg = f"You countered {attacker_name}'s {move_name}!"
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
result = ( attacker_msg = (
f"{self.attacker.name} hit {self.defender.name} " f"Your {move_name} hits {defender_name} for {damage:.1f} damage!"
f"for {damage:.1f} damage!"
) )
defender_msg = (
f"{attacker_name}'s {move_name} hits you for {damage:.1f} damage!"
)
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
result = ( attacker_msg = (
f"{self.defender.name} took the hit full force for {damage:.1f} damage!" 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!"
)
countered = False
# Check for combat end conditions # Check for combat end conditions
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0 combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
@ -121,4 +152,10 @@ class CombatEncounter:
self.current_move = None self.current_move = None
self.pending_defense = None self.pending_defense = None
return (result, combat_ended) return ResolveResult(
attacker_msg=attacker_msg,
defender_msg=defender_msg,
damage=damage,
countered=countered,
combat_ended=combat_ended,
)

View file

@ -63,7 +63,7 @@ def end_encounter(encounter: CombatEncounter) -> None:
active_encounters.remove(encounter) active_encounters.remove(encounter)
def process_combat() -> None: async def process_combat() -> None:
"""Process all active combat encounters. """Process all active combat encounters.
This should be called each game loop tick to advance combat state machines. This should be called each game loop tick to advance combat state machines.
@ -76,9 +76,13 @@ def process_combat() -> None:
# Auto-resolve if in RESOLVE state # Auto-resolve if in RESOLVE state
if encounter.state == CombatState.RESOLVE: if encounter.state == CombatState.RESOLVE:
_result, combat_ended = encounter.resolve() result = encounter.resolve()
if combat_ended: # Send resolution messages to both participants
await encounter.attacker.send(result.attacker_msg + "\r\n")
await encounter.defender.send(result.defender_msg + "\r\n")
if result.combat_ended:
# Pop combat mode from both entities if they're Players # Pop combat mode from both entities if they're Players
from mudlib.player import Player from mudlib.player import Player

View file

@ -61,7 +61,7 @@ async def game_loop() -> None:
while True: while True:
t0 = asyncio.get_event_loop().time() t0 = asyncio.get_event_loop().time()
clear_expired() clear_expired()
process_combat() await process_combat()
# Periodic auto-save (every 60 seconds) # Periodic auto-save (every 60 seconds)
current_time = time.monotonic() current_time = time.monotonic()

View file

@ -4,7 +4,7 @@ import time
import pytest import pytest
from mudlib.combat.encounter import CombatEncounter, CombatState from mudlib.combat.encounter import CombatEncounter, CombatState, ResolveResult
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity from mudlib.entity import Entity
@ -136,12 +136,16 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge):
encounter.defend(dodge) encounter.defend(dodge)
initial_pl = defender.pl initial_pl = defender.pl
result, combat_ended = encounter.resolve() result = encounter.resolve()
assert isinstance(result, ResolveResult)
assert defender.pl == initial_pl assert defender.pl == initial_pl
assert "countered" in result.lower() 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 encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
assert combat_ended is False assert result.combat_ended is False
def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge): def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge):
@ -151,13 +155,16 @@ def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge):
encounter.defend(wrong_dodge) encounter.defend(wrong_dodge)
initial_pl = defender.pl initial_pl = defender.pl
result, combat_ended = encounter.resolve() result = encounter.resolve()
expected_damage = attacker.pl * punch.damage_pct expected_damage = attacker.pl * punch.damage_pct
assert defender.pl == initial_pl - expected_damage assert defender.pl == initial_pl - expected_damage
assert "hit" in result.lower() 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 encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
assert combat_ended is False assert result.combat_ended is False
def test_resolve_no_defense(attacker, defender, punch): def test_resolve_no_defense(attacker, defender, punch):
@ -166,14 +173,17 @@ def test_resolve_no_defense(attacker, defender, punch):
encounter.attack(punch) encounter.attack(punch)
initial_pl = defender.pl initial_pl = defender.pl
result, combat_ended = encounter.resolve() result = encounter.resolve()
# No defense = 1.5x damage # No defense = 1.5x damage
expected_damage = attacker.pl * punch.damage_pct * 1.5 expected_damage = attacker.pl * punch.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage assert defender.pl == initial_pl - expected_damage
assert "full force" in result.lower() assert result.damage == expected_damage
assert result.countered is False
assert "slams" in result.attacker_msg.lower()
assert "slams" in result.defender_msg.lower()
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
assert combat_ended is False assert result.combat_ended is False
def test_resolve_clears_pending_defense(attacker, defender, punch, dodge): def test_resolve_clears_pending_defense(attacker, defender, punch, dodge):
@ -182,7 +192,7 @@ def test_resolve_clears_pending_defense(attacker, defender, punch, dodge):
encounter.attack(punch) encounter.attack(punch)
encounter.defend(dodge) encounter.defend(dodge)
_result, _combat_ended = encounter.resolve() encounter.resolve()
assert encounter.pending_defense is None assert encounter.pending_defense is None
assert encounter.current_move is None assert encounter.current_move is None
@ -207,7 +217,7 @@ def test_full_state_machine_cycle(attacker, defender, punch):
assert encounter.state == CombatState.RESOLVE assert encounter.state == CombatState.RESOLVE
# RESOLVE → IDLE # RESOLVE → IDLE
_result, _combat_ended = encounter.resolve() encounter.resolve()
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
@ -227,11 +237,11 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
defender.pl = 10.0 defender.pl = 10.0
encounter.attack(punch) encounter.attack(punch)
result, combat_ended = encounter.resolve() result = encounter.resolve()
assert defender.pl <= 0 assert defender.pl <= 0
assert combat_ended is True assert result.combat_ended is True
assert "damage" in result.lower() assert result.damage > 0
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
@ -242,10 +252,10 @@ def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
attacker.stamina = punch.stamina_cost attacker.stamina = punch.stamina_cost
encounter.attack(punch) encounter.attack(punch)
result, combat_ended = encounter.resolve() result = encounter.resolve()
assert attacker.stamina <= 0 assert attacker.stamina <= 0
assert combat_ended is True assert result.combat_ended is True
def test_resolve_returns_combat_continues_normally(attacker, defender, punch): def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
@ -253,8 +263,37 @@ def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
result, combat_ended = encounter.resolve() result = encounter.resolve()
assert attacker.stamina > 0 assert attacker.stamina > 0
assert defender.pl > 0 assert defender.pl > 0
assert combat_ended is False assert result.combat_ended is False
def test_resolve_attacker_msg_contains_move_name(attacker, defender, punch):
"""Test attacker message includes the move name."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert "punch right" in result.attacker_msg.lower()
def test_resolve_defender_msg_contains_attacker_name(attacker, defender, punch):
"""Test defender message includes attacker's name."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
assert attacker.name.lower() in result.defender_msg.lower()
def test_resolve_counter_messages_contain_move_name(attacker, defender, punch, dodge):
"""Test counter messages include the move name for both players."""
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()

View file

@ -1,6 +1,7 @@
"""Tests for combat engine and encounter management.""" """Tests for combat engine and encounter management."""
import time import time
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -14,6 +15,14 @@ from mudlib.combat.engine import (
) )
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity from mudlib.entity import Entity
from mudlib.player import Player
def _mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -88,19 +97,21 @@ def test_end_encounter_removes_from_active_list(attacker, defender):
assert get_encounter(defender) is None assert get_encounter(defender) is None
def test_process_combat_advances_encounters(attacker, defender, punch): @pytest.mark.asyncio
async def test_process_combat_advances_encounters(attacker, defender, punch):
"""Test process_combat advances all active encounters.""" """Test process_combat advances all active encounters."""
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
# Process combat should advance state from TELEGRAPH to WINDOW # Process combat should advance state from TELEGRAPH to WINDOW
time.sleep(0.31) time.sleep(0.31)
process_combat() await process_combat()
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.WINDOW
def test_process_combat_handles_multiple_encounters(punch): @pytest.mark.asyncio
async def test_process_combat_handles_multiple_encounters(punch):
"""Test process_combat handles multiple simultaneous encounters.""" """Test process_combat handles multiple simultaneous encounters."""
e1_attacker = Entity(name="A1", x=0, y=0) e1_attacker = Entity(name="A1", x=0, y=0)
e1_defender = Entity(name="D1", x=0, y=0) e1_defender = Entity(name="D1", x=0, y=0)
@ -114,13 +125,14 @@ def test_process_combat_handles_multiple_encounters(punch):
enc2.attack(punch) enc2.attack(punch)
time.sleep(0.31) time.sleep(0.31)
process_combat() await process_combat()
assert enc1.state == CombatState.WINDOW assert enc1.state == CombatState.WINDOW
assert enc2.state == CombatState.WINDOW assert enc2.state == CombatState.WINDOW
def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch): @pytest.mark.asyncio
async def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch):
"""Test process_combat auto-resolves when window expires.""" """Test process_combat auto-resolves when window expires."""
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
@ -128,11 +140,11 @@ def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch)
# Skip past telegraph and window # Skip past telegraph and window
time.sleep(0.31) # Telegraph time.sleep(0.31) # Telegraph
process_combat() await process_combat()
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.WINDOW
time.sleep(0.85) # Window time.sleep(0.85) # Window
process_combat() await process_combat()
# Should auto-resolve and return to IDLE # Should auto-resolve and return to IDLE
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
# Damage should have been applied (no defense = 1.5x damage) # Damage should have been applied (no defense = 1.5x damage)
@ -162,21 +174,23 @@ def test_active_encounters_list():
assert isinstance(active_encounters, list) assert isinstance(active_encounters, list)
def test_process_combat_with_no_encounters(): @pytest.mark.asyncio
async def test_process_combat_with_no_encounters():
"""Test process_combat handles empty encounter list.""" """Test process_combat handles empty encounter list."""
process_combat() # Should not raise await process_combat() # Should not raise
def test_encounter_cleanup_after_resolution(attacker, defender, punch): @pytest.mark.asyncio
async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
"""Test encounter can be ended after resolution.""" """Test encounter can be ended after resolution."""
encounter = start_encounter(attacker, defender) encounter = start_encounter(attacker, defender)
encounter.attack(punch) encounter.attack(punch)
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
process_combat() await process_combat()
time.sleep(0.85) time.sleep(0.85)
process_combat() await process_combat()
# Resolve # Resolve
encounter.resolve() encounter.resolve()
@ -186,12 +200,12 @@ def test_encounter_cleanup_after_resolution(attacker, defender, punch):
assert get_encounter(attacker) is None assert get_encounter(attacker) is None
def test_process_combat_ends_encounter_on_knockout(punch): @pytest.mark.asyncio
async def test_process_combat_ends_encounter_on_knockout(punch):
"""Test process_combat ends encounter when defender is knocked out.""" """Test process_combat ends encounter when defender is knocked out."""
from mudlib.player import Player w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0)
# Push combat mode onto both stacks # Push combat mode onto both stacks
attacker.mode_stack.append("combat") attacker.mode_stack.append("combat")
@ -202,9 +216,9 @@ def test_process_combat_ends_encounter_on_knockout(punch):
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
process_combat() await process_combat()
time.sleep(0.85) time.sleep(0.85)
process_combat() await process_combat()
# Combat should have ended and been cleaned up # Combat should have ended and been cleaned up
assert get_encounter(attacker) is None assert get_encounter(attacker) is None
@ -214,12 +228,26 @@ def test_process_combat_ends_encounter_on_knockout(punch):
assert defender.mode_stack == ["normal"] assert defender.mode_stack == ["normal"]
def test_process_combat_ends_encounter_on_exhaustion(punch): @pytest.mark.asyncio
async def test_process_combat_ends_encounter_on_exhaustion(punch):
"""Test process_combat ends encounter when attacker is exhausted.""" """Test process_combat ends encounter when attacker is exhausted."""
from mudlib.player import Player w = _mock_writer
attacker = Player(
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=punch.stamina_cost) name="Goku",
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0) x=0,
y=0,
pl=100.0,
stamina=punch.stamina_cost,
writer=w(),
)
defender = Player(
name="Vegeta",
x=0,
y=0,
pl=100.0,
stamina=50.0,
writer=w(),
)
# Push combat mode onto both stacks # Push combat mode onto both stacks
attacker.mode_stack.append("combat") attacker.mode_stack.append("combat")
@ -230,9 +258,9 @@ def test_process_combat_ends_encounter_on_exhaustion(punch):
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
process_combat() await process_combat()
time.sleep(0.85) time.sleep(0.85)
process_combat() await process_combat()
# Combat should have ended # Combat should have ended
assert get_encounter(attacker) is None assert get_encounter(attacker) is None
@ -241,12 +269,12 @@ def test_process_combat_ends_encounter_on_exhaustion(punch):
assert defender.mode_stack == ["normal"] assert defender.mode_stack == ["normal"]
def test_process_combat_continues_with_resources(punch): @pytest.mark.asyncio
async def test_process_combat_continues_with_resources(punch):
"""Test process_combat continues encounter when both have resources.""" """Test process_combat continues encounter when both have resources."""
from mudlib.player import Player w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0) defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0)
# Push combat mode onto both stacks # Push combat mode onto both stacks
attacker.mode_stack.append("combat") attacker.mode_stack.append("combat")
@ -257,12 +285,46 @@ def test_process_combat_continues_with_resources(punch):
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
process_combat() await process_combat()
time.sleep(0.85) time.sleep(0.85)
process_combat() await process_combat()
# Combat should still be active (but in IDLE state) # Combat should still be active (but in IDLE state)
assert get_encounter(attacker) is encounter assert get_encounter(attacker) is encounter
assert get_encounter(defender) is encounter assert get_encounter(defender) is encounter
assert attacker.mode_stack == ["normal", "combat"] assert attacker.mode_stack == ["normal", "combat"]
assert defender.mode_stack == ["normal", "combat"] assert defender.mode_stack == ["normal", "combat"]
@pytest.mark.asyncio
async def test_process_combat_sends_messages_on_resolve(punch):
"""Test process_combat sends resolution messages to both players."""
mock_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=mock_writer)
defender_writer = _mock_writer()
defender = Player(
name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=defender_writer
)
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.attack(punch)
# Advance past telegraph and window
time.sleep(0.31)
await process_combat()
time.sleep(0.85)
mock_writer.write.reset_mock()
defender_writer.write.reset_mock()
await process_combat()
# Both players should have received messages
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)