"""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 test_zone(): """Override conftest test_zone with smaller zone for stamina tests.""" return Zone(name="test", width=10, height=10) @pytest.fixture def player(mock_writer, test_zone): """Override conftest player with custom position and stats for stamina tests.""" 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): """Defender fixture with custom position for stamina tests.""" 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, hit_time_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 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 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, hit_time_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 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, active_ms=100, recovery_ms=2700, ) player.stamina = 100.0 player.max_stamina = 100.0 with patch("mudlib.combat.commands.check_stamina_cues") as mock_check: # Use a short active window to avoid blocking too long move.active_ms = 10 move.recovery_ms = 10 await do_defend(player, "", move) # check_stamina_cues should have been called 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