diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 5de2f41..e1bb261 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -7,6 +7,7 @@ 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.combat.stamina import check_stamina_cues from mudlib.commands import CommandDefinition, register from mudlib.player import Player, players from mudlib.render.colors import colorize @@ -107,6 +108,9 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: send_char_vitals(player) + # Check stamina cues after attack cost + await check_stamina_cues(player) + if switching: await player.send(f"You switch to {move.name}!\r\n") else: @@ -136,6 +140,9 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None: send_char_vitals(player) + # Check stamina cues after defense cost + await check_stamina_cues(player) + # If in combat, queue the defense on the encounter encounter = get_encounter(player) if encounter is not None: diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 8b575cd..39a8953 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -3,6 +3,7 @@ import time from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState +from mudlib.combat.stamina import check_stamina_cues from mudlib.entity import Entity, Mob from mudlib.gmcp import send_char_status, send_char_vitals from mudlib.render.colors import colorize @@ -154,6 +155,10 @@ async def process_combat() -> None: if isinstance(entity, Player): send_char_vitals(entity) + # Check stamina cues after damage + await check_stamina_cues(encounter.attacker) + await check_stamina_cues(encounter.defender) + if result.combat_ended: # Determine winner/loser if encounter.defender.pl <= 0: diff --git a/src/mudlib/combat/stamina.py b/src/mudlib/combat/stamina.py index 82e1319..f69ef71 100644 --- a/src/mudlib/combat/stamina.py +++ b/src/mudlib/combat/stamina.py @@ -65,8 +65,9 @@ async def check_stamina_cues(entity: Entity) -> None: # Send self-directed message await entity.send(f"{self_msg}\r\n") - # Broadcast to nearby players - await send_nearby_message(entity, entity.x, entity.y, f"{nearby_msg}\r\n") + # Broadcast to nearby players (only if entity has a location) + if entity.location is not None: + await send_nearby_message(entity, entity.x, entity.y, f"{nearby_msg}\r\n") # Update tracking entity._last_stamina_cue = current_threshold diff --git a/src/mudlib/commands/power.py b/src/mudlib/commands/power.py index 7c86c97..02874f3 100644 --- a/src/mudlib/commands/power.py +++ b/src/mudlib/commands/power.py @@ -3,6 +3,7 @@ import asyncio from mudlib.combat.engine import get_encounter +from mudlib.combat.stamina import check_stamina_cues from mudlib.commands import CommandDefinition, register from mudlib.commands.movement import send_nearby_message from mudlib.gmcp import send_char_vitals @@ -57,6 +58,9 @@ async def power_up_loop(player: Player, target_pl: float | None = None) -> None: # Send GMCP update send_char_vitals(player) + # Check stamina cues after deduction + await check_stamina_cues(player) + # Send periodic aura message (every ~0.2s) if int(old_pl / 20) != int(player.pl / 20): await player.send("Your aura flares!\r\n") diff --git a/src/mudlib/resting.py b/src/mudlib/resting.py index 077f1e5..fc06114 100644 --- a/src/mudlib/resting.py +++ b/src/mudlib/resting.py @@ -30,6 +30,7 @@ async def process_resting() -> None: was_sleeping = player.sleeping player.resting = False player.sleeping = False + player._last_stamina_cue = 1.0 # Reset stamina cues on full recovery send_char_status(player) send_char_vitals(player) message = ( diff --git a/src/mudlib/unconscious.py b/src/mudlib/unconscious.py index aa6952d..f1df5c4 100644 --- a/src/mudlib/unconscious.py +++ b/src/mudlib/unconscious.py @@ -34,6 +34,7 @@ async def process_unconscious() -> None: # Check if player is now conscious if was_unconscious and player.pl > 0 and player.stamina > 0: # Player regained consciousness + player._last_stamina_cue = 1.0 # Reset stamina cues on recovery send_char_status(player) send_char_vitals(player) await player.send("You come to.\r\n") diff --git a/tests/test_stamina_cue_wiring.py b/tests/test_stamina_cue_wiring.py new file mode 100644 index 0000000..a1a816a --- /dev/null +++ b/tests/test_stamina_cue_wiring.py @@ -0,0 +1,287 @@ +"""Tests for stamina cue system wiring and reset behavior.""" + +import asyncio +import contextlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mudlib.player import Player +from mudlib.zone import Zone + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def test_zone(): + return Zone(name="test", width=10, height=10) + + +@pytest.fixture +def player(mock_writer, test_zone): + p = Player( + name="Goku", + x=5, + y=5, + pl=100.0, + stamina=100.0, + max_stamina=100.0, + writer=mock_writer, + ) + p.move_to(test_zone, x=5, y=5) + return p + + +@pytest.fixture +def defender(test_zone): + w = MagicMock() + w.write = MagicMock() + w.drain = AsyncMock() + p = Player( + name="Vegeta", + x=5, + y=5, + pl=100.0, + stamina=100.0, + max_stamina=100.0, + writer=w, + ) + p.move_to(test_zone, x=5, y=5) + return p + + +@pytest.mark.asyncio +async def test_stamina_cue_after_combat_damage(player, defender): + """check_stamina_cues is called after damage in combat resolution.""" + import time + + from mudlib.combat.encounter import CombatState + from mudlib.combat.engine import active_encounters, start_encounter + from mudlib.combat.moves import CombatMove + + # Clear encounters + active_encounters.clear() + + # Create a move that deals damage + move = CombatMove( + name="weak punch", + command="weak", + move_type="attack", + damage_pct=0.5, + stamina_cost=5.0, + timing_window_ms=100, + ) + + # Start encounter + encounter = start_encounter(player, defender) + encounter.attack(move) + + # Fast-forward to RESOLVE state + encounter.state = CombatState.RESOLVE + encounter.move_started_at = time.monotonic() + + # Patch check_stamina_cues to verify it's called + with patch("mudlib.combat.engine.check_stamina_cues") as mock_check: + from mudlib.combat.engine import process_combat + + await process_combat() + + # Should be called for both entities after damage + assert mock_check.called + calls = [call[0][0] for call in mock_check.call_args_list] + assert player in calls + assert defender in calls + + +@pytest.mark.asyncio +async def test_stamina_cue_after_power_up_deduction(player): + """check_stamina_cues is called during power-up stamina deduction.""" + from mudlib.commands.power import power_up_loop + + player.stamina = 100.0 + player.max_stamina = 100.0 + player.pl = 10.0 + player.max_pl = 50.0 + + with patch("mudlib.commands.power.check_stamina_cues") as mock_check: + # Run power-up for a short time + task = asyncio.create_task(power_up_loop(player, target_pl=20.0)) + await asyncio.sleep(0.15) # Let a few ticks happen + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + # check_stamina_cues should have been called at least once + assert mock_check.called + # Verify player was the argument + calls = [call[0][0] for call in mock_check.call_args_list] + assert player in calls + + +@pytest.mark.asyncio +async def test_stamina_cue_after_attack_cost(player, defender): + """check_stamina_cues is called after attack stamina cost deduction.""" + from mudlib.combat.commands import do_attack + from mudlib.combat.engine import active_encounters + from mudlib.combat.moves import CombatMove + from mudlib.player import players + + # Clear encounters + active_encounters.clear() + + # Register defender in global players dict so do_attack can find it + players["Vegeta"] = defender + + try: + # Create a move with high stamina cost + move = CombatMove( + name="power punch", + command="power", + move_type="attack", + damage_pct=0.3, + stamina_cost=60.0, + timing_window_ms=1000, + ) + + player.stamina = 100.0 + player.max_stamina = 100.0 + + with patch("mudlib.combat.commands.check_stamina_cues") as mock_check: + await do_attack(player, "Vegeta", move) + + # check_stamina_cues should have been called + assert mock_check.called + # Verify player was the argument + calls = [call[0][0] for call in mock_check.call_args_list] + assert player in calls + finally: + players.clear() + + +@pytest.mark.asyncio +async def test_stamina_cue_after_defense_cost(player): + """check_stamina_cues is called after defense stamina cost deduction.""" + from mudlib.combat.commands import do_defend + from mudlib.combat.moves import CombatMove + + # Create a defense move with stamina cost + move = CombatMove( + name="duck", + command="duck", + move_type="defense", + stamina_cost=30.0, + timing_window_ms=100, + ) + + player.stamina = 100.0 + player.max_stamina = 100.0 + + with patch("mudlib.combat.commands.check_stamina_cues") as mock_check: + # Use a short timing window to avoid blocking too long + move.timing_window_ms = 10 + await do_defend(player, "", move) + + # check_stamina_cues should have been called + assert mock_check.called + # Verify player was the argument + calls = [call[0][0] for call in mock_check.call_args_list] + assert player in calls + + +@pytest.mark.asyncio +async def test_stamina_cue_reset_on_resting_recovery(player): + """_last_stamina_cue resets to 1.0 when stamina fully recovers via resting.""" + from mudlib.combat.stamina import check_stamina_cues + from mudlib.player import players + from mudlib.resting import process_resting + + # Register player in global dict so process_resting can find them + players[player.name] = player + + try: + # Drop stamina to trigger a cue + player.stamina = 20.0 + player.max_stamina = 100.0 + await check_stamina_cues(player) + assert player._last_stamina_cue == 0.25 # Below 25% threshold + + # Start resting and set stamina very close to max + player.resting = True + player.stamina = 99.85 # Will be clamped to 100.0 after one tick + + # Process one tick to reach max + await process_resting() + + # Stamina should be at max and _last_stamina_cue should reset + assert player.stamina == 100.0 + assert player._last_stamina_cue == 1.0 + finally: + players.clear() + + +@pytest.mark.asyncio +async def test_stamina_cue_reset_on_unconscious_recovery(player): + """_last_stamina_cue resets to 1.0 when stamina recovers from unconsciousness.""" + from mudlib.combat.stamina import check_stamina_cues + from mudlib.player import players + from mudlib.unconscious import process_unconscious + + # Register player in global dict so process_unconscious can find them + players[player.name] = player + + try: + # Drop stamina to trigger a cue + player.stamina = 5.0 + player.max_stamina = 100.0 + await check_stamina_cues(player) + assert player._last_stamina_cue == 0.10 # Below 10% threshold + + # Drop to unconscious + player.stamina = 0.0 + player.pl = 0.0 + + # Verify player is unconscious + assert player.posture == "unconscious" + + # Process recovery tick to regain consciousness + await process_unconscious() + + # Should have regained consciousness and reset cue + assert player.stamina > 0 + assert player.pl > 0 + assert player._last_stamina_cue == 1.0 + finally: + players.clear() + + +@pytest.mark.asyncio +async def test_cue_fires_again_after_reset(player): + """After reset, cues fire again on next descent.""" + from mudlib.combat.stamina import check_stamina_cues + + player.max_stamina = 100.0 + + # First descent: trigger 75% threshold + player.stamina = 70.0 + player.writer.write.reset_mock() + await check_stamina_cues(player) + first_call_count = player.writer.write.call_count + assert first_call_count > 0 + assert player._last_stamina_cue == 0.75 + + # Recover to max and reset + player.stamina = 100.0 + player._last_stamina_cue = 1.0 + + # Second descent: should fire again at 75% + player.stamina = 70.0 + player.writer.write.reset_mock() + await check_stamina_cues(player) + second_call_count = player.writer.write.call_count + assert second_call_count > 0 # Should fire again