Compare commits

..

No commits in common. "e9378bb6fa5929b750788a16894a1c2157bf5b53" and "588227bcd004e15cd4403721054963b0df540a3b" have entirely different histories.

13 changed files with 117 additions and 625 deletions

View file

@ -1,7 +1,7 @@
name = "parry" name = "parry"
move_type = "defense" move_type = "defense"
stamina_cost = 4.0 stamina_cost = 4.0
timing_window_ms = 1200 timing_window_ms = 500
[variants.high] [variants.high]
aliases = ["f"] aliases = ["f"]

View file

@ -1,7 +1,7 @@
name = "punch" name = "punch"
move_type = "attack" move_type = "attack"
stamina_cost = 5.0 stamina_cost = 5.0
timing_window_ms = 1800 timing_window_ms = 800
damage_pct = 0.15 damage_pct = 0.15
[variants.left] [variants.left]

View file

@ -3,6 +3,6 @@ aliases = ["rh"]
move_type = "attack" move_type = "attack"
stamina_cost = 8.0 stamina_cost = 8.0
telegraph = "{attacker} spins into a roundhouse kick!" telegraph = "{attacker} spins into a roundhouse kick!"
timing_window_ms = 2000 timing_window_ms = 600
damage_pct = 0.25 damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"] countered_by = ["duck", "parry high", "parry low"]

View file

@ -3,6 +3,6 @@ aliases = ["sw"]
move_type = "attack" move_type = "attack"
stamina_cost = 6.0 stamina_cost = 6.0
telegraph = "{attacker} drops low for a leg sweep!" telegraph = "{attacker} drops low for a leg sweep!"
timing_window_ms = 1800 timing_window_ms = 700
damage_pct = 0.18 damage_pct = 0.18
countered_by = ["jump", "parry low"] countered_by = ["jump", "parry low"]

View file

