Add rest command for stamina recovery

This commit is contained in:
Jared Miller 2026-02-08 22:16:47 -05:00
parent 0f7404cb12
commit f36085c921
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
6 changed files with 257 additions and 0 deletions

View file

@ -0,0 +1,4 @@
name = "rest"
help = "restore stamina by resting"
mode = "normal"
handler = "mudlib.commands.rest:cmd_rest"

View file

@ -0,0 +1,33 @@
"""Rest command for restoring stamina."""
from mudlib.commands.movement import send_nearby_message
from mudlib.player import Player
async def cmd_rest(player: Player, args: str) -> None:
"""Toggle resting state to restore stamina over time.
Cannot rest if stamina is already full.
Broadcasts to nearby players when beginning and ending rest.
Stamina restoration happens via the game loop while resting.
"""
# Check if stamina is already full
if player.stamina >= player.max_stamina:
await player.send("You're not tired.\r\n")
return
# Toggle resting state
if player.resting:
# Stop resting
player.resting = False
await player.send("You stop resting.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} stops resting.\r\n"
)
else:
# Start resting
player.resting = True
await player.send("You begin to rest.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} begins to rest.\r\n"
)

View file

@ -15,6 +15,7 @@ class Entity:
stamina: float = 100.0 # current stamina
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
async def send(self, message: str) -> None:
"""Send a message to this entity. Base implementation is a no-op."""

31
src/mudlib/resting.py Normal file
View file

@ -0,0 +1,31 @@
"""Resting system for stamina regeneration."""
from mudlib.commands.movement import send_nearby_message
from mudlib.player import players
# Stamina regeneration rate: 2.0 per second
# At 10 ticks/sec, that's 0.2 per tick
STAMINA_PER_TICK = 0.2
async def process_resting() -> None:
"""Process stamina regeneration for all resting players.
Called once per game loop tick (10 times per second).
Adds STAMINA_PER_TICK stamina to each resting player.
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)
# Check if we reached max stamina
if player.stamina >= player.max_stamina:
player.resting = False
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"
)

View file

@ -24,6 +24,7 @@ from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.effects import clear_expired
from mudlib.player import Player, players
from mudlib.resting import process_resting
from mudlib.store import (
PlayerData,
account_exists,
@ -64,6 +65,7 @@ async def game_loop() -> None:
t0 = asyncio.get_event_loop().time()
clear_expired()
await process_combat()
await process_resting()
# Periodic auto-save (every 60 seconds)
current_time = time.monotonic()

186
tests/test_rest.py Normal file
View file

@ -0,0 +1,186 @@
"""Tests for rest command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.commands.rest import cmd_rest
from mudlib.player import Player, players
from mudlib.resting import process_resting
@pytest.fixture(autouse=True)
def clear_state():
"""Clear players before and after each test."""
players.clear()
yield
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@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):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def nearby_player(mock_reader, mock_writer):
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.mark.asyncio
async def test_rest_when_full_sends_not_tired_message(player):
"""Test resting when at full stamina sends 'not tired' message."""
player.stamina = 100.0
player.max_stamina = 100.0
await cmd_rest(player, "")
player.writer.write.assert_called_once_with("You're not tired.\r\n")
assert player.stamina == 100.0
assert not player.resting
@pytest.mark.asyncio
async def test_rest_when_not_resting_starts_resting(player):
"""Test rest command when not resting starts the resting state."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = False
await cmd_rest(player, "")
assert player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("begin to rest" in msg for msg in messages)
@pytest.mark.asyncio
async def test_rest_when_already_resting_stops_resting(player):
"""Test rest command when already resting stops the resting state."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = True
await cmd_rest(player, "")
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("stop resting" in msg for msg in messages)
@pytest.mark.asyncio
async def test_rest_broadcasts_begin_to_nearby_players(player, nearby_player):
"""Test resting broadcasts begin message to nearby players."""
player.stamina = 50.0
player.resting = False
await cmd_rest(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku begins to rest" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_rest_broadcasts_stop_to_nearby_players(player, nearby_player):
"""Test stopping rest broadcasts stop message to nearby players."""
player.stamina = 50.0
player.resting = True
await cmd_rest(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku stops resting" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_process_resting_ticks_up_stamina(player):
"""Test process_resting increases stamina for resting players."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = True
await process_resting()
assert player.stamina == 50.2 # 50 + 0.2 per tick
@pytest.mark.asyncio
async def test_process_resting_auto_stops_when_full(player):
"""Test process_resting auto-stops resting when stamina reaches max."""
player.stamina = 99.9
player.max_stamina = 100.0
player.resting = True
await process_resting()
assert player.stamina == 100.0
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("fully rested" in msg for msg in messages)
@pytest.mark.asyncio
async def test_process_resting_broadcasts_when_auto_stopping(player, nearby_player):
"""Test process_resting broadcasts when auto-stopping rest."""
player.stamina = 99.9
player.max_stamina = 100.0
player.resting = True
await process_resting()
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku stops resting" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_process_resting_ignores_non_resting_players(player):
"""Test process_resting doesn't modify stamina for non-resting players."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = False
await process_resting()
assert player.stamina == 50.0 # unchanged
@pytest.mark.asyncio
async def test_stamina_doesnt_exceed_max(player):
"""Test stamina doesn't exceed max_stamina during resting."""
player.stamina = 99.95
player.max_stamina = 100.0
player.resting = True
await process_resting()
assert player.stamina == 100.0 # capped at max