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"
move_type = "defense"
stamina_cost = 4.0
timing_window_ms = 1200
timing_window_ms = 500
[variants.high]
aliases = ["f"]

View file

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

View file

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

View file

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

View file

@ -1,11 +1,8 @@
"""Combat command handlers."""
import asyncio
import time
from collections import defaultdict
from pathlib import Path
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter
from mudlib.combat.moves import CombatMove, load_moves
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)
await defender.send(f"{telegraph}\r\n")
# Detect switch before attack() modifies state
switching = encounter.state in (
CombatState.TELEGRAPH,
CombatState.WINDOW,
)
# Execute the attack
encounter.attack(move)
if switching:
await player.send(f"You switch to {move.name}!\r\n")
else:
await player.send(f"You use {move.name}!\r\n")
await player.send(f"You use {move.name}!\r\n")
async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
"""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:
player: The defending player
_args: Unused (defense moves don't take a target)
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
if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n")
return
player.stamina -= move.stamina_cost
# If in combat, queue the defense on the encounter
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")
# Queue the defense
encounter.defend(move)
await player.send(f"You attempt to {move.name}!\r\n")
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)
for move in simple_moves:
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"
register(
CommandDefinition(
@ -211,7 +181,7 @@ def register_combat_commands(content_dir: Path) -> None:
# Determine type from first variant
first_variant = next(iter(variants.values()))
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
all_aliases = []

View file

@ -20,20 +20,6 @@ class CombatState(Enum):
# Telegraph phase duration in seconds (3 game ticks at 100ms/tick)
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
class CombatEncounter:
@ -45,46 +31,30 @@ class CombatEncounter:
current_move: CombatMove | None = None
move_started_at: float = 0.0
pending_defense: CombatMove | None = None
last_action_at: float = 0.0
def attack(self, move: CombatMove) -> None:
"""Initiate or switch an attack move.
If called during TELEGRAPH or WINDOW, switches to the new move
without resetting the timer. Refunds old move's stamina cost.
"""Initiate an attack move.
Args:
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.state = CombatState.TELEGRAPH
self.move_started_at = time.monotonic()
# Apply 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:
"""Queue a defense move on the encounter.
Stamina cost and lock are handled by the command layer (do_defend).
"""Queue a defense move.
Args:
move: The defense move to attempt
"""
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:
"""Advance the state machine based on current time.
@ -110,24 +80,14 @@ class CombatEncounter:
if elapsed >= total_time:
self.state = CombatState.RESOLVE
def resolve(self) -> ResolveResult:
"""Resolve the combat exchange and return result.
def resolve(self) -> tuple[str, bool]:
"""Resolve the combat exchange and return result message.
Returns:
ResolveResult with messages for both participants
Tuple of (result message, combat_ended flag)
"""
if self.current_move is None:
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
return ("No active move to resolve.", False)
# Check if defense counters attack
defense_succeeds = (
@ -136,32 +96,22 @@ class CombatEncounter:
)
if defense_succeeds:
# Successful counter - no damage
damage = 0.0
attacker_msg = f"{defender_name} countered your {move_name}!"
defender_msg = f"You countered {attacker_name}'s {move_name}!"
countered = True
result = f"{self.defender.name} countered the attack!"
elif self.pending_defense:
# Wrong defense - normal damage
damage = self.attacker.pl * self.current_move.damage_pct
self.defender.pl -= damage
attacker_msg = (
f"Your {move_name} hits {defender_name} for {damage:.1f} damage!"
result = (
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:
# No defense - increased damage
damage = self.attacker.pl * self.current_move.damage_pct * 1.5
self.defender.pl -= damage
attacker_msg = (
f"Your {move_name} slams {defender_name} for {damage:.1f} damage!"
result = (
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
combat_ended = self.defender.pl <= 0 or self.attacker.stamina <= 0
@ -171,10 +121,4 @@ class CombatEncounter:
self.current_move = None
self.pending_defense = None
return ResolveResult(
attacker_msg=attacker_msg,
defender_msg=defender_msg,
damage=damage,
countered=countered,
combat_ended=combat_ended,
)
return (result, combat_ended)

View file

@ -2,7 +2,7 @@
import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.combat.encounter import CombatEncounter, CombatState
from mudlib.entity import Entity
# Global list of active combat encounters
@ -32,11 +32,7 @@ def start_encounter(attacker: Entity, defender: Entity) -> CombatEncounter:
raise ValueError(msg)
# Create and register the encounter
encounter = CombatEncounter(
attacker=attacker,
defender=defender,
last_action_at=time.monotonic(),
)
encounter = CombatEncounter(attacker=attacker, defender=defender)
active_encounters.append(encounter)
return encounter
@ -67,7 +63,7 @@ def end_encounter(encounter: CombatEncounter) -> None:
active_encounters.remove(encounter)
async def process_combat() -> None:
def process_combat() -> None:
"""Process all active combat encounters.
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()
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
encounter.tick(now)
# Auto-resolve if in RESOLVE state
if encounter.state == CombatState.RESOLVE:
result = encounter.resolve()
_result, combat_ended = encounter.resolve()
# Send resolution messages to both participants
await encounter.attacker.send(result.attacker_msg + "\r\n")
await encounter.defender.send(result.defender_msg + "\r\n")
if result.combat_ended:
if combat_ended:
# Pop combat mode from both entities if they're Players
from mudlib.player import Player

View file

@ -14,7 +14,6 @@ class Entity:
pl: float = 100.0 # power level (health and damage multiplier)
stamina: float = 100.0 # current stamina
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:
"""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:
t0 = asyncio.get_event_loop().time()
clear_expired()
await process_combat()
process_combat()
# Periodic auto-save (every 60 seconds)
current_time = time.monotonic()

View file

@ -1,12 +1,10 @@
"""Tests for combat commands."""
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves
@ -23,18 +21,6 @@ def clear_state():
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
def mock_writer():
writer = MagicMock()
@ -168,24 +154,25 @@ async def test_attack_sends_telegraph_to_defender(player, target, punch_right):
@pytest.mark.asyncio
async def test_defense_works_outside_combat(player, dodge_left):
"""Test do_defend works outside combat (costs stamina, 'the air' message)."""
initial_stamina = player.stamina
async def test_defense_only_in_combat(player, dodge_left):
"""Test do_defend only works in combat mode."""
await combat_commands.do_defend(player, "", dodge_left)
assert player.stamina == initial_stamina - dodge_left.stamina_cost
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("dodge the air" in msg.lower() for msg in messages)
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "not in combat" in message.lower()
@pytest.mark.asyncio
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
await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.reset_mock()
initial_stamina = target.stamina
# Switch to defender's perspective
target.writer = player.writer
target.mode_stack = ["combat"]
# Defend
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.pending_defense is not None
assert encounter.pending_defense.name == "dodge left"
assert target.stamina == initial_stamina - dodge_left.stamina_cost
@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."""
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()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("stamina" in msg.lower() for msg in messages)
target.writer.write.assert_called()
message = target.writer.write.call_args[0][0]
assert "stamina" in message.lower()
# --- 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.attacker is player
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
from mudlib.combat.encounter import CombatEncounter, CombatState, ResolveResult
from mudlib.combat.encounter import CombatEncounter, CombatState
from mudlib.combat.moves import CombatMove
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):
"""Test encounter starts in IDLE state."""
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
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
"""Test encounter.defend() does not deduct stamina (command layer does)."""
def test_defend_applies_stamina_cost(attacker, defender, punch, dodge):
"""Test defending costs stamina."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
initial_stamina = defender.stamina
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):
@ -148,16 +136,12 @@ def test_resolve_successful_counter(attacker, defender, punch, dodge):
encounter.defend(dodge)
initial_pl = defender.pl
result = encounter.resolve()
result, combat_ended = encounter.resolve()
assert isinstance(result, ResolveResult)
assert defender.pl == initial_pl
assert result.damage == 0.0
assert result.countered is True
assert "countered" in result.attacker_msg.lower()
assert "countered" in result.defender_msg.lower()
assert "countered" in result.lower()
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):
@ -167,16 +151,13 @@ def test_resolve_failed_counter(attacker, defender, punch, wrong_dodge):
encounter.defend(wrong_dodge)
initial_pl = defender.pl
result = encounter.resolve()
result, combat_ended = encounter.resolve()
expected_damage = attacker.pl * punch.damage_pct
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
assert "hits" in result.attacker_msg.lower()
assert "hits" in result.defender_msg.lower()
assert "hit" in result.lower()
assert encounter.state == CombatState.IDLE
assert result.combat_ended is False
assert combat_ended is False
def test_resolve_no_defense(attacker, defender, punch):
@ -185,17 +166,14 @@ def test_resolve_no_defense(attacker, defender, punch):
encounter.attack(punch)
initial_pl = defender.pl
result = encounter.resolve()
result, combat_ended = encounter.resolve()
# No defense = 1.5x damage
expected_damage = attacker.pl * punch.damage_pct * 1.5
assert defender.pl == initial_pl - expected_damage
assert result.damage == expected_damage
assert result.countered is False
assert "slams" in result.attacker_msg.lower()
assert "slams" in result.defender_msg.lower()
assert "full force" in result.lower()
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):
@ -204,7 +182,7 @@ def test_resolve_clears_pending_defense(attacker, defender, punch, dodge):
encounter.attack(punch)
encounter.defend(dodge)
encounter.resolve()
_result, _combat_ended = encounter.resolve()
assert encounter.pending_defense 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
# RESOLVE → IDLE
encounter.resolve()
_result, _combat_ended = encounter.resolve()
assert encounter.state == CombatState.IDLE
@ -249,11 +227,11 @@ def test_resolve_returns_combat_ended_on_knockout(attacker, defender, punch):
defender.pl = 10.0
encounter.attack(punch)
result = encounter.resolve()
result, combat_ended = encounter.resolve()
assert defender.pl <= 0
assert result.combat_ended is True
assert result.damage > 0
assert combat_ended is True
assert "damage" in result.lower()
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
encounter.attack(punch)
result = encounter.resolve()
result, combat_ended = encounter.resolve()
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):
@ -275,147 +253,8 @@ def test_resolve_returns_combat_continues_normally(attacker, defender, punch):
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
result = encounter.resolve()
result, combat_ended = encounter.resolve()
assert attacker.stamina > 0
assert defender.pl > 0
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()
# --- 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
assert combat_ended is False

