Compare commits

...

7 commits

Author SHA1 Message Date
e9378bb6fa
Tweak the timing windows in combat 2026-02-08 12:28:17 -05:00
3c5c1490e6
Replace defense lock with sleep-based commitment blocking
Defense moves now asyncio.sleep for timing_window_ms instead of using
a cooldown field. Input queues naturally since the per-player loop is
sequential. Outside combat shows "parry the air!" flavor text.
2026-02-08 12:28:17 -05:00
2de1ebd59e
Fix variant defense mode and test cleanup
- Set variant defense registration to mode="*" (both attacks and defenses)
- Strengthen telegraph switch test to verify new move's telegraph text
- Remove unused punch parameter from four idle timeout tests
- Use single time.monotonic() call in attack() method
2026-02-08 12:28:17 -05:00
1b3684dc65
Add 30-second idle timeout for combat encounters
Encounters track last_action_at (updated on attack and defend). If 30
seconds pass with no actions, combat fizzles out with a message to both
players and combat mode is popped. start_encounter initializes the
timestamp so fresh encounters don't immediately timeout.
2026-02-08 12:28:17 -05:00
e368ed1843
Add defense commitment lock and defense-everywhere support
Defenses now work outside combat mode with stamina cost, recovery lock
(based on timing_window_ms), and broadcast to nearby players. Lock
prevents spamming defenses — you commit to the move. Stamina deduction
moved from encounter.defend() to do_defend command layer. Defense
commands registered with mode="*" instead of "combat".
2026-02-08 12:28:17 -05:00
cf423fb22b
Add attack switching (feints) during telegraph and window phases
Attacker can change their move mid-telegraph or mid-window without
resetting the timer. Old move's stamina is refunded, new move charged.
Defender gets a fresh telegraph on switch. Feedback says "switch to"
instead of "use" when swapping attacks.
2026-02-08 12:28:17 -05:00
9054962f5d
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.
2026-02-08 12:28:17 -05:00
13 changed files with 625 additions and 117 deletions

View file

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

View file

@ -1,7 +1,7 @@
name = "punch"
move_type = "attack"
stamina_cost = 5.0
timing_window_ms = 800
timing_window_ms = 1800
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 = 600
timing_window_ms = 2000
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 = 700
timing_window_ms = 1800
damage_pct = 0.18
countered_by = ["jump", "parry low"]

View file

@ -1,8 +1,11 @@
"""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
@ -63,33 +66,60 @@ 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)
await player.send(f"You use {move.name}!\r\n")
if switching:
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:
"""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
# Queue the defense
encounter.defend(move)
await player.send(f"You attempt to {move.name}!\r\n")
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")
def _make_direct_handler(move: CombatMove, handler_fn):
@ -164,7 +194,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 = "*" if move.move_type == "attack" else "combat"
mode = "*"
action = "Attack" if move.move_type == "attack" else "Defend"
register(
CommandDefinition(
@ -181,7 +211,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 = "*" if first_variant.move_type == "attack" else "combat"
mode = "*"
# Collect all variant aliases for the base command
all_aliases = []

View file

@ -20,6 +20,20 @@ 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:
@ -31,30 +45,46 @@ 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 an attack move.
"""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.
Args:
move: The attack move to execute
"""
self.current_move = move
self.state = CombatState.TELEGRAPH
self.move_started_at = time.monotonic()
now = time.monotonic()
# Apply stamina cost
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.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.
"""Queue a defense move on the encounter.
Stamina cost and lock are handled by the command layer (do_defend).
Args:
move: The defense move to attempt
"""
self.pending_defense = move
# Apply stamina cost
self.defender.stamina -= move.stamina_cost
self.last_action_at = time.monotonic()
def tick(self, now: float) -> None:
"""Advance the state machine based on current time.
@ -80,14 +110,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 +136,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 +171,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,
)

View file

@ -2,7 +2,7 @@
import time
from mudlib.combat.encounter import CombatEncounter, CombatState
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.entity import Entity
# Global list of active combat encounters
@ -32,7 +32,11 @@ def start_encounter(attacker: Entity, defender: Entity) -> CombatEncounter:
raise ValueError(msg)
# Create and register the encounter
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter = CombatEncounter(
attacker=attacker,
defender=defender,
last_action_at=time.monotonic(),
)
active_encounters.append(encounter)
return encounter
@ -63,7 +67,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.
@ -71,14 +75,32 @@ 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, 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

View file

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

View file

@ -1,10 +1,12 @@
"""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
@ -21,6 +23,18 @@ 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()
@ -154,25 +168,24 @@ async def test_attack_sends_telegraph_to_defender(player, target, punch_right):
@pytest.mark.asyncio
async def test_defense_only_in_combat(player, dodge_left):
"""Test do_defend only works in combat mode."""
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
await combat_commands.do_defend(player, "", dodge_left)
player.writer.write.assert_called()
message = player.writer.write.call_args[0][0]
assert "not in combat" in message.lower()
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)
@pytest.mark.asyncio
async def test_defense_records_pending_defense(player, target, punch_right, dodge_left):
"""Test do_defend records the defense move."""
"""Test do_defend queues defense on encounter and costs stamina."""
# Start combat
await combat_commands.do_attack(player, "Vegeta", punch_right)
player.writer.write.reset_mock()
# Switch to defender's perspective
target.writer = player.writer
target.mode_stack = ["combat"]
initial_stamina = target.stamina
# Defend
await combat_commands.do_defend(target, "", dodge_left)
@ -181,23 +194,19 @@ 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, target, punch_right, dodge_left):
async def test_defense_insufficient_stamina(player, dodge_left):
"""Test do_defend with insufficient stamina gives error."""
# 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)
player.stamina = 1.0 # Not enough for dodge (costs 3)
player.writer.write.reset_mock()
await combat_commands.do_defend(target, "", dodge_left)
await combat_commands.do_defend(player, "", dodge_left)
target.writer.write.assert_called()
message = target.writer.write.call_args[0][0]
assert "stamina" in message.lower()
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)
# --- variant handler tests ---
@ -288,3 +297,78 @@ 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
from mudlib.combat.encounter import CombatEncounter, CombatState, ResolveResult
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
@ -51,6 +51,18 @@ 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)
@ -88,15 +100,15 @@ def test_defend_records_pending_defense(attacker, defender, punch, dodge):
assert encounter.pending_defense is dodge
def test_defend_applies_stamina_cost(attacker, defender, punch, dodge):
"""Test defending costs stamina."""
def test_defend_does_not_deduct_stamina(attacker, defender, punch, dodge):
"""Test encounter.defend() does not deduct stamina (command layer does)."""
encounter = CombatEncounter(attacker=attacker, defender=defender)
encounter.attack(punch)
initial_stamina = defender.stamina
encounter.defend(dodge)
assert defender.stamina == initial_stamina - dodge.stamina_cost
assert defender.stamina == initial_stamina
def test_tick_telegraph_to_window(attacker, defender, punch):
@ -136,12 +148,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 +167,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 +185,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 +204,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 +229,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 +249,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 +264,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 +275,147 @@ 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()
# --- 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,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,137 @@ 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)
# --- 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,6 +27,7 @@ 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():