diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 8e8bcb7..29f57cd 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -1,5 +1,6 @@ """Combat command handlers.""" +import asyncio import time from collections import defaultdict from pathlib import Path @@ -90,20 +91,11 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None: _args: Unused (defense moves don't take a target) move: The resolved combat move """ - now = time.monotonic() - - # Commitment check: locked from previous defense - if player.defense_locked_until > now: - await player.send("You're still recovering!\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 - # Apply lock and stamina cost - player.defense_locked_until = now + (move.timing_window_ms / 1000.0) player.stamina -= move.stamina_cost # If in combat, queue the defense on the encounter @@ -118,10 +110,16 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None: player, player.x, player.y, - f"{player.name} {move.name}s!\r\n", + f"{player.name} {move.command}s!\r\n", ) - await player.send(f"You {move.name}!\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): diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index 20c59c6..dfbcc4f 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -169,12 +169,13 @@ async def test_attack_sends_telegraph_to_defender(player, target, punch_right): @pytest.mark.asyncio async def test_defense_works_outside_combat(player, dodge_left): - """Test do_defend works outside combat (costs stamina, applies lock).""" + """Test do_defend works outside combat (costs stamina, 'the air' message).""" initial_stamina = player.stamina await combat_commands.do_defend(player, "", dodge_left) assert player.stamina == initial_stamina - dodge_left.stamina_cost - assert player.defense_locked_until > 0 + 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 @@ -334,52 +335,21 @@ async def test_switch_attack_sends_new_telegraph( @pytest.mark.asyncio -async def test_defense_rejected_while_locked(player, dodge_left): - """Test defense rejected during recovery lock.""" - await combat_commands.do_defend(player, "", dodge_left) - player.writer.write.reset_mock() - - # Immediately try again — should be locked - await combat_commands.do_defend(player, "", dodge_left) - - messages = [call[0][0] for call in player.writer.write.call_args_list] - assert any("still recovering" in msg.lower() for msg in messages) - - -@pytest.mark.asyncio -async def test_defense_works_after_lock_expires(player, dodge_left): - """Test defense works after recovery lock expires.""" - await combat_commands.do_defend(player, "", dodge_left) - - # Fast-forward past the lock - player.defense_locked_until = time.monotonic() - 1.0 - player.writer.write.reset_mock() - - await combat_commands.do_defend(player, "", dodge_left) - - messages = [call[0][0] for call in player.writer.write.call_args_list] - # Should succeed, not say "still recovering" - assert not any("still recovering" in msg.lower() for msg in messages) - assert any("dodge left" in msg.lower() for msg in messages) - - -@pytest.mark.asyncio -async def test_defense_lock_uses_timing_window(player, dodge_left, moves): - """Test defense lock duration matches move's timing_window_ms.""" +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_lock = dodge_left.timing_window_ms / 1000.0 - # Lock should be roughly now + timing_window - assert player.defense_locked_until >= before + expected_lock - 0.1 - assert player.defense_locked_until <= before + expected_lock + 0.5 + 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 applies lock.""" + """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) @@ -387,7 +357,9 @@ async def test_defense_in_combat_queues_on_encounter( encounter = get_encounter(target) assert encounter is not None assert encounter.pending_defense is dodge_left - assert target.defense_locked_until > 0 + # 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