diff --git a/content/commands/rest.toml b/content/commands/rest.toml new file mode 100644 index 0000000..ef89096 --- /dev/null +++ b/content/commands/rest.toml @@ -0,0 +1,4 @@ +name = "rest" +help = "restore stamina by resting" +mode = "normal" +handler = "mudlib.commands.rest:cmd_rest" diff --git a/src/mudlib/commands/rest.py b/src/mudlib/commands/rest.py new file mode 100644 index 0000000..71d3ef2 --- /dev/null +++ b/src/mudlib/commands/rest.py @@ -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" + ) diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index a5dedd6..ae04ba1 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -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.""" diff --git a/src/mudlib/resting.py b/src/mudlib/resting.py new file mode 100644 index 0000000..df618d6 --- /dev/null +++ b/src/mudlib/resting.py @@ -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" + ) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 52ec5f8..642bab8 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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() diff --git a/tests/test_rest.py b/tests/test_rest.py new file mode 100644 index 0000000..40a5407 --- /dev/null +++ b/tests/test_rest.py @@ -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