@ -1,11 +1,8 @@
"""Combat command handlers.""" """Combat command handlers."""
import asyncio
import time
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter 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
@ -66,60 +63,33 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
telegraph = move.telegraph.format(attacker=player.name) telegraph = move.telegraph.format(attacker=player.name)
await defender.send(f"{telegraph}\r\n") await defender.send(f"{telegraph}\r\n")
# Detect switch before attack() modifies state
switching = encounter.state in (
CombatState.TELEGRAPH,
CombatState.WINDOW,
)
# Execute the attack # Execute the attack
encounter.attack(move) encounter.attack(move)
if switching: await player.send(f"You use {move.name}!\r\n")
await player.send(f"You switch to {move.name}!\r\n")
else:
await player.send(f"You use {move.name}!\r\n")
async def do_defend(player: Player, _args: str, move: CombatMove) -> None: async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
"""Core defense logic with a resolved move. """Core defense logic with a resolved move.
Works both in and outside combat. Applies a recovery lock
(based on timing_window_ms) so defenses have commitment.
Args: Args:
player: The defending player player: The defending player
_args: Unused (defense moves don't take a target) _args: Unused (defense moves don't take a target)
move: The resolved combat move move: The resolved combat move
""" """
# Check if in combat
encounter = get_encounter(player)
if encounter is None:
await player.send("You're not in combat.\r\n")
return
# Check stamina # Check stamina
if player.stamina < move.stamina_cost: if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n") await player.send("You don't have enough stamina for that move.\r\n")
return return
player.stamina -= move.stamina_cost # Queue the defense
encounter.defend(move)
# If in combat, queue the defense on the encounter await player.send(f"You attempt to {move.name}!\r\n")
encounter = get_encounter(player)
if encounter is not None:
encounter.defend(move)
# Broadcast to nearby players
from mudlib.commands.movement import send_nearby_message
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} {move.command}s!\r\n",
)
# Commitment: block for the timing window (inputs queue naturally)
await asyncio.sleep(move.timing_window_ms / 1000.0)
if encounter is not None:
await player.send(f"You {move.name}!\r\n")
else:
await player.send(f"You {move.command} the air!\r\n")
def _make_direct_handler(move: CombatMove, handler_fn): def _make_direct_handler(move: CombatMove, handler_fn):
@ -194,7 +164,7 @@ def register_combat_commands(content_dir: Path) -> None:
# Register simple moves (roundhouse, sweep, duck, jump) # Register simple moves (roundhouse, sweep, duck, jump)
for move in simple_moves: for move in simple_moves:
handler_fn = do_attack if move.move_type == "attack" else do_defend handler_fn = do_attack if move.move_type == "attack" else do_defend
mode = "*" mode = "*" if move.move_type == "attack" else "combat"
action = "Attack" if move.move_type == "attack" else "Defend" action = "Attack" if move.move_type == "attack" else "Defend"
register( register(
CommandDefinition( CommandDefinition(
@ -211,7 +181,7 @@ def register_combat_commands(content_dir: Path) -> None:
# Determine type from first variant # Determine type from first variant
first_variant = next(iter(variants.values())) first_variant = next(iter(variants.values()))
handler_fn = do_attack if first_variant.move_type == "attack" else do_defend handler_fn = do_attack if first_variant.move_type == "attack" else do_defend
mode = "*" mode = "*" if first_variant.move_type == "attack" else "combat"
# Collect all variant aliases for the base command # Collect all variant aliases for the base command
all_aliases = [] all_aliases = []

View file

@ -20,20 +20,6 @@ class CombatState(Enum):
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick) # Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
TELEGRAPH_DURATION = 0.3 TELEGRAPH_DURATION = 0.3
# Seconds of no action before combat fizzles out
IDLE_TIMEOUT = 30.0
@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:
@ -45,46 +31,30 @@ class CombatEncounter:
current_move: CombatMove | None = None current_move: CombatMove | None = None
move_started_at: float = 0.0 move_started_at: float = 0.0
pending_defense: CombatMove | None = None pending_defense: CombatMove | None = None
last_action_at: float = 0.0
def attack(self, move: CombatMove) -> None: def attack(self, move: CombatMove) -> None:
"""Initiate or switch an attack move. """Initiate an attack move.
If called during TELEGRAPH or WINDOW, switches to the new move
without resetting the timer. Refunds old move's stamina cost.
Args: Args:
move: The attack move to execute move: The attack move to execute
""" """
now = time.monotonic()
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
# Switching — refund old cost, keep timer
if self.current_move:
self.attacker.stamina = min(
self.attacker.stamina + self.current_move.stamina_cost,
self.attacker.max_stamina,
)
else:
# First attack — start timer
self.move_started_at = now
self.current_move = move self.current_move = move
self.state = CombatState.TELEGRAPH
self.move_started_at = time.monotonic()
# Apply stamina cost
self.attacker.stamina -= move.stamina_cost self.attacker.stamina -= move.stamina_cost
self.last_action_at = now
if self.state == CombatState.IDLE:
self.state = CombatState.TELEGRAPH
def defend(self, move: CombatMove) -> None: def defend(self, move: CombatMove) -> None:
"""Queue a defense move on the encounter. """Queue a defense move.
Stamina cost and lock are handled by the command layer (do_defend).
Args: Args:
move: The defense move to attempt move: The defense move to attempt
""" """
self.pending_defense = move self.pending_defense = move
self.last_action_at = time.monotonic()
# Apply stamina cost
self.defender.stamina -= move.stamina_cost
def tick(self, now: float) -> None: def tick(self, now: float) -> None:
"""Advance the state machine based on current time. """Advance the state machine based on current time.
@ -110,24 +80,14 @@ class CombatEncounter:
if elapsed >= total_time: if elapsed >= total_time:
self.state = CombatState.RESOLVE self.state = CombatState.RESOLVE
def resolve(self) -> ResolveResult: def resolve(self) -> tuple[str, bool]:
"""Resolve the combat exchange and return result. """Resolve the combat exchange and return result message.
Returns: Returns:
ResolveResult with messages for both participants Tuple of (result message, combat_ended flag)
""" """
if self.current_move is None: if self.current_move is None:
return ResolveResult( return ("No active move to resolve.", False)
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 = (
@ -136,32 +96,22 @@ class CombatEncounter:
) )
if defense_succeeds: if defense_succeeds:
# Successful counter - no damage # Successful counter - no damage
damage = 0.0 result = f"{self.defender.name} countered the attack!"
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
attacker_msg = ( result = (
f"Your {move_name} hits {defender_name} for {damage:.1f} damage!" f"{self.attacker.name} hit {self.defender.name} "
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
attacker_msg = ( result = (
f"Your {move_name} slams {defender_name} for {damage:.1f} damage!" f"{self.defender.name} took the hit full force 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
@ -171,10 +121,4 @@ class CombatEncounter:
self.current_move = None self.current_move = None
self.pending_defense = None self.pending_defense = None
return ResolveResult( return (result, combat_ended)
attacker_msg=attacker_msg,
defender_msg=defender_msg,
damage=damage,
countered=countered,
combat_ended=combat_ended,
)

View file

@ -2,7 +2,7 @@
import time import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.combat.encounter import CombatEncounter, CombatState
from mudlib.entity import Entity from mudlib.entity import Entity
# Global list of active combat encounters # Global list of active combat encounters
@ -32,11 +32,7 @@ def start_encounter(attacker: Entity, defender: Entity) -> CombatEncounter:
raise ValueError(msg) raise ValueError(msg)
# Create and register the encounter # Create and register the encounter
encounter = CombatEncounter( encounter = CombatEncounter(attacker=attacker, defender=defender)
attacker=attacker,
defender=defender,
last_action_at=time.monotonic(),
)
active_encounters.append(encounter) active_encounters.append(encounter)
return encounter return encounter
@ -67,7 +63,7 @@ def end_encounter(encounter: CombatEncounter) -> None:
active_encounters.remove(encounter) active_encounters.remove(encounter)
async def process_combat() -> None: 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.
@ -75,32 +71,14 @@ async def process_combat() -> None:
now = time.monotonic() now = time.monotonic()
for encounter in active_encounters[:]: # Copy list to allow modification for encounter in active_encounters[:]: # Copy list to allow modification
# Check for idle timeout
if now - encounter.last_action_at > IDLE_TIMEOUT:
await encounter.attacker.send("Combat has fizzled out.\r\n")
await encounter.defender.send("Combat has fizzled out.\r\n")
from mudlib.player import Player
for entity in (encounter.attacker, encounter.defender):
if isinstance(entity, Player) and entity.mode == "combat":
entity.mode_stack.pop()
end_encounter(encounter)
continue
# Tick the state machine # Tick the state machine
encounter.tick(now) encounter.tick(now)
# Auto-resolve if in RESOLVE state # Auto-resolve if in RESOLVE state
if encounter.state == CombatState.RESOLVE: if encounter.state == CombatState.RESOLVE:
result = encounter.resolve() _result, combat_ended = encounter.resolve()
# Send resolution messages to both participants if combat_ended:
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

@ -14,7 +14,6 @@ class Entity:
pl: float = 100.0 # power level (health and damage multiplier) pl: float = 100.0 # power level (health and damage multiplier)
stamina: float = 100.0 # current stamina stamina: float = 100.0 # current stamina
max_stamina: float = 100.0 # stamina ceiling max_stamina: float = 100.0 # stamina ceiling
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
async def send(self, message: str) -> None: async def send(self, message: str) -> None:
"""Send a message to this entity. Base implementation is a no-op.""" """Send a message to this entity. Base implementation is a no-op."""

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()
await process_combat() process_combat()
# Periodic auto-save (every 60 seconds) # Periodic auto-save (every 60 seconds)
current_time = time.monotonic() current_time = time.monotonic()

View file

@ -1,12 +1,10 @@
"""Tests for combat commands.""" """Tests for combat commands."""
import time
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters, get_encounter from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves from mudlib.combat.moves import load_moves
@ -23,18 +21,6 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture @pytest.fixture
def mock_writer(): def mock_writer():
writer = MagicMock() writer = MagicMock()
@ -168,24 +154,25 @@ async def test_attack_sends_telegraph_to_defender(player, target, punch_right):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_works_outside_combat(player, dodge_left): async def test_defense_only_in_combat(player, dodge_left):
"""Test do_defend works outside combat (costs stamina, 'the air' message).""" """Test do_defend only works in combat mode."""
initial_stamina = player.stamina
await combat_commands.do_defend(player, "", dodge_left) await combat_commands.do_defend(player, "", dodge_left)
assert player.stamina == initial_stamina - dodge_left.stamina_cost player.writer.write.assert_called()
messages = [call[0][0] for call in player.writer.write.call_args_list] message = player.writer.write.call_args[0][0]
assert any("dodge the air" in msg.lower() for msg in messages) assert "not in combat" in message.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_records_pending_defense(player, target, punch_right, dodge_left): async def test_defense_records_pending_defense(player, target, punch_right, dodge_left):
"""Test do_defend queues defense on encounter and costs stamina.""" """Test do_defend records the defense move."""
# Start combat # Start combat
await combat_commands.do_attack(player, "Vegeta", punch_right) await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.reset_mock() player.writer.write.reset_mock()
initial_stamina = target.stamina # Switch to defender's perspective
target.writer = player.writer
target.mode_stack = ["combat"]
# Defend # Defend
await combat_commands.do_defend(target, "", dodge_left) await combat_commands.do_defend(target, "", dodge_left)
@ -194,19 +181,23 @@ async def test_defense_records_pending_defense(player, target, punch_right, dodg
assert encounter is not None assert encounter is not None
assert encounter.pending_defense is not None assert encounter.pending_defense is not None
assert encounter.pending_defense.name == "dodge left" assert encounter.pending_defense.name == "dodge left"
assert target.stamina == initial_stamina - dodge_left.stamina_cost
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_defense_insufficient_stamina(player, dodge_left): async def test_defense_insufficient_stamina(player, target, punch_right, dodge_left):
"""Test do_defend with insufficient stamina gives error.""" """Test do_defend with insufficient stamina gives error."""
player.stamina = 1.0 # Not enough for dodge (costs 3) # Start combat
await combat_commands.do_attack(player, "Vegeta", punch_right)
target.writer = player.writer
target.mode_stack = ["combat"]
target.stamina = 1.0 # Not enough for dodge (costs 3)
await combat_commands.do_defend(player, "", dodge_left) player.writer.write.reset_mock()
await combat_commands.do_defend(target, "", dodge_left)
player.writer.write.assert_called() target.writer.write.assert_called()
messages = [call[0][0] for call in player.writer.write.call_args_list] message = target.writer.write.call_args[0][0]
assert any("stamina" in msg.lower() for msg in messages) assert "stamina" in message.lower()
# --- variant handler tests --- # --- variant handler tests ---
@ -297,78 +288,3 @@ async def test_direct_handler_alias_for_variant(player, target, punch_right):
assert encounter.current_move.name == "punch right" assert encounter.current_move.name == "punch right"
assert encounter.attacker is player assert encounter.attacker is player
assert encounter.defender is target assert encounter.defender is target
# --- attack switching tests ---
@pytest.mark.asyncio
async def test_switch_attack_shows_switch_message(
player, target, punch_right, punch_left
):
"""Test switching attack says 'switch to' instead of 'use'."""
await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.reset_mock()
await combat_commands.do_attack(player, "", punch_left)
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("switch to" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_switch_attack_sends_new_telegraph(
player, target, punch_right, punch_left
):
"""Test switching attack sends new telegraph to defender."""
await combat_commands.do_attack(player, "Vegeta", punch_right)
target.writer.write.reset_mock()
await combat_commands.do_attack(player, "", punch_left)
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)
# --- defense commitment tests ---
@pytest.mark.asyncio
async def test_defense_blocks_for_timing_window(player, dodge_left):
"""Test defense sleeps for timing_window_ms (commitment via blocking)."""
before = time.monotonic()
await combat_commands.do_defend(player, "", dodge_left)
elapsed = time.monotonic() - before
expected = dodge_left.timing_window_ms / 1000.0
assert elapsed >= expected - 0.05
@pytest.mark.asyncio
async def test_defense_in_combat_queues_on_encounter(
player, target, punch_right, dodge_left
):
"""Test defense in combat queues on encounter and shows move name."""
await combat_commands.do_attack(player, "Vegeta", punch_right)
await combat_commands.do_defend(target, "", dodge_left)
encounter = get_encounter(target)
assert encounter is not None
assert encounter.pending_defense is dodge_left
# In combat: full move name (not "the air")
messages = [call[0][0] for call in target.writer.write.call_args_list]
assert any("dodge left" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_defense_broadcast_to_nearby(player, target, dodge_left):
"""Test defense broadcasts to nearby players."""
target.writer.write.reset_mock()
await combat_commands.do_defend(player, "", dodge_left)
# Target is at same coords, should get broadcast
target_msgs = [call[0][0] for call in target.writer.write.call_args_list]
assert any(player.name in msg for msg in target_msgs)

View file

@ -4,7 +4,7 @@ import time
import pytest import pytest
from mudlib.combat.encounter import CombatEncounter, CombatState, ResolveResult from mudlib.combat.encounter import CombatEncounter, CombatState
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity from mudlib.entity import Entity
@ -51,18 +51,6 @@ def wrong_dodge():
) )
@pytest.fixture
def sweep():
return CombatMove(
name="sweep",
move_type="attack",
stamina_cost=8.0,
timing_window_ms=600,
damage_pct=0.20,
countered_by=["jump"],
)
def test_combat_encounter_initial_state(attacker, defender): def test_combat_encounter_initial_state(attacker, defender):
"""Test encounter starts in IDLE state.""" """Test encounter starts in IDLE state."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
@ -100,15 +88,15 @@ def test_defend_records_pending_defense(attacker, defender, punch, dodge):
assert encounter.pending_defense is dodge assert encounter.pending_defense is dodge
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge): def test_defend_applies_stamina_cost(attacker, defender, punch, dodge):
"""Test encounter.defend() does not deduct stamina (command layer does).""" """Test defending costs stamina."""
encounter = CombatEncounter(attacker=attacker, defender=defender) encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch) encounter.attack(punch)
initial_stamina = defender.stamina initial_stamina = defender.stamina
encounter.defend(dodge) encounter.defend(dodge)
assert defender.stamina == initial_stamina assert defender.stamina == initial_stamina - dodge.stamina_cost
def test_tick_telegraph_to_window(attacker, defender, punch): def test_tick_telegraph_to_window(attacker, defender, punch):
@ -148,16 +136,12 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge):
encounter.defend(dodge) encounter.defend(dodge)
initial_pl = defender.pl initial_pl = defender.pl
result = encounter.resolve() result, combat_ended = encounter.resolve()
assert isinstance(result, ResolveResult)
assert defender.pl == initial_pl assert defender.pl == initial_pl
assert result.damage == 0.0 assert "countered" in result.lower()
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 result.combat_ended is False assert combat_ended is False
def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge): def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge):
@ -167,16 +151,13 @@ 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 = encounter.resolve() result, combat_ended = 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 result.damage == expected_damage assert "hit" in result.lower()
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 result.combat_ended is False assert combat_ended is False
def test_resolve_no_defense(attacker, defender, punch): def test_resolve_no_defense(attacker, defender, punch):
@ -185,17 +166,14 @@ def test_resolve_no_defense(attacker, defender, punch):
encounter.attack(punch) encounter.attack(punch)
initial_pl = defender.pl initial_pl = defender.pl
result = encounter.resolve() result, combat_ended = 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 result.damage == expected_damage assert "full force" in result.lower()
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 result.combat_ended is False assert combat_ended is False
def test_resolve_clears_pending_defense(attacker, defender, punch, dodge): def test_resolve_clears_pending_defense(attacker, defender, punch, dodge):
@ -204,7 +182,7 @@ def test_resolve_clears_pending_defense(attacker, defender, punch, dodge):
encounter.attack(punch) encounter.attack(punch)
encounter.defend(dodge) encounter.defend(dodge)
encounter.resolve() _result, _combat_ended = 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
@ -229,7 +207,7 @@ def test_full_state_machine_cycle(attacker, defender, punch):
assert encounter.state == CombatState.RESOLVE assert encounter.state == CombatState.RESOLVE
# RESOLVE → IDLE # RESOLVE → IDLE
encounter.resolve() _result, _combat_ended = encounter.resolve()
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
@ -249,11 +227,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 = encounter.resolve() result, combat_ended = encounter.resolve()
assert defender.pl <= 0 assert defender.pl <= 0
assert result.combat_ended is True assert combat_ended is True
assert result.damage > 0 assert "damage" in result.lower()
def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch): def test_resolve_returns_combat_ended_on_exhaustion(attacker, defender, punch):
@ -264,10 +242,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 = encounter.resolve() result, combat_ended = encounter.resolve()
assert attacker.stamina <= 0 assert attacker.stamina <= 0
assert result.combat_ended is True assert combat_ended is True
def test_resolve_returns_combat_continues_normally(attacker, defender, punch): def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
@ -275,147 +253,8 @@ 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 = encounter.resolve() result, combat_ended = encounter.resolve()
assert attacker.stamina > 0 assert attacker.stamina > 0
assert defender.pl > 0 assert defender.pl > 0
assert result.combat_ended is False assert 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()
# --- Attack switching (feint) tests ---
def test_switch_attack_during_telegraph(attacker, defender, punch, sweep):
"""Test attack during TELEGRAPH replaces move and keeps timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
original_start = encounter.move_started_at
assert encounter.state == CombatState.TELEGRAPH
# Switch to sweep during telegraph
encounter.attack(sweep)
assert encounter.current_move is sweep
assert encounter.state == CombatState.TELEGRAPH
# Timer should NOT restart
assert encounter.move_started_at == original_start
def test_switch_attack_during_window(attacker, defender, punch, sweep):
"""Test attack during WINDOW replaces move and keeps timer."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
original_start = encounter.move_started_at
# Advance to WINDOW
time.sleep(0.31)
encounter.tick(time.monotonic())
assert encounter.state == CombatState.WINDOW
# Switch to sweep during window
encounter.attack(sweep)
assert encounter.current_move is sweep
assert encounter.state == CombatState.WINDOW
# Timer should NOT restart
assert encounter.move_started_at == original_start
def test_switch_refunds_old_stamina(attacker, defender, punch, sweep):
"""Test switching refunds old move's cost and charges new."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
initial_stamina = attacker.stamina
encounter.attack(punch)
assert attacker.stamina == initial_stamina - punch.stamina_cost
# Switch to sweep — should refund punch, charge sweep
encounter.attack(sweep)
assert attacker.stamina == initial_stamina - sweep.stamina_cost
def test_switch_stamina_clamped_to_max(attacker, defender, punch, sweep):
"""Test stamina refund clamped to max_stamina."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
# Set stamina so refund would exceed max
attacker.stamina = attacker.max_stamina
encounter.attack(punch)
# Manually set stamina high so refund would exceed max
attacker.stamina = attacker.max_stamina - 1
encounter.attack(sweep)
# Refund of punch (5) would push past max, should clamp
assert attacker.stamina <= attacker.max_stamina
def test_resolve_uses_final_move(attacker, defender, punch, sweep):
"""Test resolve uses the switched-to move, not the original."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
encounter.attack(sweep) # Switch
initial_pl = defender.pl
result = encounter.resolve()
# 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()
# --- last_action_at tracking tests ---
def test_last_action_at_updates_on_attack(attacker, defender, punch):
"""Test last_action_at is set when attack() is called."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
assert encounter.last_action_at == 0.0
before = time.monotonic()
encounter.attack(punch)
assert encounter.last_action_at >= before
def test_last_action_at_updates_on_defend(attacker, defender, punch, dodge):
"""Test last_action_at is set when defend() is called."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
first_action = encounter.last_action_at
time.sleep(0.01)
encounter.defend(dodge)
assert encounter.last_action_at > first_action

View file

@ -1,7 +1,6 @@
"""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
@ -15,14 +14,6 @@ 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)
@ -97,21 +88,19 @@ def test_end_encounter_removes_from_active_list(attacker, defender):
assert get_encounter(defender) is None assert get_encounter(defender) is None
@pytest.mark.asyncio def test_process_combat_advances_encounters(attacker, defender, punch):
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)
await process_combat() process_combat()
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.WINDOW
@pytest.mark.asyncio def test_process_combat_handles_multiple_encounters(punch):
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)
@ -125,14 +114,13 @@ async def test_process_combat_handles_multiple_encounters(punch):
enc2.attack(punch) enc2.attack(punch)
time.sleep(0.31) time.sleep(0.31)
await process_combat() process_combat()
assert enc1.state == CombatState.WINDOW assert enc1.state == CombatState.WINDOW
assert enc2.state == CombatState.WINDOW assert enc2.state == CombatState.WINDOW
@pytest.mark.asyncio def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch):
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)
@ -140,11 +128,11 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
# Skip past telegraph and window # Skip past telegraph and window
time.sleep(0.31) # Telegraph time.sleep(0.31) # Telegraph
await process_combat() process_combat()
assert encounter.state == CombatState.WINDOW assert encounter.state == CombatState.WINDOW
time.sleep(0.85) # Window time.sleep(0.85) # Window
await process_combat() 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)
@ -174,23 +162,21 @@ def test_active_encounters_list():
assert isinstance(active_encounters, list) assert isinstance(active_encounters, list)
@pytest.mark.asyncio def test_process_combat_with_no_encounters():
async def test_process_combat_with_no_encounters():
"""Test process_combat handles empty encounter list.""" """Test process_combat handles empty encounter list."""
await process_combat() # Should not raise process_combat() # Should not raise
@pytest.mark.asyncio def test_encounter_cleanup_after_resolution(attacker, defender, punch):
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)
await process_combat() process_combat()
time.sleep(0.85) time.sleep(0.85)
await process_combat() process_combat()
# Resolve # Resolve
encounter.resolve() encounter.resolve()
@ -200,12 +186,12 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
assert get_encounter(attacker) is None assert get_encounter(attacker) is None
@pytest.mark.asyncio def test_process_combat_ends_encounter_on_knockout(punch):
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."""
w = _mock_writer from mudlib.player import Player
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()) 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)
# Push combat mode onto both stacks # Push combat mode onto both stacks
attacker.mode_stack.append("combat") attacker.mode_stack.append("combat")
@ -216,9 +202,9 @@ async def test_process_combat_ends_encounter_on_knockout(punch):
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
await process_combat() process_combat()
time.sleep(0.85) time.sleep(0.85)
await process_combat() 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
@ -228,26 +214,12 @@ async def test_process_combat_ends_encounter_on_knockout(punch):
assert defender.mode_stack == ["normal"] assert defender.mode_stack == ["normal"]
@pytest.mark.asyncio def test_process_combat_ends_encounter_on_exhaustion(punch):
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."""
w = _mock_writer from mudlib.player import Player
attacker = Player(
name="Goku", attacker = Player(name="Goku", x=0, y=0, pl=100.0, stamina=punch.stamina_cost)
x=0, defender = Player(name="Vegeta", x=0, y=0, pl=100.0, stamina=50.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")
@ -258,9 +230,9 @@ async def test_process_combat_ends_encounter_on_exhaustion(punch):
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
await process_combat() process_combat()
time.sleep(0.85) time.sleep(0.85)
await process_combat() process_combat()
# Combat should have ended # Combat should have ended
assert get_encounter(attacker) is None assert get_encounter(attacker) is None
@ -269,12 +241,12 @@ async def test_process_combat_ends_encounter_on_exhaustion(punch):
assert defender.mode_stack == ["normal"] assert defender.mode_stack == ["normal"]
@pytest.mark.asyncio def test_process_combat_continues_with_resources(punch):
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."""
w = _mock_writer from mudlib.player import Player
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()) 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)
# Push combat mode onto both stacks # Push combat mode onto both stacks
attacker.mode_stack.append("combat") attacker.mode_stack.append("combat")
@ -285,137 +257,12 @@ async def test_process_combat_continues_with_resources(punch):
# Advance to resolution # Advance to resolution
time.sleep(0.31) time.sleep(0.31)
await process_combat() process_combat()
time.sleep(0.85) time.sleep(0.85)
await process_combat() 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)
# --- Idle timeout tests ---
@pytest.mark.asyncio
async def test_idle_timeout_ends_encounter():
"""Test encounter times out after 30s of no actions."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w())
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
# Force last_action_at to 31 seconds ago
encounter.last_action_at = time.monotonic() - 31
await process_combat()
assert get_encounter(attacker) is None
assert get_encounter(defender) is None
@pytest.mark.asyncio
async def test_idle_timeout_sends_message():
"""Test timeout sends fizzle message to both players."""
atk_writer = _mock_writer()
def_writer = _mock_writer()
attacker = Player(name="Goku", x=0, y=0, writer=atk_writer)
defender = Player(name="Vegeta", x=0, y=0, writer=def_writer)
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.last_action_at = time.monotonic() - 31
await process_combat()
atk_msgs = [c[0][0] for c in atk_writer.write.call_args_list]
def_msgs = [c[0][0] for c in def_writer.write.call_args_list]
assert any("fizzled" in msg.lower() for msg in atk_msgs)
assert any("fizzled" in msg.lower() for msg in def_msgs)
@pytest.mark.asyncio
async def test_idle_timeout_pops_combat_mode():
"""Test timeout pops combat mode from both players."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w())
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
encounter.last_action_at = time.monotonic() - 31
await process_combat()
assert attacker.mode_stack == ["normal"]
assert defender.mode_stack == ["normal"]
@pytest.mark.asyncio
async def test_recent_action_prevents_timeout():
"""Test recent action prevents idle timeout."""
w = _mock_writer
attacker = Player(name="Goku", x=0, y=0, writer=w())
defender = Player(name="Vegeta", x=0, y=0, writer=w())
attacker.mode_stack.append("combat")
defender.mode_stack.append("combat")
encounter = start_encounter(attacker, defender)
# last_action_at was set to now by start_encounter
await process_combat()
# Should still be active
assert get_encounter(attacker) is encounter
@pytest.mark.asyncio
async def test_start_encounter_sets_last_action_at():
"""Test start_encounter initializes last_action_at."""
attacker = Entity(name="Goku", x=0, y=0)
defender = Entity(name="Vegeta", x=0, y=0)
before = time.monotonic()
encounter = start_encounter(attacker, defender)
assert encounter.last_action_at >= before

View file

@ -27,7 +27,6 @@ def test_entity_has_combat_stats():
assert entity.pl == 100.0 assert entity.pl == 100.0
assert entity.stamina == 100.0 assert entity.stamina == 100.0
assert entity.max_stamina == 100.0 assert entity.max_stamina == 100.0
assert entity.defense_locked_until == 0.0
def test_entity_combat_stats_can_be_customized(): def test_entity_combat_stats_can_be_customized():