From e368ed184340fddf4418ae85a6b6aa9acf70ba2a Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 11:37:09 -0500 Subject: [PATCH] Add defense commitment lock and defense-everywhere support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- src/mudlib/combat/commands.py | 38 ++++++++--- src/mudlib/combat/encounter.py | 7 +- src/mudlib/entity.py | 1 + tests/test_combat_commands.py | 120 +++++++++++++++++++++++++++------ tests/test_combat_encounter.py | 6 +- tests/test_entity.py | 1 + 6 files changed, 138 insertions(+), 35 deletions(-) diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 4a5e693..57190c4 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -1,5 +1,6 @@ """Combat command handlers.""" +import time from collections import defaultdict from pathlib import Path @@ -81,15 +82,19 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: 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") + 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 @@ -97,9 +102,26 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None: 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") + # 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 + 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.name}s!\r\n", + ) + + await player.send(f"You {move.name}!\r\n") def _make_direct_handler(move: CombatMove, handler_fn): @@ -174,7 +196,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( diff --git a/src/mudlib/combat/encounter.py b/src/mudlib/combat/encounter.py index e65413d..e064853 100644 --- a/src/mudlib/combat/encounter.py +++ b/src/mudlib/combat/encounter.py @@ -69,16 +69,15 @@ class CombatEncounter: 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 - def tick(self, now: float) -> None: """Advance the state machine based on current time. diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index b0a4311..a5dedd6 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -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.""" diff --git a/tests/test_combat_commands.py b/tests/test_combat_commands.py index 263e57f..33ad69a 100644 --- a/tests/test_combat_commands.py +++ b/tests/test_combat_commands.py @@ -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,23 @@ 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, applies lock).""" + 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 + assert player.defense_locked_until > 0 @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 +193,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 --- @@ -320,3 +328,75 @@ async def test_switch_attack_sends_new_telegraph( 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 + + +# --- defense commitment tests --- + + +@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.""" + before = time.monotonic() + await combat_commands.do_defend(player, "", dodge_left) + + 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 + + +@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.""" + 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 + assert target.defense_locked_until > 0 + + +@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) diff --git a/tests/test_combat_encounter.py b/tests/test_combat_encounter.py index 7b96466..643de2a 100644 --- a/tests/test_combat_encounter.py +++ b/tests/test_combat_encounter.py @@ -100,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): diff --git a/tests/test_entity.py b/tests/test_entity.py index 0699b08..415baa9 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -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():