Add rest command for stamina recovery
This commit is contained in:
parent
0f7404cb12
commit
f36085c921
6 changed files with 257 additions and 0 deletions
4
content/commands/rest.toml
Normal file
4
content/commands/rest.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "rest"
|
||||||
|
help = "restore stamina by resting"
|
||||||
|
mode = "normal"
|
||||||
|
handler = "mudlib.commands.rest:cmd_rest"
|
||||||
33
src/mudlib/commands/rest.py
Normal file
33
src/mudlib/commands/rest.py
Normal 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"
|
||||||
|
)
|
||||||
|
|
@ -15,6 +15,7 @@ class Entity:
|
||||||
stamina: float = 100.0 # current stamina
|
stamina: float = 100.0 # current stamina
|
||||||
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
|
||||||
|
|
||||||
async def send(self, message: str) -> None:
|
async def send(self, message: str) -> None:
|
||||||
"""Send a message to this entity. Base implementation is a no-op."""
|
"""Send a message to this entity. Base implementation is a no-op."""
|
||||||
|
|
|
||||||
31
src/mudlib/resting.py
Normal file
31
src/mudlib/resting.py
Normal 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"
|
||||||
|
)
|
||||||
|
|
@ -24,6 +24,7 @@ from mudlib.combat.engine import process_combat
|
||||||
from mudlib.content import load_commands
|
from mudlib.content import load_commands
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.resting import process_resting
|
||||||
from mudlib.store import (
|
from mudlib.store import (
|
||||||
PlayerData,
|
PlayerData,
|
||||||
account_exists,
|
account_exists,
|
||||||
|
|
@ -64,6 +65,7 @@ async def game_loop() -> None:
|
||||||
t0 = asyncio.get_event_loop().time()
|
t0 = asyncio.get_event_loop().time()
|
||||||
clear_expired()
|
clear_expired()
|
||||||
await process_combat()
|
await process_combat()
|
||||||
|
await process_resting()
|
||||||
|
|
||||||
# Periodic auto-save (every 60 seconds)
|
# Periodic auto-save (every 60 seconds)
|
||||||
current_time = time.monotonic()
|
current_time = time.monotonic()
|
||||||
|
|
|
||||||
186
tests/test_rest.py
Normal file
186
tests/test_rest.py
Normal 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
|
||||||
Loading…
Reference in a new issue