Add visible stamina cue broadcasts
This commit is contained in:
parent
4e8459df5f
commit
47534b1514
3 changed files with 310 additions and 0 deletions
72
src/mudlib/combat/stamina.py
Normal file
72
src/mudlib/combat/stamina.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -25,6 +25,7 @@ class Entity(Object):
|
||||||
max_stamina: float = 100.0 # stamina ceiling
|
max_stamina: float = 100.0 # stamina ceiling
|
||||||
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
||||||
resting: bool = False # whether this entity is currently resting
|
resting: bool = False # whether this entity is currently resting
|
||||||
|
_last_stamina_cue: float = 1.0 # Last stamina percentage that triggered a cue
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def posture(self) -> str:
|
def posture(self) -> str:
|
||||||
|
|
|
||||||
237
tests/test_stamina_cues.py
Normal file
237
tests/test_stamina_cues.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue