diff --git a/src/mudlib/combat/stamina.py b/src/mudlib/combat/stamina.py new file mode 100644 index 0000000..82e1319 --- /dev/null +++ b/src/mudlib/combat/stamina.py @@ -0,0 +1,72 @@ +"""Stamina cue broadcasts for combat strain visibility.""" + +from mudlib.commands.movement import send_nearby_message +from mudlib.entity import Entity + + +async def check_stamina_cues(entity: Entity) -> None: + """Broadcast stamina strain messages when thresholds are crossed. + + Thresholds (escalating severity): + - Below 75%: breathing heavily + - Below 50%: drenched in sweat + - Below 25%: visibly shaking from exhaustion + - Below 10%: can barely stand + + Each threshold triggers only ONCE per descent. Track the last triggered + threshold to prevent spam. + + Args: + entity: The entity to check stamina for + """ + if entity.max_stamina == 0: + return + + stamina_pct = entity.stamina / entity.max_stamina + + # Define thresholds from lowest to highest (check in reverse order) + thresholds = [ + ( + 0.10, + "You can barely stand.", + f"{entity.name} can barely stand.", + ), + ( + 0.25, + "You're visibly shaking from exhaustion.", + f"{entity.name} is visibly shaking from exhaustion.", + ), + ( + 0.50, + "You're drenched in sweat.", + f"{entity.name} is drenched in sweat.", + ), + ( + 0.75, + "You're breathing heavily.", + f"{entity.name} is breathing heavily.", + ), + ] + + # Find the current threshold (highest threshold we're below) + current_threshold = None + self_msg = None + nearby_msg = None + + for threshold, self_text, nearby_text in thresholds: + if stamina_pct < threshold: + current_threshold = threshold + self_msg = self_text + nearby_msg = nearby_text + break + + # If we found a threshold and it's lower than the last triggered one + if current_threshold is not None and current_threshold < entity._last_stamina_cue: + # 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") + + # Update tracking + entity._last_stamina_cue = current_threshold diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index 43aa27e..f0bf12e 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -25,6 +25,7 @@ class Entity(Object): max_stamina: float = 100.0 # stamina ceiling defense_locked_until: float = 0.0 # monotonic time when defense recovery ends resting: bool = False # whether this entity is currently resting + _last_stamina_cue: float = 1.0 # Last stamina percentage that triggered a cue @property def posture(self) -> str: diff --git a/tests/test_stamina_cues.py b/tests/test_stamina_cues.py new file mode 100644 index 0000000..0f721c4 --- /dev/null +++ b/tests/test_stamina_cues.py @@ -0,0 +1,237 @@ +"""Tests for stamina cue broadcasts.""" + +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 nearby_player(test_zone): + """A nearby player to receive broadcasts.""" + 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_no_cue_above_75_percent(player): + """No cue when stamina is above 75%.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 80.0 + player.max_stamina = 100.0 + + await check_stamina_cues(player) + + # No message sent to player + player.writer.write.assert_not_called() + + +@pytest.mark.asyncio +async def test_breathing_heavily_below_75_percent(player): + """'Breathing heavily' cue when stamina drops below 75%.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 70.0 + player.max_stamina = 100.0 + + await check_stamina_cues(player) + + # Check self-directed message + calls = [call[0][0] for call in player.writer.write.call_args_list] + assert any("You're breathing heavily." in msg for msg in calls) + + +@pytest.mark.asyncio +async def test_drenched_in_sweat_below_50_percent(player): + """'Drenched in sweat' cue when below 50%.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 45.0 + player.max_stamina = 100.0 + + await check_stamina_cues(player) + + calls = [call[0][0] for call in player.writer.write.call_args_list] + assert any("You're drenched in sweat." in msg for msg in calls) + + +@pytest.mark.asyncio +async def test_visibly_shaking_below_25_percent(player): + """'Visibly shaking' cue when below 25%.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 20.0 + player.max_stamina = 100.0 + + await check_stamina_cues(player) + + calls = [call[0][0] for call in player.writer.write.call_args_list] + assert any("You're visibly shaking from exhaustion." in msg for msg in calls) + + +@pytest.mark.asyncio +async def test_can_barely_stand_below_10_percent(player): + """'Can barely stand' cue when below 10%.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 8.0 + player.max_stamina = 100.0 + + await check_stamina_cues(player) + + calls = [call[0][0] for call in player.writer.write.call_args_list] + assert any("You can barely stand." in msg for msg in calls) + + +@pytest.mark.asyncio +async def test_same_threshold_not_triggered_twice(player): + """Same threshold doesn't trigger twice (spam prevention).""" + from mudlib.combat.stamina import check_stamina_cues + + # First trigger at 70% + player.stamina = 70.0 + player.max_stamina = 100.0 + await check_stamina_cues(player) + first_call_count = player.writer.write.call_count + + # Second trigger at same threshold (still 70%) + player.stamina = 70.0 + await check_stamina_cues(player) + second_call_count = player.writer.write.call_count + + # No new messages should have been sent + assert second_call_count == first_call_count + + +@pytest.mark.asyncio +async def test_higher_threshold_doesnt_retrigger(player): + """Higher threshold doesn't trigger if already at lower.""" + from mudlib.combat.stamina import check_stamina_cues + + # Drop to 20% first + player.stamina = 20.0 + player.max_stamina = 100.0 + await check_stamina_cues(player) + player.writer.write.reset_mock() + + # Recover to 30% (back into 25-50% range) + player.stamina = 30.0 + await check_stamina_cues(player) + + # No new cue should trigger + player.writer.write.assert_not_called() + + +@pytest.mark.asyncio +async def test_self_directed_message_sent(player): + """Self-directed message sent to the entity.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 70.0 + player.max_stamina = 100.0 + + await check_stamina_cues(player) + + # Verify self message was sent + calls = [call[0][0] for call in player.writer.write.call_args_list] + assert any("You're breathing heavily." in msg for msg in calls) + + +@pytest.mark.asyncio +async def test_nearby_broadcast_sent(player, nearby_player): + """Nearby broadcast sent to other players.""" + from mudlib.combat.stamina import check_stamina_cues + + player.stamina = 70.0 + player.max_stamina = 100.0 + + with patch("mudlib.combat.stamina.send_nearby_message") as mock_nearby: + await check_stamina_cues(player) + + # Verify send_nearby_message was called + mock_nearby.assert_called_once() + args = mock_nearby.call_args[0] + assert args[0] is player + assert args[1] == 5 # x coordinate + assert args[2] == 5 # y coordinate + assert "Goku is breathing heavily." in args[3] + + +@pytest.mark.asyncio +async def test_descending_thresholds_trigger_each_once(player): + """Descending through thresholds triggers each once.""" + from mudlib.combat.stamina import check_stamina_cues + + player.max_stamina = 100.0 + + # Start at 80%, no cue + player.stamina = 80.0 + await check_stamina_cues(player) + count_80 = player.writer.write.call_count + + # Drop to 70%, should trigger 75% threshold + player.stamina = 70.0 + await check_stamina_cues(player) + count_70 = player.writer.write.call_count + assert count_70 > count_80 + + # Drop to 40%, should trigger 50% threshold + player.stamina = 40.0 + await check_stamina_cues(player) + count_40 = player.writer.write.call_count + assert count_40 > count_70 + + # Drop to 20%, should trigger 25% threshold + player.stamina = 20.0 + await check_stamina_cues(player) + count_20 = player.writer.write.call_count + assert count_20 > count_40 + + # Drop to 5%, should trigger 10% threshold + player.stamina = 5.0 + await check_stamina_cues(player) + count_5 = player.writer.write.call_count + assert count_5 > count_20