View file

@ -1,7 +1,6 @@
"""Tests for combat engine and encounter management."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -15,14 +14,6 @@ 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)
@ -97,21 +88,19 @@ def test_end_encounter_removes_from_active_list(attacker, defender):
assert get_encounter(defender) is None
@pytest.mark.asyncio
async def test_process_combat_advances_encounters(attacker, defender, punch):
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)
await process_combat()
process_combat()
assert encounter.state == CombatState.WINDOW
@pytest.mark.asyncio
async def test_process_combat_handles_multiple_encounters(punch):
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)
@ -125,14 +114,13 @@ async def test_process_combat_handles_multiple_encounters(punch):
enc2.attack(punch)
time.sleep(0.31)
await process_combat()
process_combat()
assert enc1.state == CombatState.WINDOW
assert enc2.state == CombatState.WINDOW
@pytest.mark.asyncio
async def test_process_combat_auto_resolves_expired_windows(attacker, defender, punch):
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)
@ -140,11 +128,11 @@ async def test_process_combat_auto_resolves_expired_windows(attacker, defender,
# Skip past telegraph and window
time.sleep(0.31) # Telegraph
await process_combat()
process_combat()
assert encounter.state == CombatState.WINDOW
time.sleep(0.85) # Window
await process_combat()
process_combat()
# Should auto-resolve and return to IDLE
assert encounter.state == CombatState.IDLE
# Damage should have been applied (no defense = 1.5x damage)
@ -174,23 +162,21 @@ def test_active_encounters_list():
assert isinstance(active_encounters, list)
@pytest.mark.asyncio
async def test_process_combat_with_no_encounters():
def test_process_combat_with_no_encounters():
"""Test process_combat handles empty encounter list."""
await process_combat() # Should not raise
process_combat() # Should not raise
@pytest.mark.asyncio
async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
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)
await process_combat()
process_combat()
time.sleep(0.85)
await process_combat()
process_combat()
# Resolve
encounter.resolve()
@ -200,12 +186,12 @@ async def test_encounter_cleanup_after_resolution(attacker, defender, punch):
assert get_encounter(attacker) is None
@pytest.mark.asyncio
async def test_process_combat_ends_encounter_on_knockout(punch):
def test_process_combat_ends_encounter_on_knockout(punch):
"""Test process_combat ends encounter when defender is knocked out."""
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())
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)
# Push combat mode onto both stacks
attacker.mode_stack.append("combat")
@ -216,9 +202,9 @@ async def test_process_combat_ends_encounter_on_knockout(punch):
# Advance to resolution
time.sleep(0.31)
await process_combat()
process_combat()
time.sleep(0.85)
await process_combat()
process_combat()
# Combat should have ended and been cleaned up
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"]
@pytest.mark.asyncio
async def test_process_combat_ends_encounter_on_exhaustion(punch):
def test_process_combat_ends_encounter_on_exhaustion(punch):
"""Test process_combat ends encounter when attacker is exhausted."""
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(),
)
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)
# Push combat mode onto both stacks
attacker.mode_stack.append("combat")
@ -258,9 +230,9 @@ async def test_process_combat_ends_encounter_on_exhaustion(punch):
# Advance to resolution
time.sleep(0.31)
await process_combat()
process_combat()
time.sleep(0.85)
await process_combat()
process_combat()
# Combat should have ended
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"]
@pytest.mark.asyncio
async def test_process_combat_continues_with_resources(punch):
def test_process_combat_continues_with_resources(punch):
"""Test process_combat continues encounter when both have resources."""
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())
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)
# Push combat mode onto both stacks
attacker.mode_stack.append("combat")
@ -285,137 +257,12 @@ async def test_process_combat_continues_with_resources(punch):
# Advance to resolution
time.sleep(0.31)
await process_combat()
process_combat()
time.sleep(0.85)
await process_combat()
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)
# --- 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.stamina == 100.0
assert entity.max_stamina == 100.0
assert entity.defense_locked_until == 0.0
def test_entity_combat_stats_can_be_customized():