Add sleep command for deep rest recovery
This commit is contained in:
parent
36fcbecc12
commit
d8cd880b61
6 changed files with 292 additions and 6 deletions
5
content/commands/sleep.toml
Normal file
5
content/commands/sleep.toml
Normal 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"]
|
||||||
|
|
@ -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:
|
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.
|
"""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:
|
Args:
|
||||||
entity: The entity who triggered the message (excluded from receiving it)
|
entity: The entity who triggered the message (excluded from receiving it)
|
||||||
x: X coordinate of the location
|
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"
|
assert isinstance(zone, Zone), "Entity must be in a zone to send nearby messages"
|
||||||
for obj in zone.contents_near(x, y, viewport_range):
|
for obj in zone.contents_near(x, y, viewport_range):
|
||||||
if obj is not entity and isinstance(obj, Entity):
|
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)
|
await obj.send(message)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
43
src/mudlib/commands/sleep.py
Normal file
43
src/mudlib/commands/sleep.py
Normal 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"
|
||||||
|
)
|
||||||
|
|
@ -25,13 +25,14 @@ 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
|
||||||
|
sleeping: bool = False # whether this entity is currently sleeping (deep rest)
|
||||||
_last_stamina_cue: float = 1.0 # Last stamina percentage that triggered a cue
|
_last_stamina_cue: float = 1.0 # Last stamina percentage that triggered a cue
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def posture(self) -> str:
|
def posture(self) -> str:
|
||||||
"""Return entity's current posture for room display.
|
"""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)
|
# Unconscious (highest priority)
|
||||||
if self.pl <= 0 or self.stamina <= 0:
|
if self.pl <= 0 or self.stamina <= 0:
|
||||||
|
|
@ -45,6 +46,10 @@ class Entity(Object):
|
||||||
if getattr(self, "flying", False):
|
if getattr(self, "flying", False):
|
||||||
return "flying"
|
return "flying"
|
||||||
|
|
||||||
|
# Sleeping (before resting since sleeping implies resting)
|
||||||
|
if self.sleeping:
|
||||||
|
return "sleeping"
|
||||||
|
|
||||||
# Resting
|
# Resting
|
||||||
if self.resting:
|
if self.resting:
|
||||||
return "resting"
|
return "resting"
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,33 @@ async def process_resting() -> None:
|
||||||
|
|
||||||
Called once per game loop tick (10 times per second).
|
Called once per game loop tick (10 times per second).
|
||||||
Adds STAMINA_PER_TICK stamina to each resting player.
|
Adds STAMINA_PER_TICK stamina to each resting player.
|
||||||
|
Sleeping players get 3x the recovery rate.
|
||||||
Auto-stops resting when stamina reaches max.
|
Auto-stops resting when stamina reaches max.
|
||||||
"""
|
"""
|
||||||
for player in list(players.values()):
|
for player in list(players.values()):
|
||||||
if not player.resting:
|
if not player.resting:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add stamina for this tick
|
# Calculate stamina gain (3x if sleeping)
|
||||||
player.stamina = min(player.stamina + STAMINA_PER_TICK, player.max_stamina)
|
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
|
# Check if we reached max stamina
|
||||||
if player.stamina >= player.max_stamina:
|
if player.stamina >= player.max_stamina:
|
||||||
|
was_sleeping = player.sleeping
|
||||||
player.resting = False
|
player.resting = False
|
||||||
|
player.sleeping = False
|
||||||
send_char_status(player)
|
send_char_status(player)
|
||||||
send_char_vitals(player)
|
send_char_vitals(player)
|
||||||
await player.send("You feel fully rested.\r\n")
|
message = (
|
||||||
await send_nearby_message(
|
"You wake up fully rested.\r\n"
|
||||||
player, player.x, player.y, f"{player.name} stops resting.\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
216
tests/test_sleep.py
Normal 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)
|
||||||
Loading…
Reference in a new issue