Compare commits
7 commits
588227bcd0
...
e9378bb6fa
| Author | SHA1 | Date | |
|---|---|---|---|
| e9378bb6fa | |||
| 3c5c1490e6 | |||
| 2de1ebd59e | |||
| 1b3684dc65 | |||
| e368ed1843 | |||
| cf423fb22b | |||
| 9054962f5d |
13 changed files with 625 additions and 117 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue