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:
parent
588227bcd0
commit
9054962f5d
5 changed files with 209 additions and 67 deletions
|
|
@ -21,6 +21,17 @@ class CombatState(Enum):
|
|||
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
|
||||
class CombatEncounter:
|
||||
"""Represents an active combat encounter between two entities."""
|
||||
|
|
@ -80,14 +91,24 @@ class CombatEncounter:
|
|||
if elapsed >= total_time:
|
||||
self.state = CombatState.RESOLVE
|
||||
|
||||
def resolve(self) -> tuple[str, bool]:
|
||||
"""Resolve the combat exchange and return result message.
|
||||
def resolve(self) -> ResolveResult:
|
||||
"""Resolve the combat exchange and return result.
|
||||
|
||||
Returns:
|
||||
Tuple of (result message, combat_ended flag)
|
||||
ResolveResult with messages for both participants
|
||||
"""
|
||||
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
|
||||
defense_succeeds = (
|
||||
|
|
@ -96,22 +117,32 @@ class CombatEncounter:
|
|||
)
|
||||
if defense_succeeds:
|
||||
# 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:
|
||||
# Wrong defense - normal damage
|
||||
damage = self.attacker.pl * self.current_move.damage_pct
|
||||
self.defender.pl -= damage
|
||||
result = (
|
||||
f"{self.attacker.name} hit {self.defender.name} "
|
||||
f"for {damage:.1f} 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!"
|
||||
)
|
||||
countered = False
|
||||
else:
|
||||
# No defense - increased damage
|
||||
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
|
||||
self.defender.pl -= damage
|
||||
result = (
|
||||
f"{self.defender.name} took the hit full force for {damage:.1f} 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!"
|
||||
)
|
||||
countered = False
|
||||
|
||||
# Check for combat end conditions
|
||||
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
|
||||
|
|
@ -121,4 +152,10 @@ class CombatEncounter:
|
|||
self.current_move = 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def end_encounter(encounter: CombatEncounter) -> None:
|
|||
active_encounters.remove(encounter)
|
||||
|
||||
|
||||
def process_combat() -> None:
|
||||
async def process_combat() -> None:
|
||||
"""Process all active combat encounters.
|
||||
|
||||
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
|
||||
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
|
||||
from mudlib.player import Player
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ async def game_loop() -> None:
|
|||
while True:
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
clear_expired()
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
# Periodic auto-save (every 60 seconds)
|
||||
current_time = time.monotonic()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import time
|
|||
|
||||
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.entity import Entity
|
||||
|
||||
|
|
@ -136,12 +136,16 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge):
|
|||
encounter.defend(dodge)
|
||||
|
||||
initial_pl = defender.pl
|
||||
result, combat_ended = encounter.resolve()
|
||||
result = encounter.resolve()
|
||||
|
||||
assert isinstance(result, ResolveResult)
|
||||
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 combat_ended is False
|
||||
assert result.combat_ended is False
|
||||
|
||||
|
||||
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)
|
||||
|
||||
initial_pl = defender.pl
|
||||
result, combat_ended = encounter.resolve()
|
||||
result = encounter.resolve()
|
||||
|
||||
expected_damage = attacker.pl * punch.damage_pct
|
||||
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 combat_ended is False
|
||||
assert result.combat_ended is False
|
||||
|
||||
|
||||
def test_resolve_no_defense(attacker, defender, punch):
|
||||
|
|
@ -166,14 +173,17 @@ def test_resolve_no_defense(attacker, defender, punch):
|
|||
encounter.attack(punch)
|
||||
|
||||
initial_pl = defender.pl
|
||||
result, combat_ended = encounter.resolve()
|
||||
result = encounter.resolve()
|
||||
|
||||
# No defense = 1.5x damage
|
||||
expected_damage = attacker.pl * punch.damage_pct * 1.5
|
||||
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 combat_ended is False
|
||||
assert result.combat_ended is False
|
||||
|
||||
|
||||
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.defend(dodge)
|
||||
|
||||
_result, _combat_ended = encounter.resolve()
|
||||
encounter.resolve()
|
||||
|
||||
assert encounter.pending_defense 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
|
||||
|
||||
# RESOLVE → IDLE
|
||||
_result, _combat_ended = encounter.resolve()
|
||||
encounter.resolve()
|
||||
assert encounter.state == CombatState.IDLE
|
||||
|
||||
|
||||
|
|
@ -227,11 +237,11 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
|
|||
defender.pl = 10.0
|
||||
|
||||
encounter.attack(punch)
|
||||
result, combat_ended = encounter.resolve()
|
||||
result = encounter.resolve()
|
||||
|
||||
assert defender.pl <= 0
|
||||
assert combat_ended is True
|
||||
assert "damage" in result.lower()
|
||||
assert result.combat_ended is True
|
||||
assert result.damage > 0
|
||||
|
||||
|
||||
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
|
||||
|
||||
encounter.attack(punch)
|
||||
result, combat_ended = encounter.resolve()
|
||||
result = encounter.resolve()
|
||||
|
||||
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):
|
||||
|
|
@ -253,8 +263,37 @@ def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
|
|||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||
encounter.attack(punch)
|
||||
|
||||
result, combat_ended = encounter.resolve()
|
||||
result = encounter.resolve()
|
||||
|
||||
assert attacker.stamina > 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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for combat engine and encounter management."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -14,6 +15,14 @@ from mudlib.combat.engine import (
|
|||
)
|
||||
from mudlib.combat.moves import CombatMove
|
||||
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)
|
||||
|
|
@ -88,19 +97,21 @@ def test_end_encounter_removes_from_active_list(attacker, defender):
|
|||
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."""
|
||||
encounter = start_encounter(attacker, defender)
|
||||
encounter.attack(punch)
|
||||
|
||||
# Process combat should advance state from TELEGRAPH to WINDOW
|
||||
time.sleep(0.31)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
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."""
|
||||
e1_attacker = Entity(name="A1", 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)
|
||||
|
||||
time.sleep(0.31)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
assert enc1.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."""
|
||||
encounter = start_encounter(attacker, defender)
|
||||
encounter.attack(punch)
|
||||
|
|
@ -128,11 +140,11 @@ def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch)
|
|||
|
||||
# Skip past telegraph and window
|
||||
time.sleep(0.31) # Telegraph
|
||||
process_combat()
|
||||
await process_combat()
|
||||
assert encounter.state == CombatState.WINDOW
|
||||
|
||||
time.sleep(0.85) # Window
|
||||
process_combat()
|
||||
await process_combat()
|
||||
# Should auto-resolve and return to IDLE
|
||||
assert encounter.state == CombatState.IDLE
|
||||
# Damage should have been applied (no defense = 1.5x damage)
|
||||
|
|
@ -162,21 +174,23 @@ def test_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."""
|
||||
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."""
|
||||
encounter = start_encounter(attacker, defender)
|
||||
encounter.attack(punch)
|
||||
|
||||
# Advance to resolution
|
||||
time.sleep(0.31)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
time.sleep(0.85)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
# Resolve
|
||||
encounter.resolve()
|
||||
|
|
@ -186,12 +200,12 @@ def test_encounter_cleanup_after_resolution(attacker, defender, punch):
|
|||
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."""
|
||||
from mudlib.player import Player
|
||||
|
||||
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)
|
||||
w = _mock_writer
|
||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=w())
|
||||
defender = Player(name="Vegeta", x=0, y=0, pl=10.0, stamina=50.0, writer=w())
|
||||
|
||||
# Push combat mode onto both stacks
|
||||
attacker.mode_stack.append("combat")
|
||||
|
|
@ -202,9 +216,9 @@ def test_process_combat_ends_encounter_on_knockout(punch):
|
|||
|
||||
# Advance to resolution
|
||||
time.sleep(0.31)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
time.sleep(0.85)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
# Combat should have ended and been cleaned up
|
||||
assert get_encounter(attacker) is None
|
||||
|
|
@ -214,12 +228,26 @@ def test_process_combat_ends_encounter_on_knockout(punch):
|
|||
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."""
|
||||
from mudlib.player import Player
|
||||
|
||||
attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=punch.stamina_cost)
|
||||
defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0)
|
||||
w = _mock_writer
|
||||
attacker = Player(
|
||||
name="Goku",
|
||||
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
|
||||
attacker.mode_stack.append("combat")
|
||||
|
|
@ -230,9 +258,9 @@ def test_process_combat_ends_encounter_on_exhaustion(punch):
|
|||
|
||||
# Advance to resolution
|
||||
time.sleep(0.31)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
time.sleep(0.85)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
# Combat should have ended
|
||||
assert get_encounter(attacker) is None
|
||||
|
|
@ -241,12 +269,12 @@ def test_process_combat_ends_encounter_on_exhaustion(punch):
|
|||
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."""
|
||||
from mudlib.player import Player
|
||||
|
||||
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)
|
||||
w = _mock_writer
|
||||
attacker = Player(name="Goku", 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, writer=w())
|
||||
|
||||
# Push combat mode onto both stacks
|
||||
attacker.mode_stack.append("combat")
|
||||
|
|
@ -257,12 +285,46 @@ def test_process_combat_continues_with_resources(punch):
|
|||
|
||||
# Advance to resolution
|
||||
time.sleep(0.31)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
time.sleep(0.85)
|
||||
process_combat()
|
||||
await process_combat()
|
||||
|
||||
# Combat should still be active (but in IDLE state)
|
||||
assert get_encounter(attacker) is encounter
|
||||
assert get_encounter(defender) is encounter
|
||||
assert attacker.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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue