Add sleep command for deep rest recovery

This commit is contained in:
Jared Miller 2026-02-14 00:04:51 -05:00
parent 36fcbecc12
commit d8cd880b61
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
6 changed files with 292 additions and 6 deletions

View file

@ -0,0 +1,5 @@
name = "sleep"
help = "fall asleep for fastest stamina recovery (3x rest rate)"
mode = "normal"
handler = "mudlib.commands.sleep:cmd_sleep"
aliases = ["wake"]

View file

@ -134,6 +134,8 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> None:
"""Send a message to all players near a location, excluding the entity.
Sleeping players do not receive nearby messages (they are blind to room events).
Args:
entity: The entity who triggered the message (excluded from receiving it)
x: X coordinate of the location
@ -147,6 +149,9 @@ async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> N
assert isinstance(zone, Zone), "Entity must be in a zone to send nearby messages"
for obj in zone.contents_near(x, y, viewport_range):
if obj is not entity and isinstance(obj, Entity):
# Skip sleeping players (they are blind to room events)
if getattr(obj, "sleeping", False):
continue
await obj.send(message)

View file

@ -0,0 +1,43 @@
"""Sleep command for fastest stamina recovery."""
from mudlib.commands.movement import send_nearby_message
from mudlib.gmcp import send_char_status
from mudlib.player import Player
async def cmd_sleep(player: Player, args: str) -> None:
"""Toggle sleeping state for deep rest and fastest stamina recovery.
Cannot sleep if stamina is already full or if in combat.
While sleeping, players are blind to nearby events but recover stamina 3x faster.
Broadcasts to nearby players when falling asleep and waking up.
"""
# Check if in combat
if player.mode == "combat":
await player.send("You can't sleep in the middle of combat!\r\n")
return
# Check if stamina is already full
if player.stamina >= player.max_stamina:
await player.send("You're not tired.\r\n")
return
# Toggle sleeping state
if player.sleeping:
# Wake up
player.sleeping = False
player.resting = False
send_char_status(player)
await player.send("You wake up.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} wakes up.\r\n"
)
else:
# Fall asleep
player.sleeping = True
player.resting = True # sleeping implies resting
send_char_status(player)
await player.send("You fall asleep.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} falls asleep.\r\n"
)

View file

@ -25,13 +25,14 @@ 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
sleeping: bool = False # whether this entity is currently sleeping (deep rest)
_last_stamina_cue: float = 1.0 # Last stamina percentage that triggered a cue
@property
def posture(self) -> str:
"""Return entity's current posture for room display.
Priority order: unconscious > fighting > flying > resting > standing
Priority order: unconscious > fighting > flying > sleeping > resting > standing
"""
# Unconscious (highest priority)
if self.pl <= 0 or self.stamina <= 0:
@ -45,6 +46,10 @@ class Entity(Object):
if getattr(self, "flying", False):
return "flying"
# Sleeping (before resting since sleeping implies resting)
if self.sleeping:
return "sleeping"
# Resting
if self.resting:
return "resting"

View file

@ -14,21 +14,33 @@ async def process_resting() -> None:
Called once per game loop tick (10 times per second).
Adds STAMINA_PER_TICK stamina to each resting player.
Sleeping players get 3x the recovery rate.
Auto-stops resting when stamina reaches max.
"""
for player in list(players.values()):
if not player.resting:
continue
# Add stamina for this tick
player.stamina = min(player.stamina + STAMINA_PER_TICK, player.max_stamina)
# Calculate stamina gain (3x if sleeping)
stamina_gain = STAMINA_PER_TICK * (3 if player.sleeping else 1)
player.stamina = min(player.stamina + stamina_gain, player.max_stamina)
# Check if we reached max stamina
if player.stamina >= player.max_stamina:
was_sleeping = player.sleeping
player.resting = False
player.sleeping = False
send_char_status(player)
send_char_vitals(player)
await player.send("You feel fully rested.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} stops resting.\r\n"
message = (
"You wake up fully rested.\r\n"
if was_sleeping
else "You feel fully rested.\r\n"
)
await player.send(message)
nearby_message = (
f"{player.name} wakes up.\r\n"
if was_sleeping
else f"{player.name} stops resting.\r\n"
)
await send_nearby_message(player, player.x, player.y, nearby_message)

216
tests/test_sleep.py Normal file
View file

@ -0,0 +1,216 @@
"""Tests for sleep command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.sleep import cmd_sleep
from mudlib.player import Player, players
from mudlib.resting import STAMINA_PER_TICK, process_resting
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear players before and after each test."""
players.clear()
yield
players.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.fixture
def nearby_player(mock_reader, mock_writer, test_zone):
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.mark.asyncio
async def test_sleep_when_full_sends_not_tired_message(player):
"""Test sleeping when at full stamina sends 'not tired' message."""
player.stamina = 100.0
player.max_stamina = 100.0
await cmd_sleep(player, "")
player.writer.write.assert_called_once_with("You're not tired.\r\n")
assert player.stamina == 100.0
assert not player.sleeping
assert not player.resting
@pytest.mark.asyncio
async def test_sleep_when_not_sleeping_starts_sleeping(player):
"""Test sleep command when not sleeping starts the sleeping state."""
player.stamina = 50.0
player.max_stamina = 100.0
player.sleeping = False
player.resting = False
await cmd_sleep(player, "")
assert player.sleeping
assert player.resting # sleeping implies resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("fall asleep" in msg or "go to sleep" in msg for msg in messages)
@pytest.mark.asyncio
async def test_sleep_when_already_sleeping_wakes_up(player):
"""Test sleep command when already sleeping wakes the player."""
player.stamina = 50.0
player.max_stamina = 100.0
player.sleeping = True
player.resting = True
await cmd_sleep(player, "")
assert not player.sleeping
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("wake up" in msg or "wake" in msg for msg in messages)
@pytest.mark.asyncio
async def test_sleep_blocked_during_combat(player):
"""Test sleep is blocked when player is in combat mode."""
player.stamina = 50.0
player.mode_stack.append("combat") # Push combat mode onto the stack
await cmd_sleep(player, "")
assert not player.sleeping
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any(
"can't sleep" in msg.lower() or "combat" in msg.lower() for msg in messages
)
@pytest.mark.asyncio
async def test_sleep_broadcasts_to_nearby_players(player, nearby_player):
"""Test sleeping broadcasts message to nearby players."""
player.stamina = 50.0
player.sleeping = False
await cmd_sleep(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any(
"Goku" in msg and ("asleep" in msg or "sleep" in msg) for msg in nearby_messages
)
@pytest.mark.asyncio
async def test_wake_broadcasts_to_nearby_players(player, nearby_player):
"""Test waking up broadcasts message to nearby players."""
player.stamina = 50.0
player.sleeping = True
player.resting = True
await cmd_sleep(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku" in msg and "wake" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_sleep_stamina_recovery_faster_than_rest(player):
"""Test sleeping provides faster stamina recovery than resting."""
player.stamina = 50.0
player.max_stamina = 100.0
player.sleeping = True
player.resting = True
await process_resting()
# Sleep should give 3x the base rate (0.2 * 3 = 0.6)
expected = 50.0 + (STAMINA_PER_TICK * 3)
assert player.stamina == expected
@pytest.mark.asyncio
async def test_sleep_auto_stops_when_full(player):
"""Test sleep auto-stops when stamina reaches max."""
player.stamina = 99.5
player.max_stamina = 100.0
player.sleeping = True
player.resting = True
await process_resting()
assert player.stamina == 100.0
assert not player.sleeping
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("fully rested" in msg or "wake" in msg for msg in messages)
@pytest.mark.asyncio
async def test_sleeping_player_does_not_see_nearby_messages(player, nearby_player):
"""Test sleeping players don't receive nearby messages."""
from mudlib.commands.movement import send_nearby_message
player.sleeping = True
# Nearby player does something that would normally broadcast
await send_nearby_message(
nearby_player, nearby_player.x, nearby_player.y, "Test message.\r\n"
)
# Sleeping player should not have received it
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert len(messages) == 0
@pytest.mark.asyncio
async def test_awake_player_sees_nearby_messages(player, nearby_player):
"""Test awake players receive nearby messages normally."""
from mudlib.commands.movement import send_nearby_message
player.sleeping = False
await send_nearby_message(
nearby_player, nearby_player.x, nearby_player.y, "Test message.\r\n"
)
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("Test message" in msg for msg in messages)