Players become unconscious when PL or stamina drops to 0. While unconscious, both stats slowly recover at 0.1 per tick (1.0 per second). When both reach above 0, player regains consciousness with a message. Recovery runs in the main game loop via process_unconscious.
185 lines
5.3 KiB
Python
185 lines
5.3 KiB
Python
"""Tests for the server module."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from mudlib import server
|
|
from mudlib.store import init_db
|
|
from mudlib.world.terrain import World
|
|
from mudlib.zone import Zone
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db():
|
|
"""Create a temporary database for testing."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
|
db_path = f.name
|
|
|
|
init_db(db_path)
|
|
|
|
yield db_path
|
|
|
|
# Cleanup
|
|
os.unlink(db_path)
|
|
|
|
|
|
def test_port_constant():
|
|
assert server.PORT == 6789
|
|
assert isinstance(server.PORT, int)
|
|
|
|
|
|
def test_shell_exists():
|
|
assert callable(server.shell)
|
|
assert asyncio.iscoroutinefunction(server.shell)
|
|
|
|
|
|
def test_run_server_exists():
|
|
assert callable(server.run_server)
|
|
assert asyncio.iscoroutinefunction(server.run_server)
|
|
|
|
|
|
def test_find_passable_start():
|
|
world = World(seed=42, width=100, height=100)
|
|
zone = Zone(
|
|
name="test", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
)
|
|
x, y = server.find_passable_start(zone, 50, 50)
|
|
assert isinstance(x, int)
|
|
assert isinstance(y, int)
|
|
assert zone.is_passable(x, y)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_greets_and_accepts_commands(temp_db):
|
|
world = World(seed=42, width=100, height=100)
|
|
zone = Zone(
|
|
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
)
|
|
server._overworld = zone
|
|
|
|
reader = AsyncMock()
|
|
writer = MagicMock()
|
|
writer.is_closing.side_effect = [False, False, False, True]
|
|
writer.drain = AsyncMock()
|
|
writer.close = MagicMock()
|
|
|
|
readline = "mudlib.server.readline2"
|
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
|
# Simulate: name, create account (y), password, confirm password, look, quit
|
|
mock_readline.side_effect = [
|
|
"TestPlayer",
|
|
"y",
|
|
"password",
|
|
"password",
|
|
"look",
|
|
"quit",
|
|
]
|
|
await server.shell(reader, writer)
|
|
|
|
calls = [str(call) for call in writer.write.call_args_list]
|
|
assert any("Welcome" in call for call in calls)
|
|
assert any("TestPlayer" in call for call in calls)
|
|
writer.close.assert_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_handles_eof():
|
|
world = World(seed=42, width=100, height=100)
|
|
zone = Zone(
|
|
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
)
|
|
server._overworld = zone
|
|
|
|
reader = AsyncMock()
|
|
writer = MagicMock()
|
|
writer.is_closing.return_value = False
|
|
writer.drain = AsyncMock()
|
|
writer.close = MagicMock()
|
|
|
|
readline = "mudlib.server.readline2"
|
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
|
mock_readline.return_value = None
|
|
await server.shell(reader, writer)
|
|
|
|
writer.close.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_handles_quit(temp_db):
|
|
world = World(seed=42, width=100, height=100)
|
|
zone = Zone(
|
|
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
)
|
|
server._overworld = zone
|
|
|
|
reader = AsyncMock()
|
|
writer = MagicMock()
|
|
writer.is_closing.side_effect = [False, False, True]
|
|
writer.drain = AsyncMock()
|
|
writer.close = MagicMock()
|
|
|
|
readline = "mudlib.server.readline2"
|
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
|
# Simulate: name, create account (y), password, confirm password, quit
|
|
mock_readline.side_effect = [
|
|
"TestPlayer",
|
|
"y",
|
|
"password",
|
|
"password",
|
|
"quit",
|
|
]
|
|
await server.shell(reader, writer)
|
|
|
|
calls = [str(call) for call in writer.write.call_args_list]
|
|
assert any("Goodbye" in call for call in calls)
|
|
writer.close.assert_called()
|
|
|
|
|
|
def test_load_world_config():
|
|
"""Config loader returns expected values from worlds/earth/config.toml."""
|
|
config = server.load_world_config()
|
|
assert config["world"]["seed"] == 42
|
|
assert config["world"]["width"] == 1000
|
|
assert config["world"]["height"] == 1000
|
|
|
|
|
|
def test_load_world_config_missing():
|
|
"""Config loader raises FileNotFoundError for nonexistent world."""
|
|
with pytest.raises(FileNotFoundError):
|
|
server.load_world_config("nonexistent")
|
|
|
|
|
|
def test_tick_constants():
|
|
"""Tick rate and interval are configured correctly."""
|
|
assert server.TICK_RATE == 10
|
|
assert pytest.approx(0.1) == server.TICK_INTERVAL
|
|
|
|
|
|
def test_game_loop_exists():
|
|
"""Game loop is an async callable."""
|
|
assert callable(server.game_loop)
|
|
assert asyncio.iscoroutinefunction(server.game_loop)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_game_loop_calls_clear_expired():
|
|
"""Game loop calls clear_expired each tick."""
|
|
with (
|
|
patch("mudlib.server.clear_expired") as mock_clear,
|
|
patch("mudlib.server.process_combat", new_callable=AsyncMock),
|
|
patch("mudlib.server.process_mobs", new_callable=AsyncMock),
|
|
patch("mudlib.server.process_resting", new_callable=AsyncMock),
|
|
patch("mudlib.server.process_unconscious", new_callable=AsyncMock),
|
|
):
|
|
task = asyncio.create_task(server.game_loop())
|
|
await asyncio.sleep(0.25)
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert mock_clear.call_count >= 1
|