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.
This commit is contained in:
parent
9054962f5d
commit
cf423fb22b
4 changed files with 156 additions and 6 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import get_encounter, start_encounter
|
from mudlib.combat.engine import get_encounter, start_encounter
|
||||||
from mudlib.combat.moves import CombatMove, load_moves
|
from mudlib.combat.moves import CombatMove, load_moves
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
|
@ -63,8 +64,17 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
telegraph = move.telegraph.format(attacker=player.name)
|
telegraph = move.telegraph.format(attacker=player.name)
|
||||||
await defender.send(f"{telegraph}\r\n")
|
await defender.send(f"{telegraph}\r\n")
|
||||||
|
|
||||||
|
# Detect switch before attack() modifies state
|
||||||
|
switching = encounter.state in (
|
||||||
|
CombatState.TELEGRAPH,
|
||||||
|
CombatState.WINDOW,
|
||||||
|
)
|
||||||
|
|
||||||
# Execute the attack
|
# Execute the attack
|
||||||
encounter.attack(move)
|
encounter.attack(move)
|
||||||
|
if switching:
|
||||||
|
await player.send(f"You 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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,17 +44,29 @@ class CombatEncounter:
|
||||||
pending_defense: CombatMove | None = None
|
pending_defense: CombatMove | None = None
|
||||||
|
|
||||||
def attack(self, move: CombatMove) -> None:
|
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:
|
Args:
|
||||||
move: The attack move to execute
|
move: The attack move to execute
|
||||||
"""
|
"""
|
||||||
self.current_move = move
|
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
|
||||||
self.state = CombatState.TELEGRAPH
|
# 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 = time.monotonic()
|
self.move_started_at = time.monotonic()
|
||||||
|
|
||||||
# Apply stamina cost
|
self.current_move = move
|
||||||
self.attacker.stamina -= move.stamina_cost
|
self.attacker.stamina -= move.stamina_cost
|
||||||
|
if self.state == CombatState.IDLE:
|
||||||
|
self.state = CombatState.TELEGRAPH
|
||||||
|
|
||||||
def defend(self, move: CombatMove) -> None:
|
def defend(self, move: CombatMove) -> None:
|
||||||
"""Queue a defense move.
|
"""Queue a defense move.
|
||||||
|
|
|
||||||
|
|
@ -288,3 +288,35 @@ async def test_direct_handler_alias_for_variant(player, target, punch_right):
|
||||||
assert encounter.current_move.name == "punch right"
|
assert encounter.current_move.name == "punch right"
|
||||||
assert encounter.attacker is player
|
assert encounter.attacker is player
|
||||||
assert encounter.defender is target
|
assert encounter.defender is target
|
||||||
|
|
||||||
|
|
||||||
|
# --- attack switching tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_attack_shows_switch_message(
|
||||||
|
player, target, punch_right, punch_left
|
||||||
|
):
|
||||||
|
"""Test switching attack says 'switch to' instead of 'use'."""
|
||||||
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
await combat_commands.do_attack(player, "", punch_left)
|
||||||
|
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
assert any("switch to" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_switch_attack_sends_new_telegraph(
|
||||||
|
player, target, punch_right, punch_left
|
||||||
|
):
|
||||||
|
"""Test switching attack sends new telegraph to defender."""
|
||||||
|
await combat_commands.do_attack(player, "Vegeta", punch_right)
|
||||||
|
target.writer.write.reset_mock()
|
||||||
|
|
||||||
|
await combat_commands.do_attack(player, "", punch_left)
|
||||||
|
|
||||||
|
target_msgs = [call[0][0] for call in target.writer.write.call_args_list]
|
||||||
|
# Defender should get a new telegraph
|
||||||
|
assert len(target_msgs) > 0
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def test_combat_encounter_initial_state(attacker, defender):
|
||||||
"""Test encounter starts in IDLE state."""
|
"""Test encounter starts in IDLE state."""
|
||||||
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
encounter = CombatEncounter(attacker=attacker, defender=defender)
|
||||||
|
|
@ -297,3 +309,87 @@ def test_resolve_counter_messages_contain_move_name(attacker, defender, punch, d
|
||||||
|
|
||||||
assert "punch right" in result.attacker_msg.lower()
|
assert "punch right" in result.attacker_msg.lower()
|
||||||
assert "punch right" in result.defender_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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue