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:
Jared Miller 2026-02-08 11:34:32 -05:00
parent 9054962f5d
commit cf423fb22b
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 156 additions and 6 deletions

View file

@ -3,6 +3,7 @@
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,9 +64,18 @@ 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:

View file

@ -44,17 +44,29 @@ class CombatEncounter:
pending_defense: CombatMove | None = 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:
move: The attack move to execute
"""
self.current_move = move
self.state = CombatState.TELEGRAPH
self.move_started_at = time.monotonic()
if self.state in (CombatState.TELEGRAPH, CombatState.WINDOW):
# Switching — refund old cost, keep timer
if self.current_move:
self.attacker.stamina = min(
self.attacker.stamina + self.current_move.stamina_cost,
self.attacker.max_stamina,
)
else:
# First attack — start timer
self.move_started_at = time.monotonic()
# Apply stamina cost
self.current_move = move
self.attacker.stamina -= move.stamina_cost
if self.state == CombatState.IDLE:
self.state = CombatState.TELEGRAPH
def defend(self, move: CombatMove) -> None:
"""Queue a defense move.

View file

@ -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.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
assert len(target_msgs) > 0

View file

@ -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)
@ -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.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()