From d8cd880b61be3819da1b0cbb42c28dfe9d94214e Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 00:04:51 -0500 Subject: [PATCH] Add sleep command for deep rest recovery --- content/commands/sleep.toml | 5 + src/mudlib/commands/movement.py | 5 + src/mudlib/commands/sleep.py | 43 +++++++ src/mudlib/entity.py | 7 +- src/mudlib/resting.py | 22 +++- tests/test_sleep.py | 216 ++++++++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 content/commands/sleep.toml create mode 100644 src/mudlib/commands/sleep.py create mode 100644 tests/test_sleep.py diff --git a/content/commands/sleep.toml b/content/commands/sleep.toml new file mode 100644 index 0000000..89ed21d --- /dev/null +++ b/content/commands/sleep.toml @@ -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"] diff --git a/src/mudlib/commands/movement.py b/src/mudlib/commands/movement.py index 876611a..0fc4059 100644 --- a/src/mudlib/commands/movement.py +++ b/src/mudlib/commands/movement.py @@ -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) diff --git a/src/mudlib/commands/sleep.py b/src/mudlib/commands/sleep.py new file mode 100644 index 0000000..cd4795f --- /dev/null +++ b/src/mudlib/commands/sleep.py @@ -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" + ) diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index f0bf12e..72575e7 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -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" diff --git a/src/mudlib/resting.py b/src/mudlib/resting.py index 1c0177f..077f1e5 100644 --- a/src/mudlib/resting.py +++ b/src/mudlib/resting.py @@ -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) diff --git a/tests/test_sleep.py b/tests/test_sleep.py new file mode 100644 index 0000000..be7df67 --- /dev/null +++ b/tests/test_sleep.py @@ -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)