Compare commits

...

21 commits

Author SHA1 Message Date
708985e62a
Add decorative furniture and crafting material templates 2026-02-14 17:58:59 -05:00
11636e073a
Add craft and recipes commands
Implements craft command to create items from recipes by consuming
ingredients from player inventory. Recipes command lists available
recipes or shows details for a specific recipe. Registers commands
and loads recipes at server startup.
2026-02-14 17:58:59 -05:00
7342a70ba2
Add furnish and unfurnish commands 2026-02-14 17:58:47 -05:00
5b6c808050
Add describe command for home zone descriptions
Allows players to set custom descriptions for their home zones.
Only works in the player's own home zone. Saves to TOML file.
2026-02-14 17:58:47 -05:00
5d14011684
Add terrain editing command for home zones 2026-02-14 17:58:30 -05:00
ec43ead568
Add crafting recipe system
Implements Recipe dataclass, recipe loading from TOML files, and recipe
registry. Recipes define ingredients consumed and result produced for
item crafting.
2026-02-14 17:58:30 -05:00
9f760bc3af
Add furniture persistence to home zone TOML 2026-02-14 17:58:30 -05:00
acfff671fe
Wire character creation and housing into server login flow 2026-02-14 17:17:36 -05:00
32c570b777
Update roadmap to split furniture and crafting into phase 18 2026-02-14 17:17:36 -05:00
6229c87945
Add home command for personal zone teleportation 2026-02-14 17:17:36 -05:00
9fac18ad2b
Add player housing zone creation and persistence 2026-02-14 17:17:36 -05:00
1c22530be7
Add character creation flow with description prompt 2026-02-14 17:17:36 -05:00
0f3ae87f33
Add description and home_zone fields to player and database 2026-02-14 17:17:36 -05:00
97d5173522
Fix command registry leaking between tests 2026-02-14 16:22:45 -05:00
25339edbf5
Add weather-driven ambient messages
Rain, storm, snow, and fog now have atmospheric ambient messages.
Clear and cloudy conditions return empty list. Messages are evocative
and lowercase, ready to be mixed with zone-specific ambience.
2026-02-14 16:20:00 -05:00
4c4d947ce2
Add visibility system for time and weather effects
Night, fog, and storms now reduce viewport size. Night reduces by 6
width and 2 height (21x11 -> 15x9). Thick fog reduces by 8 width and 4
height. Storm reduces by 4 width and 2 height. Effects stack but clamp
to minimum 7x5. Dawn and dusk subtly dim by 2 width.
2026-02-14 16:20:00 -05:00
9594e23011
Add atmosphere rendering function 2026-02-14 16:20:00 -05:00
d91b180824
Add game day tracking to game time system 2026-02-14 16:18:03 -05:00
15d141b53e
Add season system with tests 2026-02-14 16:18:03 -05:00
4b0a7315c1
Add weather system with tests
Implements procedural weather transitions with:
- WeatherCondition enum (clear, cloudy, rain, storm, snow, fog)
- WeatherState dataclass (condition + intensity 0-1)
- get_weather_description() for atmospheric text varying by intensity
- advance_weather() with probabilistic transitions based on season/climate
- Climate profiles: temperate (balanced), arid (clear/rare rain),
  arctic (snow/fog/cloudy)
2026-02-14 16:18:03 -05:00
32f52ef704
Add time-of-day system with tests 2026-02-14 16:18:03 -05:00
49 changed files with 4580 additions and 15 deletions

View file

@ -0,0 +1,4 @@
name = "wooden_table"
description = "Craft a sturdy table from planks and nails"
ingredients = ["plank", "plank", "plank", "nail", "nail"]
result = "table"

View file

@ -0,0 +1,4 @@
name = "bookshelf"
description = "a tall wooden bookshelf lined with dusty volumes"
portable = true
aliases = ["shelf"]

View file

@ -0,0 +1,3 @@
name = "chair"
description = "a simple wooden chair with a woven seat"
portable = true

4
content/things/lamp.toml Normal file
View file

@ -0,0 +1,4 @@
name = "lamp"
description = "a brass oil lamp with a glass chimney"
portable = true
aliases = ["lantern"]

3
content/things/nail.toml Normal file
View file

@ -0,0 +1,3 @@
name = "nail"
description = "a small iron nail"
portable = true

View file

@ -0,0 +1,4 @@
name = "painting"
description = "a framed painting of rolling hills under a twilight sky"
portable = true
aliases = ["picture"]

View file

@ -0,0 +1,4 @@
name = "plank"
description = "a rough-hewn wooden plank"
portable = true
aliases = ["board", "wood"]

3
content/things/rug.toml Normal file
View file

@ -0,0 +1,3 @@
name = "rug"
description = "a colorful woven rug with geometric patterns"
portable = true

View file

@ -0,0 +1,3 @@
name = "table"
description = "a sturdy wooden table with thick oak legs"
portable = true

View file

@ -299,9 +299,15 @@ The engine roadmap's later phases, renumbered:
- **Phase 15: NPC evolution** -- dialogue trees, behavior, schedules. - **Phase 15: NPC evolution** -- dialogue trees, behavior, schedules.
Grimm's Library librarians may pull some dialogue work into phase 14. Grimm's Library librarians may pull some dialogue work into phase 14.
- **Phase 16: World systems** -- time of day, weather, seasons - **Phase 16: World systems** -- time of day, weather, seasons
- **Phase 17: Player creation + housing** -- player-owned zones, crafting - **Phase 17: Player creation + housing** -- character creation flow
- **Phase 18: The DSL** -- in-world scripting language (description prompt for new players), personal home zones with
- **Phase 19: Horizons** -- web client, compression, inter-MUD, AI NPCs persistence (TOML in ``data/player_zones/``), home command for
teleportation to/from personal zone
- **Phase 18: Housing expansion + crafting** -- furniture placement,
home zone customization (terrain editing, descriptions), crafting
system, decorative objects
- **Phase 19: The DSL** -- in-world scripting language
- **Phase 20: Horizons** -- web client, compression, inter-MUD, AI NPCs
Key references Key references

View file

@ -0,0 +1,130 @@
"""Crafting commands."""
from collections import Counter
from mudlib.commands import CommandDefinition, register
from mudlib.crafting import recipes
from mudlib.player import Player
from mudlib.things import spawn_thing, thing_templates
async def cmd_craft(player: Player, args: str) -> None:
"""Craft an item from a recipe.
Args:
player: The player crafting
args: Recipe name
"""
if not args:
await player.send("Usage: craft <recipe>\r\n")
return
recipe_name = args.strip().lower()
# Find recipe by name (case-insensitive, prefix match)
matching_recipes = [
name for name in recipes if name.lower().startswith(recipe_name)
]
if not matching_recipes:
await player.send(f"Unknown recipe: {args}\r\n")
return
if len(matching_recipes) > 1:
await player.send(
f"Ambiguous recipe name. Matches: {', '.join(matching_recipes)}\r\n"
)
return
recipe = recipes[matching_recipes[0]]
# Count required ingredients
required = Counter(ingredient.lower() for ingredient in recipe.ingredients)
# Count available ingredients in inventory
inventory = player.contents
available = Counter(obj.name.lower() for obj in inventory)
# Check if player has all ingredients
missing = []
for ingredient, count in required.items():
if available[ingredient] < count:
needed = count - available[ingredient]
missing.append(f"{ingredient} (need {needed} more)")
if missing:
await player.send(f"Missing ingredients: {', '.join(missing)}\r\n")
return
# Check if result template exists
if recipe.result not in thing_templates:
await player.send(
f"Error: Recipe result '{recipe.result}' template not found.\r\n"
)
return
# Consume ingredients
consumed = Counter()
for obj in list(inventory):
obj_name = obj.name.lower()
if obj_name in required and consumed[obj_name] < required[obj_name]:
obj.move_to(None) # Remove from world
consumed[obj_name] += 1
# Create result item
result_template = thing_templates[recipe.result]
spawn_thing(result_template, player)
await player.send(f"You craft a {result_template.name}.\r\n")
async def cmd_recipes(player: Player, args: str) -> None:
"""List available recipes.
Args:
player: The player viewing recipes
args: Optional recipe name for details
"""
if not recipes:
await player.send("No recipes available.\r\n")
return
if args:
# Show details for specific recipe
recipe_name = args.strip().lower()
matching = [name for name in recipes if name.lower().startswith(recipe_name)]
if not matching:
await player.send(f"Unknown recipe: {args}\r\n")
return
if len(matching) > 1:
await player.send(
f"Ambiguous recipe name. Matches: {', '.join(matching)}\r\n"
)
return
recipe = recipes[matching[0]]
ingredient_counts = Counter(recipe.ingredients)
ingredient_list = ", ".join(
f"{count}x {name}" if count > 1 else name
for name, count in sorted(ingredient_counts.items())
)
await player.send(
f"Recipe: {recipe.name}\r\n"
f"{recipe.description}\r\n"
f"Ingredients: {ingredient_list}\r\n"
f"Result: {recipe.result}\r\n"
)
else:
# List all recipes
await player.send("Available recipes:\r\n")
for name, recipe in sorted(recipes.items()):
await player.send(f" {name}: {recipe.description}\r\n")
register(CommandDefinition("craft", cmd_craft, help="Craft items from recipes."))
register(
CommandDefinition("recipes", cmd_recipes, help="List available crafting recipes.")
)

View file

@ -0,0 +1,50 @@
"""Describe command — set home zone description."""
from mudlib.commands import CommandDefinition, register
from mudlib.housing import save_home_zone
from mudlib.player import Player
from mudlib.zone import Zone
async def cmd_describe(player: Player, args: str) -> None:
"""Set the description for your home zone.
Usage:
describe <text> set your home zone description
describe show current description
"""
zone = player.location
# Must be in a zone
if not isinstance(zone, Zone):
await player.send("You aren't anywhere.\r\n")
return
# Must be in own home zone
if zone.name != player.home_zone:
await player.send("You can only describe your own home zone.\r\n")
return
# No args — show current description
if not args.strip():
await player.send(f"Current description: {zone.description}\r\n")
return
# Set new description
description = args.strip()
if len(description) > 500:
await player.send("Description too long (max 500 characters).\r\n")
return
zone.description = description
save_home_zone(player.name, zone)
await player.send(f"Home zone description set to: {zone.description}\r\n")
register(
CommandDefinition(
"describe",
cmd_describe,
help="Set your home zone description.",
)
)

View file

@ -0,0 +1,92 @@
"""Furnish and unfurnish commands — place/remove furniture in home zones."""
from mudlib.commands import CommandDefinition, register
from mudlib.housing import save_home_zone
from mudlib.player import Player
from mudlib.targeting import find_in_inventory, find_thing_on_tile
from mudlib.zone import Zone
async def cmd_furnish(player: Player, args: str) -> None:
"""Place an item from inventory as furniture in your home zone.
Usage:
furnish <item>
"""
# Validate arguments
if not args.strip():
await player.send("Usage: furnish <item>\r\n")
return
# Check that player is in their home zone
zone = player.location
if not isinstance(zone, Zone) or zone.name != player.home_zone:
await player.send("You can only furnish items in your home zone.\r\n")
return
# Find item in inventory
item_name = args.strip()
thing = find_in_inventory(item_name, player)
if thing is None:
await player.send(f"You don't have '{item_name}'.\r\n")
return
# Place item at player's position
thing.move_to(zone, x=player.x, y=player.y)
# Save the zone
save_home_zone(player.name, zone)
await player.send(f"You place the {thing.name} here.\r\n")
async def cmd_unfurnish(player: Player, args: str) -> None:
"""Pick up furniture from your home zone into inventory.
Usage:
unfurnish <item>
"""
# Validate arguments
if not args.strip():
await player.send("Usage: unfurnish <item>\r\n")
return
# Check that player is in their home zone
zone = player.location
if not isinstance(zone, Zone) or zone.name != player.home_zone:
await player.send("You can only unfurnish items in your home zone.\r\n")
return
# Find furniture at player's position
item_name = args.strip()
thing = find_thing_on_tile(item_name, zone, player.x, player.y)
if thing is None:
await player.send(f"You don't see '{item_name}' here.\r\n")
return
# Pick up the item
thing.move_to(player)
# Save the zone
save_home_zone(player.name, zone)
await player.send(f"You pick up the {thing.name}.\r\n")
register(
CommandDefinition(
"furnish",
cmd_furnish,
help="Place an item from inventory as furniture in your home zone.",
)
)
register(
CommandDefinition(
"unfurnish",
cmd_unfurnish,
help="Pick up furniture from your home zone into inventory.",
)
)

108
src/mudlib/commands/home.py Normal file
View file

@ -0,0 +1,108 @@
"""Home command — teleport to personal zone."""
from mudlib.commands import CommandDefinition, register
from mudlib.commands.movement import send_nearby_message
from mudlib.housing import get_or_create_home
from mudlib.player import Player
from mudlib.store import save_player_home_zone
from mudlib.zone import Zone
from mudlib.zones import get_zone
async def cmd_home(player: Player, args: str) -> None:
"""Teleport to your personal zone, or return from it.
Usage:
home go to your home zone
home return return to where you were before going home
"""
from mudlib.commands.look import cmd_look
arg = args.strip().lower()
zone = player.location
if arg == "return":
# Return to previous location
if player.return_location is None:
await player.send("You have nowhere to return to.\r\n")
return
target_zone_name, target_x, target_y = player.return_location
target_zone = get_zone(target_zone_name)
if target_zone is None:
await player.send("Your return destination no longer exists.\r\n")
player.return_location = None
return
# Departure message
if isinstance(zone, Zone):
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} vanishes in a flash.\r\n",
)
# Move
player.move_to(target_zone, x=target_x, y=target_y)
player.return_location = None
# Arrival message
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} appears in a flash.\r\n",
)
await player.send("You return to where you were.\r\n")
await cmd_look(player, "")
return
if arg:
await player.send("Usage: home | home return\r\n")
return
# Go home
home = get_or_create_home(player.name)
# Save current location for return trip (only if not already at home)
home_zone_name = f"home:{player.name.lower()}"
if isinstance(zone, Zone) and zone.name != home_zone_name:
player.return_location = (zone.name, player.x, player.y)
# Departure message
if isinstance(zone, Zone):
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} vanishes in a flash.\r\n",
)
# Move to home spawn point
player.move_to(home, x=home.spawn_x, y=home.spawn_y)
# Update home_zone on player
player.home_zone = home.name
save_player_home_zone(player.name, home.name)
# Arrival message (usually nobody else is in your home, but just in case)
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} appears in a flash.\r\n",
)
await player.send("You arrive at your home.\r\n")
await cmd_look(player, "")
register(
CommandDefinition(
"home",
cmd_home,
help="Teleport to your personal zone. 'home return' to go back.",
)
)

View file

@ -4,16 +4,25 @@ from mudlib.commands import CommandDefinition, register
from mudlib.commands.things import _format_thing_name from mudlib.commands.things import _format_thing_name
from mudlib.effects import get_effects_at from mudlib.effects import get_effects_at
from mudlib.entity import Entity from mudlib.entity import Entity
from mudlib.gametime import get_game_day, get_game_hour
from mudlib.player import Player from mudlib.player import Player
from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.ansi import RESET, colorize_terrain
from mudlib.render.room import ( from mudlib.render.room import (
render_atmosphere,
render_entity_lines, render_entity_lines,
render_exits, render_exits,
render_location, render_location,
render_nearby, render_nearby,
render_where, render_where,
) )
from mudlib.seasons import get_season
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.visibility import get_visibility
from mudlib.weather import (
WeatherCondition,
get_current_weather,
get_weather_description,
)
from mudlib.zone import Zone from mudlib.zone import Zone
# Viewport dimensions # Viewport dimensions
@ -82,16 +91,38 @@ async def cmd_look(player: Player, args: str) -> None:
await player.writer.drain() await player.writer.drain()
return return
# Compute environment state once
hour = None
day = None
weather = None
season = None
try:
hour = get_game_hour()
day = get_game_day()
weather = get_current_weather()
season = get_season(day)
except RuntimeError:
pass
# Use hour/weather for visibility
if hour is not None and weather is not None:
effective_width, effective_height = get_visibility(
hour, weather, VIEWPORT_WIDTH, VIEWPORT_HEIGHT
)
else:
effective_width = VIEWPORT_WIDTH
effective_height = VIEWPORT_HEIGHT
# Get the viewport from the zone # Get the viewport from the zone
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) viewport = zone.get_viewport(player.x, player.y, effective_width, effective_height)
# Calculate center position # Calculate center position
center_x = VIEWPORT_WIDTH // 2 center_x = effective_width // 2
center_y = VIEWPORT_HEIGHT // 2 center_y = effective_height // 2
# Get nearby entities (players and mobs) from the zone # Get nearby entities (players and mobs) from the zone
# Viewport half-diagonal distance for range # Viewport half-diagonal distance for range
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2 viewport_range = effective_width // 2 + effective_height // 2
nearby = zone.contents_near(player.x, player.y, viewport_range) nearby = zone.contents_near(player.x, player.y, viewport_range)
# Build a list of (relative_x, relative_y) for other entities # Build a list of (relative_x, relative_y) for other entities
@ -121,13 +152,13 @@ async def cmd_look(player: Player, args: str) -> None:
rel_y = dy + center_y rel_y = dy + center_y
# Check if within viewport bounds # Check if within viewport bounds
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: if 0 <= rel_x < effective_width and 0 <= rel_y < effective_height:
entity_positions.append((rel_x, rel_y)) entity_positions.append((rel_x, rel_y))
# Build the output with ANSI coloring # Build the output with ANSI coloring
# priority: player @ > other players * > mobs * > effects > terrain # priority: player @ > other players * > mobs * > effects > terrain
half_width = VIEWPORT_WIDTH // 2 half_width = effective_width // 2
half_height = VIEWPORT_HEIGHT // 2 half_height = effective_height // 2
output_lines = [] output_lines = []
for y, row in enumerate(viewport): for y, row in enumerate(viewport):
@ -160,6 +191,16 @@ async def cmd_look(player: Player, args: str) -> None:
# Where header # Where header
output.append(render_where(zone.description)) output.append(render_where(zone.description))
# Atmosphere line (between Where and viewport)
if hour is not None and weather is not None and season is not None:
if weather.condition == WeatherCondition.clear:
weather_desc = ""
else:
weather_desc = get_weather_description(weather)
atmosphere = render_atmosphere(hour, weather_desc, season)
output.append(atmosphere)
# Viewport # Viewport
output.append("\r\n".join(output_lines)) output.append("\r\n".join(output_lines))

View file

@ -0,0 +1,72 @@
"""Terrain editing command for home zones."""
from mudlib.commands import CommandDefinition, register
from mudlib.housing import save_home_zone
from mudlib.player import Player
from mudlib.zone import Zone
async def cmd_terrain(player: Player, args: str) -> None:
"""Paint terrain tiles in your home zone.
Usage:
terrain <tile> paint terrain at your current position
Common tiles:
. grass
~ water
^ mountain
T tree
, dirt
" tall grass
"""
zone = player.location
# Must be in a Zone
if not isinstance(zone, Zone):
await player.send("You need to be in a zone to edit terrain.\r\n")
return
# Must be in home zone
if zone.name != player.home_zone:
await player.send("You can only edit terrain in your home zone.\r\n")
return
# Check for args
if not args.strip():
await player.send(
"Usage: terrain <tile>\r\n\r\n"
"Common tiles: . (grass), ~ (water), ^ (mountain), "
'T (tree), , (dirt), " (tall grass)\r\n'
)
return
tile = args.strip()
# Must be single character
if len(tile) != 1:
await player.send("Tile must be a single character.\r\n")
return
# Cannot edit border tiles
x, y = player.x, player.y
if x == 0 or x == zone.width - 1 or y == 0 or y == zone.height - 1:
await player.send("You cannot edit the border walls.\r\n")
return
# Paint the tile
zone.terrain[y][x] = tile
# Save the zone
save_home_zone(player.name, zone)
await player.send(f"You paint the ground beneath you as '{tile}'.\r\n")
register(
CommandDefinition(
"terrain",
cmd_terrain,
help="Paint terrain tiles in your home zone.",
)
)

57
src/mudlib/crafting.py Normal file
View file

@ -0,0 +1,57 @@
"""Crafting recipe system."""
import logging
import tomllib
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class Recipe:
"""A crafting recipe definition."""
name: str
description: str
ingredients: list[str]
result: str
# Module-level registry
recipes: dict[str, Recipe] = {}
def load_recipe(path: Path) -> Recipe:
"""Load a recipe from TOML.
Args:
path: Path to the recipe TOML file
Returns:
Recipe instance
"""
with open(path, "rb") as f:
data = tomllib.load(f)
return Recipe(
name=data["name"],
description=data["description"],
ingredients=data["ingredients"],
result=data["result"],
)
def load_recipes(directory: Path) -> dict[str, Recipe]:
"""Load all recipes from a directory.
Args:
directory: Path to directory containing recipe TOML files
Returns:
Dict of recipes keyed by name
"""
loaded: dict[str, Recipe] = {}
for path in sorted(directory.glob("*.toml")):
recipe = load_recipe(path)
loaded[recipe.name] = recipe
return loaded

36
src/mudlib/creation.py Normal file
View file

@ -0,0 +1,36 @@
"""Character creation flow for new players."""
async def character_creation(
name: str,
read_func,
write_func,
) -> dict:
"""Run character creation prompts for a new player.
Args:
name: Player name
read_func: Async function to read a line of input (returns str or None)
write_func: Async function to write output
Returns:
Dict with creation data: {"description": str}
"""
await write_func("\r\n--- Character Creation ---\r\n\r\n")
await write_func(
"Describe yourself in a short sentence or two.\r\n"
"(This is what others see when they look at you.)\r\n\r\n"
)
await write_func("Description: ")
desc_input = await read_func()
description = ""
if desc_input is not None:
description = desc_input.strip()
if description:
await write_func(f'\r\nYou will be known as: "{description}"\r\n')
else:
await write_func("\r\nNo description set. You can change it later.\r\n")
return {"description": description}

View file

@ -42,6 +42,17 @@ class GameTime:
return hour, minute return hour, minute
def get_game_day(self) -> int:
"""Get current game day (0-based).
Returns:
Current game day since epoch
"""
elapsed_real_seconds = time.time() - self.epoch
elapsed_real_minutes = elapsed_real_seconds / 60
elapsed_game_hours = elapsed_real_minutes / self.real_minutes_per_game_hour
return int(elapsed_game_hours) // 24
# Global game time instance (initialized at server startup) # Global game time instance (initialized at server startup)
_game_time: GameTime | None = None _game_time: GameTime | None = None
@ -88,3 +99,17 @@ def get_game_time() -> tuple[int, int]:
if _game_time is None: if _game_time is None:
raise RuntimeError("Game time not initialized. Call init_game_time() first.") raise RuntimeError("Game time not initialized. Call init_game_time() first.")
return _game_time.get_game_time() return _game_time.get_game_time()
def get_game_day() -> int:
"""Get current game day from global instance.
Returns:
Current game day (0-based)
Raises:
RuntimeError: If game time not initialized
"""
if _game_time is None:
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
return _game_time.get_game_day()

237
src/mudlib/housing.py Normal file
View file

@ -0,0 +1,237 @@
"""Player housing — personal zones."""
import logging
import tomllib
from pathlib import Path
from mudlib.entity import Entity
from mudlib.portal import Portal
from mudlib.thing import Thing
from mudlib.things import spawn_thing, thing_templates
from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone
log = logging.getLogger(__name__)
# Default home zone dimensions
HOME_WIDTH = 9
HOME_HEIGHT = 9
HOME_SPAWN_X = 4
HOME_SPAWN_Y = 4
# Where player zone files live
_zones_dir: Path | None = None
def init_housing(zones_dir: Path) -> None:
"""Set the directory for player zone files."""
global _zones_dir
_zones_dir = zones_dir
_zones_dir.mkdir(parents=True, exist_ok=True)
def _home_zone_name(player_name: str) -> str:
"""Return the zone registry name for a player's home."""
return f"home:{player_name.lower()}"
def _zone_path(player_name: str) -> Path:
"""Return the TOML file path for a player's home zone."""
if _zones_dir is None:
raise RuntimeError("Call init_housing() first")
return _zones_dir / f"{player_name.lower()}.toml"
def create_home_zone(player_name: str) -> Zone:
"""Create a default home zone for a player.
Creates the zone, registers it, and saves it to disk.
Args:
player_name: The player's name
Returns:
The newly created Zone
"""
zone_name = _home_zone_name(player_name)
# Build terrain — simple grass field with a border
terrain = []
for y in range(HOME_HEIGHT):
row = []
for x in range(HOME_WIDTH):
if x == 0 or x == HOME_WIDTH - 1 or y == 0 or y == HOME_HEIGHT - 1:
row.append("#") # wall border
else:
row.append(".") # open grass
terrain.append(row)
zone = Zone(
name=zone_name,
description=f"{player_name}'s home",
width=HOME_WIDTH,
height=HOME_HEIGHT,
toroidal=False,
terrain=terrain,
impassable={"#", "^", "~"},
spawn_x=HOME_SPAWN_X,
spawn_y=HOME_SPAWN_Y,
safe=True,
)
register_zone(zone_name, zone)
save_home_zone(player_name, zone)
return zone
def save_home_zone(player_name: str, zone: Zone) -> None:
"""Save a player's home zone to TOML.
Args:
player_name: The player's name
zone: The zone to save
"""
path = _zone_path(player_name)
# Build TOML content
lines = []
escaped_name = zone.name.replace("\\", "\\\\").replace('"', '\\"')
escaped_desc = zone.description.replace("\\", "\\\\").replace('"', '\\"')
lines.append(f'name = "{escaped_name}"')
lines.append(f'description = "{escaped_desc}"')
lines.append(f"width = {zone.width}")
lines.append(f"height = {zone.height}")
lines.append(f"toroidal = {'true' if zone.toroidal else 'false'}")
lines.append(f"spawn_x = {zone.spawn_x}")
lines.append(f"spawn_y = {zone.spawn_y}")
lines.append(f"safe = {'true' if zone.safe else 'false'}")
lines.append("")
lines.append("[terrain]")
lines.append("rows = [")
for row in zone.terrain:
lines.append(f' "{"".join(row)}",')
lines.append("]")
lines.append("")
lines.append("[terrain.impassable]")
tiles = ", ".join(f'"{t}"' for t in sorted(zone.impassable))
lines.append(f"tiles = [{tiles}]")
lines.append("")
# Save furniture (Things in the zone, but not Entities or Portals)
furniture = [
obj
for obj in zone._contents
if isinstance(obj, Thing)
and not isinstance(obj, Entity)
and not isinstance(obj, Portal)
]
for item in furniture:
lines.append("[[furniture]]")
lines.append(f'template = "{item.name}"')
lines.append(f"x = {item.x}")
lines.append(f"y = {item.y}")
lines.append("")
path.write_text("\n".join(lines))
def load_home_zone(player_name: str) -> Zone | None:
"""Load a player's home zone from disk if it exists.
Also registers it in the zone registry.
Args:
player_name: The player's name
Returns:
The Zone if it exists on disk, None otherwise
"""
path = _zone_path(player_name)
if not path.exists():
return None
with open(path, "rb") as f:
data = tomllib.load(f)
terrain = [list(row) for row in data.get("terrain", {}).get("rows", [])]
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
impassable = set(impassable_list) if impassable_list else {"#", "^", "~"}
zone = Zone(
name=data["name"],
description=data.get("description", ""),
width=data["width"],
height=data["height"],
toroidal=data.get("toroidal", False),
terrain=terrain,
impassable=impassable,
spawn_x=data.get("spawn_x", 0),
spawn_y=data.get("spawn_y", 0),
safe=data.get("safe", True),
)
# Load furniture
furniture_list = data.get("furniture", [])
for item_data in furniture_list:
template_name = item_data.get("template")
x = item_data.get("x")
y = item_data.get("y")
if template_name not in thing_templates:
log.warning(
"Skipping unknown furniture template '%s' in %s",
template_name,
player_name,
)
continue
if not isinstance(x, int) or not isinstance(y, int):
log.warning(
"Invalid coordinates for furniture '%s' in %s",
template_name,
player_name,
)
continue
if not (0 <= x < zone.width and 0 <= y < zone.height):
log.warning(
"Out-of-bounds furniture '%s' at (%d,%d) in %s",
template_name,
x,
y,
player_name,
)
continue
template = thing_templates[template_name]
spawn_thing(template, zone, x=x, y=y)
register_zone(zone.name, zone)
return zone
def get_or_create_home(player_name: str) -> Zone:
"""Get the player's home zone, creating it if it doesn't exist.
Args:
player_name: The player's name
Returns:
The player's home Zone
"""
zone_name = _home_zone_name(player_name)
# Check registry first
zone = get_zone(zone_name)
if zone is not None:
return zone
# Try loading from disk
zone = load_home_zone(player_name)
if zone is not None:
return zone
# Create new
return create_home_zone(player_name)

View file

@ -43,6 +43,9 @@ class Player(Entity):
unlocked_moves: set[str] = field(default_factory=set) unlocked_moves: set[str] = field(default_factory=set)
session_start: float = 0.0 session_start: float = 0.0
is_admin: bool = False is_admin: bool = False
description: str = ""
home_zone: str | None = None
return_location: tuple[str, int, int] | None = None
@property @property
def mode(self) -> str: def mode(self) -> str:

View file

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from mudlib.timeofday import get_sky_description, get_time_period
def render_where(zone_name: str) -> str: def render_where(zone_name: str) -> str:
"""Render the zone description line. """Render the zone description line.
@ -137,3 +139,23 @@ def render_entity_lines(entities: list, viewer) -> str:
lines.append(f"{entity.name} {msg}") lines.append(f"{entity.name} {msg}")
return "\n".join(lines) return "\n".join(lines)
def render_atmosphere(hour: int, weather_desc: str, season: str) -> str:
"""Render atmosphere line combining time-of-day and weather.
Args:
hour: Game hour (0-23)
weather_desc: Weather description (empty string for clear weather)
season: Season name ("spring", "summer", "autumn", "winter")
Returns:
Formatted atmosphere line
"""
sky = get_sky_description(hour)
period = get_time_period(hour)
if weather_desc:
return f"{sky}. {weather_desc}. [{period}, {season}]"
else:
return f"{sky}. [{period}, {season}]"

68
src/mudlib/seasons.py Normal file
View file

@ -0,0 +1,68 @@
"""Season system derived from game day count."""
from __future__ import annotations
SEASONS: list[str] = ["spring", "summer", "autumn", "winter"]
DAYS_PER_SEASON: int = 7 # 28-day year
def get_season(game_day: int, days_per_season: int = DAYS_PER_SEASON) -> str:
"""Return the current season based on game day count.
Args:
game_day: Current game day (0-based)
days_per_season: Number of days per season (default 7)
Returns:
Current season: "spring", "summer", "autumn", or "winter"
"""
if game_day < 0:
game_day = 0
days_per_year = days_per_season * 4
day_of_year = game_day % days_per_year
season_index = day_of_year // days_per_season
return SEASONS[season_index]
def get_day_of_year(game_day: int, days_per_season: int = DAYS_PER_SEASON) -> int:
"""Return the day within the current year (0 to days_per_season*4 - 1).
Args:
game_day: Current game day (0-based)
days_per_season: Number of days per season (default 7)
Returns:
Day of year (0-based)
"""
if game_day < 0:
game_day = 0
days_per_year = days_per_season * 4
return game_day % days_per_year
def get_season_description(season: str, terrain: str) -> str:
"""Return a seasonal description variant for a terrain type.
Args:
season: Current season
terrain: Terrain type
Returns:
Seasonal description for this terrain, or empty string if no variation
"""
# Only grass and forest have seasonal variation
descriptions = {
("spring", "grass"): "fresh green grass springs up everywhere",
("summer", "grass"): "golden grass waves in the breeze",
("autumn", "grass"): "the grass turns brown and brittle",
("winter", "grass"): "frost clings to brittle brown grass",
("spring", "forest"): "the trees burst with pale blossoms",
("summer", "forest"): "a thick green canopy spreads overhead",
("autumn", "forest"): "the trees blaze with amber and crimson",
("winter", "forest"): "bare branches reach toward the sky",
}
return descriptions.get((season, terrain), "")

View file

@ -16,10 +16,14 @@ from telnetlib3.server_shell import readline2
import mudlib.combat.commands import mudlib.combat.commands
import mudlib.commands import mudlib.commands
import mudlib.commands.containers import mudlib.commands.containers
import mudlib.commands.crafting
import mudlib.commands.describe
import mudlib.commands.edit import mudlib.commands.edit
import mudlib.commands.examine import mudlib.commands.examine
import mudlib.commands.fly import mudlib.commands.fly
import mudlib.commands.furnish
import mudlib.commands.help import mudlib.commands.help
import mudlib.commands.home
import mudlib.commands.look import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
import mudlib.commands.play import mudlib.commands.play
@ -30,6 +34,7 @@ import mudlib.commands.reload
import mudlib.commands.snapneck import mudlib.commands.snapneck
import mudlib.commands.spawn import mudlib.commands.spawn
import mudlib.commands.talk import mudlib.commands.talk
import mudlib.commands.terrain
import mudlib.commands.things import mudlib.commands.things
import mudlib.commands.use import mudlib.commands.use
from mudlib.caps import parse_mtts from mudlib.caps import parse_mtts
@ -37,6 +42,8 @@ from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat
from mudlib.content import load_commands from mudlib.content import load_commands
from mudlib.corpse import process_decomposing from mudlib.corpse import process_decomposing
from mudlib.crafting import load_recipes, recipes
from mudlib.creation import character_creation
from mudlib.dialogue import load_all_dialogues from mudlib.dialogue import load_all_dialogues
from mudlib.effects import clear_expired from mudlib.effects import clear_expired
from mudlib.gametime import get_game_hour, init_game_time from mudlib.gametime import get_game_hour, init_game_time
@ -47,6 +54,7 @@ from mudlib.gmcp import (
send_msdp_vitals, send_msdp_vitals,
send_room_info, send_room_info,
) )
from mudlib.housing import init_housing, load_home_zone
from mudlib.if_session import broadcast_to_spectators from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mob_movement, process_mobs from mudlib.mob_ai import process_mob_movement, process_mobs
from mudlib.mobs import load_mob_templates, mob_templates, mobs from mudlib.mobs import load_mob_templates, mob_templates, mobs
@ -64,6 +72,7 @@ from mudlib.store import (
load_player_data, load_player_data,
load_player_stats, load_player_stats,
save_player, save_player,
save_player_description,
update_last_login, update_last_login,
) )
from mudlib.thing import Thing from mudlib.thing import Thing
@ -198,7 +207,7 @@ async def handle_login(
if authenticate(name, password.strip()): if authenticate(name, password.strip()):
# Success - load player data # Success - load player data
player_data = load_player_data(name) player_data = load_player_data(name)
return {"success": True, "player_data": player_data} return {"success": True, "player_data": player_data, "is_new": False}
remaining = max_attempts - attempt - 1 remaining = max_attempts - attempt - 1
if remaining > 0: if remaining > 0:
@ -236,7 +245,7 @@ async def handle_login(
await write_func("Account created successfully!\r\n") await write_func("Account created successfully!\r\n")
# Return default data for new account # Return default data for new account
player_data = load_player_data(name) player_data = load_player_data(name)
return {"success": True, "player_data": player_data} return {"success": True, "player_data": player_data, "is_new": True}
await write_func("Failed to create account.\r\n") await write_func("Failed to create account.\r\n")
return {"success": False, "player_data": None} return {"success": False, "player_data": None}
@ -310,6 +319,15 @@ async def shell(
# Load player data from database or use defaults for new player # Load player data from database or use defaults for new player
player_data: PlayerData | None = login_result["player_data"] player_data: PlayerData | None = login_result["player_data"]
# Run character creation for new accounts
is_new_account = login_result.get("is_new", False)
if is_new_account:
creation_data = await character_creation(player_name, read_input, write_output)
if creation_data.get("description"):
save_player_description(player_name, creation_data["description"])
if player_data is not None:
player_data["description"] = creation_data["description"]
if player_data is None: if player_data is None:
# New player - find a passable starting position # New player - find a passable starting position
center_x = _overworld.width // 2 center_x = _overworld.width // 2
@ -324,6 +342,8 @@ async def shell(
"flying": False, "flying": False,
"zone_name": "overworld", "zone_name": "overworld",
"inventory": [], "inventory": [],
"description": "",
"home_zone": None,
} }
# Resolve zone from zone_name using zone registry # Resolve zone from zone_name using zone registry
@ -346,6 +366,11 @@ async def shell(
player_data["x"] = start_x player_data["x"] = start_x
player_data["y"] = start_y player_data["y"] = start_y
# Load player's home zone if they have one
home_zone_name = player_data.get("home_zone")
if home_zone_name and home_zone_name.startswith("home:"):
load_home_zone(player_name)
# Create player instance # Create player instance
player = Player( player = Player(
name=player_name, name=player_name,
@ -360,6 +385,10 @@ async def shell(
reader=_reader, reader=_reader,
) )
# Set description and home zone
player.description = player_data.get("description", "")
player.home_zone = player_data.get("home_zone")
# Load aliases from database # Load aliases from database
player.aliases = load_aliases(player_name) player.aliases = load_aliases(player_name)
@ -551,6 +580,11 @@ async def run_server() -> None:
register_zone(zone_name, zone) register_zone(zone_name, zone)
log.info("loaded %d zones from %s", len(loaded_zones), zones_dir) log.info("loaded %d zones from %s", len(loaded_zones), zones_dir)
# Initialize player housing
player_zones_dir = data_dir / "player_zones"
init_housing(player_zones_dir)
log.info("player housing initialized at %s", player_zones_dir)
# Load content-defined commands from TOML files # Load content-defined commands from TOML files
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands" content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
if content_dir.exists(): if content_dir.exists():
@ -581,6 +615,13 @@ async def run_server() -> None:
thing_templates.update(loaded_things) thing_templates.update(loaded_things)
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir) log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
# Load crafting recipes
recipes_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "recipes"
if recipes_dir.exists():
loaded_recipes = load_recipes(recipes_dir)
recipes.update(loaded_recipes)
log.info("loaded %d recipes from %s", len(loaded_recipes), recipes_dir)
# Load dialogue trees for NPC conversations # Load dialogue trees for NPC conversations
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue" dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
if dialogue_dir.exists(): if dialogue_dir.exists():

View file

@ -23,6 +23,8 @@ class PlayerData(TypedDict):
flying: bool flying: bool
zone_name: str zone_name: str
inventory: list[str] inventory: list[str]
description: str
home_zone: str | None
class StatsData(TypedDict): class StatsData(TypedDict):
@ -104,6 +106,12 @@ def init_db(db_path: str | Path) -> None:
cursor.execute( cursor.execute(
"ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'" "ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'"
) )
if "description" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN description TEXT NOT NULL DEFAULT ''"
)
if "home_zone" not in columns:
cursor.execute("ALTER TABLE accounts ADD COLUMN home_zone TEXT")
conn.commit() conn.commit()
conn.close() conn.close()
@ -218,6 +226,40 @@ def authenticate(name: str, password: str) -> bool:
return hmac.compare_digest(password_hash, stored_hash) return hmac.compare_digest(password_hash, stored_hash)
def save_player_description(name: str, description: str) -> None:
"""Save a player's description to the database.
Args:
name: Account name
description: Description text
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE accounts SET description = ? WHERE name = ?",
(description, name),
)
conn.commit()
conn.close()
def save_player_home_zone(name: str, home_zone: str | None) -> None:
"""Save a player's home zone to the database.
Args:
name: Account name
home_zone: Home zone name
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE accounts SET home_zone = ? WHERE name = ?",
(home_zone, name),
)
conn.commit()
conn.close()
def save_player(player: Player) -> None: def save_player(player: Player) -> None:
"""Save player state to the database. """Save player state to the database.
@ -242,7 +284,7 @@ def save_player(player: Player) -> None:
""" """
UPDATE accounts UPDATE accounts
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?, SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
zone_name = ?, inventory = ? zone_name = ?, inventory = ?, description = ?, home_zone = ?
WHERE name = ? WHERE name = ?
""", """,
( (
@ -254,6 +296,8 @@ def save_player(player: Player) -> None:
1 if player.flying else 0, 1 if player.flying else 0,
player.location.name if player.location else "overworld", player.location.name if player.location else "overworld",
inventory_json, inventory_json,
player.description,
player.home_zone,
player.name, player.name,
), ),
) )
@ -283,6 +327,8 @@ def load_player_data(name: str) -> PlayerData | None:
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
has_zone_name = "zone_name" in columns has_zone_name = "zone_name" in columns
has_inventory = "inventory" in columns has_inventory = "inventory" in columns
has_description = "description" in columns
has_home_zone = "home_zone" in columns
# Build SELECT based on available columns # Build SELECT based on available columns
select_cols = "x, y, pl, stamina, max_stamina, flying" select_cols = "x, y, pl, stamina, max_stamina, flying"
@ -290,6 +336,10 @@ def load_player_data(name: str) -> PlayerData | None:
select_cols += ", zone_name" select_cols += ", zone_name"
if has_inventory: if has_inventory:
select_cols += ", inventory" select_cols += ", inventory"
if has_description:
select_cols += ", description"
if has_home_zone:
select_cols += ", home_zone"
cursor.execute( cursor.execute(
f"SELECT {select_cols} FROM accounts WHERE name = ?", f"SELECT {select_cols} FROM accounts WHERE name = ?",
@ -314,6 +364,17 @@ def load_player_data(name: str) -> PlayerData | None:
inventory: list[str] = [] inventory: list[str] = []
if has_inventory: if has_inventory:
inventory = json.loads(result[idx]) inventory = json.loads(result[idx])
idx += 1
description = ""
if has_description:
description = result[idx]
idx += 1
home_zone = None
if has_home_zone:
home_zone = result[idx]
idx += 1
return { return {
"x": x, "x": x,
@ -324,6 +385,8 @@ def load_player_data(name: str) -> PlayerData | None:
"flying": bool(flying_int), "flying": bool(flying_int),
"zone_name": zone_name, "zone_name": zone_name,
"inventory": inventory, "inventory": inventory,
"description": description,
"home_zone": home_zone,
} }

70
src/mudlib/timeofday.py Normal file
View file

@ -0,0 +1,70 @@
"""Time-of-day periods and atmospheric descriptions."""
from __future__ import annotations
def get_time_period(hour: int) -> str:
"""Map a game hour (0-23) to a time period.
Args:
hour: Game hour (0-23)
Returns:
Time period: "dawn", "day", "dusk", or "night"
"""
if 5 <= hour <= 6:
return "dawn"
elif 7 <= hour <= 17:
return "day"
elif 18 <= hour <= 19:
return "dusk"
else:
return "night"
def get_sky_description(hour: int) -> str:
"""Return an atmospheric one-liner for the current time of day.
Args:
hour: Game hour (0-23)
Returns:
Short atmospheric description based on time of day
"""
period = get_time_period(hour)
# multiple variants per period, pick based on hour for determinism
descriptions = {
"dawn": [
"pale light seeps across the horizon",
"the first rays of dawn touch the sky",
],
"day": [
"the sun hangs high overhead",
"bright daylight illuminates everything",
"warm light fills the air",
"midday light is at its strongest",
"golden afternoon light slants across the land",
"the day is bright and warm",
"light glints off distant surfaces",
"the sun begins its slow descent",
"late afternoon shadows grow longer",
"the sun drifts toward the western horizon",
"the day's light begins to soften",
],
"dusk": [
"the sky burns orange and violet",
"twilight deepens across the landscape",
],
"night": [
"stars wheel slowly overhead",
"darkness blankets the world",
"the night is deep and still",
"moonlight casts pale shadows",
"the hours before dawn stretch long and dark",
],
}
variants = descriptions[period]
# use hour to pick variant deterministically
return variants[hour % len(variants)]

54
src/mudlib/visibility.py Normal file
View file

@ -0,0 +1,54 @@
"""Visibility calculations based on time and weather."""
from mudlib.weather import WeatherCondition, WeatherState
def get_visibility(
hour: int,
weather: WeatherState,
base_width: int = 21,
base_height: int = 11,
) -> tuple[int, int]:
"""Calculate effective viewport dimensions.
Night, fog, and storms reduce visibility. Multiple effects stack
but never reduce below minimum (7x5).
Returns:
Tuple of (effective_width, effective_height)
"""
width = base_width
height = base_height
# Time-based reductions
if hour >= 20 or hour <= 4:
# Night (hours 20-4)
width -= 6
height -= 2
elif 5 <= hour <= 6:
# Dawn (hours 5-6)
width -= 2
elif 18 <= hour <= 19:
# Dusk (hours 18-19)
width -= 2
# Weather-based reductions
if weather.condition == WeatherCondition.fog:
if weather.intensity >= 0.7:
# Thick fog
width -= 8
height -= 4
elif weather.intensity >= 0.4:
# Moderate fog
width -= 4
height -= 2
elif weather.condition == WeatherCondition.storm:
# Storm
width -= 4
height -= 2
# Clamp to minimum
width = max(7, width)
height = max(5, height)
return width, height

292
src/mudlib/weather.py Normal file
View file

@ -0,0 +1,292 @@
"""Weather system with procedural transitions."""
from __future__ import annotations
import enum
import random
from dataclasses import dataclass
class WeatherCondition(enum.Enum):
clear = "clear"
cloudy = "cloudy"
rain = "rain"
storm = "storm"
snow = "snow"
fog = "fog"
@dataclass
class WeatherState:
condition: WeatherCondition
intensity: float # 0.0 to 1.0
def get_weather_description(state: WeatherState) -> str:
"""Return atmospheric text for the current weather."""
condition = state.condition
intensity = state.intensity
if condition == WeatherCondition.clear:
return "the sky is clear"
elif condition == WeatherCondition.cloudy:
if intensity < 0.4:
return "thin clouds drift overhead"
elif intensity < 0.7:
return "clouds fill the sky"
else:
return "heavy clouds loom darkly"
elif condition == WeatherCondition.rain:
if intensity < 0.3:
return "a light drizzle falls"
elif intensity < 0.6:
return "rain patters steadily"
else:
return "rain hammers down relentlessly"
elif condition == WeatherCondition.storm:
if intensity < 0.5:
return "thunder rumbles in the distance"
else:
return "lightning splits the sky as the storm rages"
elif condition == WeatherCondition.snow:
if intensity < 0.3:
return "light snow drifts down"
elif intensity < 0.6:
return "snow falls steadily"
else:
return "heavy snow blankets everything"
elif condition == WeatherCondition.fog:
if intensity < 0.4:
return "thin mist hangs in the air"
elif intensity < 0.7:
return "fog obscures the distance"
else:
return "thick fog shrouds everything"
return ""
# Climate profiles: transition weights for each condition
# Format: {from_condition: {to_condition: weight, ...}, ...}
CLIMATE_PROFILES = {
"temperate": {
WeatherCondition.clear: {
"clear": 50,
"cloudy": 40,
"fog": 10,
},
WeatherCondition.cloudy: {
"clear": 30,
"cloudy": 35,
"rain": 20,
"snow": 10,
"fog": 5,
},
WeatherCondition.rain: {
"cloudy": 35,
"rain": 30,
"storm": 15,
"snow": 10,
"fog": 10,
},
WeatherCondition.storm: {
"rain": 60,
"cloudy": 40,
},
WeatherCondition.snow: {
"snow": 40,
"cloudy": 35,
"fog": 25,
},
WeatherCondition.fog: {
"fog": 30,
"cloudy": 40,
"clear": 30,
},
},
"arid": {
WeatherCondition.clear: {
"clear": 90,
"cloudy": 10,
},
WeatherCondition.cloudy: {
"clear": 70,
"cloudy": 20,
"rain": 5,
"fog": 5,
},
WeatherCondition.rain: {
"cloudy": 60,
"rain": 20,
"clear": 20,
},
WeatherCondition.storm: {
"rain": 50,
"cloudy": 50,
},
WeatherCondition.snow: {
"cloudy": 100,
},
WeatherCondition.fog: {
"clear": 60,
"fog": 20,
"cloudy": 20,
},
},
"arctic": {
WeatherCondition.clear: {
"cloudy": 50,
"fog": 30,
"clear": 20,
},
WeatherCondition.cloudy: {
"snow": 40,
"cloudy": 35,
"fog": 25,
},
WeatherCondition.rain: {
"snow": 40,
"cloudy": 40,
"fog": 20,
},
WeatherCondition.storm: {
"snow": 60,
"cloudy": 40,
},
WeatherCondition.snow: {
"snow": 50,
"cloudy": 30,
"fog": 20,
},
WeatherCondition.fog: {
"fog": 40,
"cloudy": 40,
"snow": 20,
},
},
}
def advance_weather(
current: WeatherState,
season: str = "summer",
rng: random.Random | None = None,
climate: str = "temperate",
) -> WeatherState:
"""Advance weather by one step (one game hour)."""
if rng is None:
rng = random.Random()
# Get climate profile
profile = CLIMATE_PROFILES.get(climate, CLIMATE_PROFILES["temperate"])
# Get transition weights for current condition
transitions = profile.get(current.condition, {})
# Filter out snow if not winter/autumn
if season not in ("winter", "autumn"):
transitions = {k: v for k, v in transitions.items() if k != "snow"}
# If no valid transitions, stay in current condition
if not transitions:
return WeatherState(
condition=current.condition, intensity=rng.uniform(0.0, 1.0)
)
# Weighted random choice
conditions = list(transitions.keys())
weights = list(transitions.values())
# Convert string keys to WeatherCondition enums
resolved_conditions = []
for cond in conditions:
if isinstance(cond, str):
resolved_conditions.append(WeatherCondition[cond])
else:
resolved_conditions.append(cond)
new_condition = rng.choices(resolved_conditions, weights=weights, k=1)[0]
# Generate appropriate intensity for the new condition
if new_condition == WeatherCondition.clear:
new_intensity = 0.5 # Clear has no meaningful intensity variation
elif new_condition == WeatherCondition.storm:
new_intensity = rng.uniform(0.5, 1.0) # Storms are always intense
else:
new_intensity = rng.uniform(0.0, 1.0)
return WeatherState(condition=new_condition, intensity=new_intensity)
# Global weather state
_current_weather: WeatherState | None = None
def init_weather(
condition: WeatherCondition = WeatherCondition.clear, intensity: float = 0.5
) -> None:
"""Initialize the global weather state.
Args:
condition: Initial weather condition
intensity: Initial intensity (0.0 to 1.0)
"""
global _current_weather
_current_weather = WeatherState(condition=condition, intensity=intensity)
def get_current_weather() -> WeatherState:
"""Get the current global weather state.
Returns:
Current weather state (defaults to clear if not initialized)
"""
if _current_weather is None:
return WeatherState(condition=WeatherCondition.clear, intensity=0.5)
return _current_weather
def tick_weather(season: str = "summer", climate: str = "temperate") -> None:
"""Advance weather by one step. Called once per game hour.
Args:
season: Current season
climate: Climate profile to use
"""
global _current_weather
current = get_current_weather()
_current_weather = advance_weather(current, season=season, climate=climate)
def get_weather_ambience(condition: WeatherCondition) -> list[str]:
"""Return ambient messages appropriate for the weather."""
if condition == WeatherCondition.rain:
return [
"rain patters on the ground around you.",
"water drips from above.",
"you hear the steady rhythm of rainfall.",
]
elif condition == WeatherCondition.storm:
return [
"thunder cracks overhead.",
"lightning flashes in the distance.",
"the wind howls fiercely.",
]
elif condition == WeatherCondition.snow:
return [
"snowflakes drift silently down.",
"the world is muffled under falling snow.",
]
elif condition == WeatherCondition.fog:
return [
"mist swirls around your feet.",
"shapes loom and fade in the fog.",
]
else:
# clear or cloudy: no extra ambience
return []

242
tests/test_command_craft.py Normal file
View file

@ -0,0 +1,242 @@
"""Tests for crafting commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.crafting import cmd_craft, cmd_recipes
from mudlib.crafting import Recipe, recipes
from mudlib.player import Player
from mudlib.things import ThingTemplate, spawn_thing, thing_templates
from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
@pytest.fixture(autouse=True)
def _clean_registries():
"""Snapshot and restore registries to prevent test leakage."""
saved_zones = dict(zone_registry)
saved_templates = dict(thing_templates)
saved_recipes = dict(recipes)
zone_registry.clear()
thing_templates.clear()
recipes.clear()
yield
zone_registry.clear()
zone_registry.update(saved_zones)
thing_templates.clear()
thing_templates.update(saved_templates)
recipes.clear()
recipes.update(saved_recipes)
def _make_zone(name="overworld", width=20, height=20):
"""Create a test zone."""
terrain = [["." for _ in range(width)] for _ in range(height)]
zone = Zone(
name=name,
description=name,
width=width,
height=height,
terrain=terrain,
toroidal=True,
)
register_zone(name, zone)
return zone
def _make_player(name="tester", zone=None, x=5, y=5):
"""Create a test player with mock writer."""
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
return Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
@pytest.mark.asyncio
async def test_craft_success():
"""Craft with ingredients: consumed, result in inventory."""
zone = _make_zone()
player = _make_player(zone=zone)
# Register templates
plank_template = ThingTemplate(name="plank", description="A wooden plank")
nail_template = ThingTemplate(name="nail", description="A small nail")
table_template = ThingTemplate(name="table", description="A sturdy table")
thing_templates["plank"] = plank_template
thing_templates["nail"] = nail_template
thing_templates["table"] = table_template
# Add ingredients to player inventory
plank1 = spawn_thing(plank_template, player)
plank2 = spawn_thing(plank_template, player)
nail1 = spawn_thing(nail_template, player)
# Register recipe
recipe = Recipe(
name="wooden_table",
description="Craft a table",
ingredients=["plank", "plank", "nail"],
result="table",
)
recipes["wooden_table"] = recipe
# Craft the item
await cmd_craft(player, "wooden_table")
# Check success message was sent
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "table" in output.lower()
# Check ingredients were consumed
inventory = player.contents
assert plank1 not in inventory
assert plank2 not in inventory
assert nail1 not in inventory
# Check result was added to inventory
table_in_inventory = [obj for obj in inventory if obj.name == "table"]
assert len(table_in_inventory) == 1
@pytest.mark.asyncio
async def test_craft_missing_ingredients():
"""Error message listing what's missing."""
zone = _make_zone()
player = _make_player(zone=zone)
# Register templates
plank_template = ThingTemplate(name="plank", description="A wooden plank")
table_template = ThingTemplate(name="table", description="A sturdy table")
thing_templates["plank"] = plank_template
thing_templates["table"] = table_template
# Add only one plank (recipe needs two)
spawn_thing(plank_template, player)
# Register recipe needing two planks
recipe = Recipe(
name="wooden_table",
description="Craft a table",
ingredients=["plank", "plank"],
result="table",
)
recipes["wooden_table"] = recipe
# Try to craft
await cmd_craft(player, "wooden_table")
# Check error message
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "missing" in output.lower() or "need" in output.lower()
@pytest.mark.asyncio
async def test_craft_unknown_recipe():
"""Error for nonexistent recipe."""
zone = _make_zone()
player = _make_player(zone=zone)
await cmd_craft(player, "nonexistent_recipe")
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "unknown" in output.lower() or "not found" in output.lower()
@pytest.mark.asyncio
async def test_craft_no_args():
"""Error with usage when no args provided."""
zone = _make_zone()
player = _make_player(zone=zone)
await cmd_craft(player, "")
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "usage" in output.lower() or "craft" in output.lower()
@pytest.mark.asyncio
async def test_craft_unknown_result_template():
"""Recipe result template not in thing_templates."""
zone = _make_zone()
player = _make_player(zone=zone)
# Register template for ingredient
plank_template = ThingTemplate(name="plank", description="A wooden plank")
thing_templates["plank"] = plank_template
# Add ingredient to inventory
spawn_thing(plank_template, player)
# Register recipe with unknown result template
recipe = Recipe(
name="broken_recipe",
description="This recipe has a broken result",
ingredients=["plank"],
result="unknown_item", # This template doesn't exist
)
recipes["broken_recipe"] = recipe
# Try to craft
await cmd_craft(player, "broken_recipe")
# Check error message
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "error" in output.lower() or "unknown" in output.lower()
@pytest.mark.asyncio
async def test_recipes_list():
"""Shows available recipes."""
zone = _make_zone()
player = _make_player(zone=zone)
# Register some recipes
recipes["wooden_table"] = Recipe(
name="wooden_table",
description="Craft a table",
ingredients=["plank", "plank"],
result="table",
)
recipes["wooden_chair"] = Recipe(
name="wooden_chair",
description="Craft a chair",
ingredients=["plank"],
result="chair",
)
await cmd_recipes(player, "")
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "wooden_table" in output.lower()
assert "wooden_chair" in output.lower()
@pytest.mark.asyncio
async def test_recipes_detail():
"""Shows specific recipe details."""
zone = _make_zone()
player = _make_player(zone=zone)
# Register a recipe
recipes["wooden_table"] = Recipe(
name="wooden_table",
description="Craft a sturdy table",
ingredients=["plank", "plank", "nail", "nail"],
result="table",
)
await cmd_recipes(player, "wooden_table")
assert player.writer.write.called
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "wooden_table" in output.lower()
assert "plank" in output.lower()
assert "nail" in output.lower()
assert "table" in output.lower()

View file

@ -0,0 +1,151 @@
"""Tests for the describe command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.describe import cmd_describe
from mudlib.housing import init_housing
from mudlib.player import Player
from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
@pytest.fixture(autouse=True)
def _clean_registries():
saved = dict(zone_registry)
zone_registry.clear()
yield
zone_registry.clear()
zone_registry.update(saved)
@pytest.fixture
def _init_housing(tmp_path):
from mudlib.store import create_account, init_db
init_housing(tmp_path / "player_zones")
init_db(tmp_path / "test.db")
# Create accounts for test players
for name in ["alice", "bob", "charlie"]:
create_account(name, "testpass")
def _make_home_zone(player_name="alice"):
name = f"home:{player_name}"
terrain = []
for y in range(9):
row = []
for x in range(9):
if x == 0 or x == 8 or y == 0 or y == 8:
row.append("#")
else:
row.append(".")
terrain.append(row)
zone = Zone(
name=name,
description=f"{player_name}'s home",
width=9,
height=9,
terrain=terrain,
toroidal=False,
impassable={"#", "^", "~"},
spawn_x=4,
spawn_y=4,
safe=True,
)
register_zone(name, zone)
return zone
def _make_player(name="alice", zone=None, x=4, y=4):
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
p = Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
p.home_zone = f"home:{name}"
return p
@pytest.mark.asyncio
async def test_describe_sets_description(_init_housing):
"""describe <text> sets zone.description."""
home = _make_home_zone("alice")
player = _make_player("alice", zone=home)
await cmd_describe(player, "a cozy cottage by the sea")
assert home.description == "a cozy cottage by the sea"
@pytest.mark.asyncio
async def test_describe_not_in_home_zone(_init_housing):
"""describe fails if not in player's own home zone."""
_make_home_zone("alice")
other_zone = Zone(
name="overworld",
description="The world",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
toroidal=True,
)
register_zone("overworld", other_zone)
player = _make_player("alice", zone=other_zone)
await cmd_describe(player, "trying to describe the overworld")
# Should show error message
assert player.writer.write.called
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "only" in output.lower() and "home" in output.lower()
@pytest.mark.asyncio
async def test_describe_no_args_shows_current(_init_housing):
"""describe with no args shows current description."""
home = _make_home_zone("alice")
home.description = "a warm and welcoming place"
player = _make_player("alice", zone=home)
await cmd_describe(player, "")
assert player.writer.write.called
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "a warm and welcoming place" in output
@pytest.mark.asyncio
async def test_describe_saves_zone(_init_housing):
"""describe saves the zone to disk."""
import tomllib
from mudlib.housing import _zone_path
home = _make_home_zone("alice")
player = _make_player("alice", zone=home)
await cmd_describe(player, "a newly described home")
# Check TOML file was saved
zone_path = _zone_path("alice")
assert zone_path.exists()
with open(zone_path, "rb") as f:
data = tomllib.load(f)
assert data["description"] == "a newly described home"
@pytest.mark.asyncio
async def test_describe_multiword(_init_housing):
"""describe handles multiword descriptions."""
home = _make_home_zone("bob")
player = _make_player("bob", zone=home)
await cmd_describe(player, "a cozy cottage with warm lighting")
assert home.description == "a cozy cottage with warm lighting"
assert player.writer.write.called
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "description" in output.lower() or "set" in output.lower()

162
tests/test_command_home.py Normal file
View file

@ -0,0 +1,162 @@
"""Tests for the home command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.home import cmd_home
from mudlib.housing import init_housing
from mudlib.player import Player
from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone, zone_registry
@pytest.fixture(autouse=True)
def _clean_zone_registry():
saved = dict(zone_registry)
yield
zone_registry.clear()
zone_registry.update(saved)
@pytest.fixture
def _init_housing(tmp_path):
from mudlib.store import create_account, init_db
init_housing(tmp_path / "player_zones")
init_db(tmp_path / "test.db")
# Create accounts for test players
for name in ["alice", "bob", "charlie", "diane", "eve", "frank", "grace"]:
create_account(name, "testpass")
def _make_zone(name="overworld", width=20, height=20):
terrain = [["." for _ in range(width)] for _ in range(height)]
zone = Zone(
name=name,
description=name,
width=width,
height=height,
terrain=terrain,
toroidal=True,
)
register_zone(name, zone)
return zone
def _make_player(name="tester", zone=None, x=5, y=5):
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
return Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
@pytest.mark.asyncio
async def test_home_creates_zone(_init_housing):
"""Player with no home calls home and gets teleported to new zone."""
overworld = _make_zone("overworld")
player = _make_player("alice", zone=overworld)
# Initially no home zone exists
assert get_zone("home:alice") is None
await cmd_home(player, "")
# Now home zone exists and player is in it
home = get_zone("home:alice")
assert home is not None
assert player.location is home
assert player.home_zone == "home:alice"
assert player.return_location == ("overworld", 5, 5)
@pytest.mark.asyncio
async def test_home_return(_init_housing):
"""Player goes home then home return, ends up back where they were."""
overworld = _make_zone("overworld")
player = _make_player("bob", zone=overworld, x=10, y=15)
# Go home
await cmd_home(player, "")
home = get_zone("home:bob")
assert player.location is home
# Return
await cmd_home(player, "return")
assert player.location is overworld
assert player.x == 10
assert player.y == 15
assert player.return_location is None
@pytest.mark.asyncio
async def test_home_return_without_location(_init_housing):
"""home return with no saved location shows error."""
overworld = _make_zone("overworld")
player = _make_player("charlie", zone=overworld)
# No return location set
assert player.return_location is None
await cmd_home(player, "return")
# Should get error message
assert player.writer.write.called
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "nowhere" in output.lower()
@pytest.mark.asyncio
async def test_home_already_at_home(_init_housing):
"""Calling home while already at home doesn't overwrite return_location."""
overworld = _make_zone("overworld")
player = _make_player("diane", zone=overworld, x=7, y=8)
# Go home first time
await cmd_home(player, "")
assert player.return_location == ("overworld", 7, 8)
# Call home again while at home
await cmd_home(player, "")
# return_location should still point to overworld, not home
assert player.return_location == ("overworld", 7, 8)
@pytest.mark.asyncio
async def test_home_invalid_args(_init_housing):
"""home foo shows usage."""
overworld = _make_zone("overworld")
player = _make_player("eve", zone=overworld)
await cmd_home(player, "foo")
assert player.writer.write.called
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "usage" in output.lower()
@pytest.mark.asyncio
async def test_home_departure_arrival_messages(_init_housing):
"""Check nearby messages are sent."""
from mudlib.player import players
players.clear()
overworld = _make_zone("overworld")
player = _make_player("frank", zone=overworld, x=10, y=10)
other = _make_player("grace", zone=overworld, x=10, y=10)
players["frank"] = player
players["grace"] = other
# Go home
await cmd_home(player, "")
# Other player should have seen departure message
assert other.writer.write.called
output = "".join(c[0][0] for c in other.writer.write.call_args_list)
assert "frank" in output.lower()
assert "vanishes" in output.lower()
players.clear()

View file

@ -12,6 +12,15 @@ from mudlib.render.ansi import RESET
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def _clean_test_commands():
"""Snapshot and restore command registry to prevent test leakage."""
snapshot = dict(commands._registry)
yield
commands._registry.clear()
commands._registry.update(snapshot)
@pytest.fixture @pytest.fixture
def mock_writer(): def mock_writer():
writer = MagicMock() writer = MagicMock()

99
tests/test_crafting.py Normal file
View file

@ -0,0 +1,99 @@
"""Tests for the crafting recipe system."""
import tempfile
from pathlib import Path
import pytest
from mudlib.crafting import Recipe, load_recipe, load_recipes, recipes
from mudlib.things import thing_templates
from mudlib.zones import zone_registry
@pytest.fixture(autouse=True)
def _clean_registries():
"""Snapshot and restore registries to prevent test leakage."""
saved_zones = dict(zone_registry)
saved_templates = dict(thing_templates)
saved_recipes = dict(recipes)
zone_registry.clear()
thing_templates.clear()
recipes.clear()
yield
zone_registry.clear()
zone_registry.update(saved_zones)
thing_templates.clear()
thing_templates.update(saved_templates)
recipes.clear()
recipes.update(saved_recipes)
def test_load_recipe_from_toml():
"""Parse a recipe TOML file."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "wooden_table"
description = "Craft a sturdy table from planks and nails"
ingredients = ["plank", "plank", "plank", "nail", "nail"]
result = "table"
""")
path = Path(f.name)
try:
recipe = load_recipe(path)
assert recipe.name == "wooden_table"
assert recipe.description == "Craft a sturdy table from planks and nails"
assert recipe.ingredients == ["plank", "plank", "plank", "nail", "nail"]
assert recipe.result == "table"
finally:
path.unlink()
def test_load_recipes_directory():
"""Load all recipes from a directory."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
# Create two recipe files
(tmp_path / "table.toml").write_text("""
name = "wooden_table"
description = "Craft a table"
ingredients = ["plank", "plank"]
result = "table"
""")
(tmp_path / "chair.toml").write_text("""
name = "wooden_chair"
description = "Craft a chair"
ingredients = ["plank"]
result = "chair"
""")
loaded = load_recipes(tmp_path)
assert len(loaded) == 2
assert "wooden_table" in loaded
assert "wooden_chair" in loaded
assert loaded["wooden_table"].result == "table"
assert loaded["wooden_chair"].result == "chair"
def test_recipe_fields():
"""Verify all recipe fields are populated correctly."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "test_recipe"
description = "A test recipe"
ingredients = ["item_a", "item_b", "item_c"]
result = "item_result"
""")
path = Path(f.name)
try:
recipe = load_recipe(path)
assert isinstance(recipe, Recipe)
assert recipe.name == "test_recipe"
assert recipe.description == "A test recipe"
assert recipe.ingredients == ["item_a", "item_b", "item_c"]
assert recipe.result == "item_result"
assert len(recipe.ingredients) == 3
finally:
path.unlink()

96
tests/test_creation.py Normal file
View file

@ -0,0 +1,96 @@
"""Tests for character creation flow."""
from collections import deque
import pytest
from mudlib.creation import character_creation
async def make_io(inputs):
"""Create mock read/write functions."""
input_queue = deque(inputs)
output = []
async def read_func():
if input_queue:
return input_queue.popleft()
return None
async def write_func(msg):
output.append(msg)
return read_func, write_func, output
@pytest.mark.asyncio
async def test_character_creation_with_description():
"""Test providing a description returns it."""
read_func, write_func, output = await make_io(
["A wandering swordsman with a mysterious past."]
)
result = await character_creation("TestPlayer", read_func, write_func)
assert result["description"] == "A wandering swordsman with a mysterious past."
assert any("Character Creation" in msg for msg in output)
assert any("Description:" in msg for msg in output)
assert any("A wandering swordsman with a mysterious past." in msg for msg in output)
@pytest.mark.asyncio
async def test_character_creation_empty_input():
"""Test empty input returns empty string."""
read_func, write_func, output = await make_io([""])
result = await character_creation("TestPlayer", read_func, write_func)
assert result["description"] == ""
assert any("No description set" in msg for msg in output)
@pytest.mark.asyncio
async def test_character_creation_none_input():
"""Test None input (disconnect) returns empty string."""
read_func, write_func, output = await make_io([None])
result = await character_creation("TestPlayer", read_func, write_func)
assert result["description"] == ""
assert any("No description set" in msg for msg in output)
@pytest.mark.asyncio
async def test_character_creation_prompts():
"""Test the output includes the prompts."""
read_func, write_func, output = await make_io(["Test description"])
await character_creation("TestPlayer", read_func, write_func)
output_text = "".join(output)
assert "Character Creation" in output_text
assert "Describe yourself" in output_text
assert "This is what others see when they look at you" in output_text
assert "Description:" in output_text
@pytest.mark.asyncio
async def test_character_creation_confirmation():
"""Test the confirmation message includes the description."""
read_func, write_func, output = await make_io(["A fierce warrior"])
await character_creation("TestPlayer", read_func, write_func)
output_text = "".join(output)
assert "You will be known as:" in output_text
assert "A fierce warrior" in output_text
@pytest.mark.asyncio
async def test_character_creation_strips_whitespace():
"""Test that leading/trailing whitespace is stripped."""
read_func, write_func, output = await make_io([" A nimble thief "])
result = await character_creation("TestPlayer", read_func, write_func)
assert result["description"] == "A nimble thief"

263
tests/test_furnish.py Normal file
View file

@ -0,0 +1,263 @@
"""Tests for furnish and unfurnish commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.housing import init_housing
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
@pytest.fixture(autouse=True)
def _clean_registries():
"""Clear zone registry between tests."""
saved = dict(zone_registry)
zone_registry.clear()
yield
zone_registry.clear()
zone_registry.update(saved)
def _make_zone(name="overworld", width=20, height=20):
"""Create a zone for testing."""
terrain = [["." for _ in range(width)] for _ in range(height)]
zone = Zone(
name=name,
description=name,
width=width,
height=height,
terrain=terrain,
toroidal=True,
)
register_zone(name, zone)
return zone
def _make_player(name="tester", zone=None, x=5, y=5):
"""Create a player with mock writer."""
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
return Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
@pytest.mark.asyncio
async def test_furnish_places_item(tmp_path):
"""furnish moves an item from inventory to the zone at player position."""
from mudlib.commands.furnish import cmd_furnish
init_housing(tmp_path)
# Create home zone and player
home_zone = _make_zone("home:alice", width=9, height=9)
player = _make_player("alice", zone=home_zone, x=4, y=4)
player.home_zone = "home:alice"
# Give player a chair
chair = Thing(name="chair", description="A wooden chair")
chair.move_to(player)
# Furnish the chair
await cmd_furnish(player, "chair")
# Chair should be in the zone at player position
assert chair.location is home_zone
assert chair.x == 4
assert chair.y == 4
assert chair not in player.contents
# Player should get feedback
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "chair" in output.lower()
@pytest.mark.asyncio
async def test_furnish_not_in_home_zone():
"""furnish fails if player is not in their home zone."""
from mudlib.commands.furnish import cmd_furnish
overworld = _make_zone("overworld")
player = _make_player("alice", zone=overworld)
player.home_zone = "home:alice"
chair = Thing(name="chair")
chair.move_to(player)
await cmd_furnish(player, "chair")
# Chair should still be in inventory
assert chair.location is player
assert chair in player.contents
# Player should get error message
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "home zone" in output.lower()
@pytest.mark.asyncio
async def test_furnish_item_not_in_inventory():
"""furnish fails if item is not in player inventory."""
from mudlib.commands.furnish import cmd_furnish
home_zone = _make_zone("home:alice", width=9, height=9)
player = _make_player("alice", zone=home_zone)
player.home_zone = "home:alice"
await cmd_furnish(player, "chair")
# Player should get error message
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "don't have" in output.lower() or "not carrying" in output.lower()
@pytest.mark.asyncio
async def test_furnish_no_args():
"""furnish fails with usage message if no args provided."""
from mudlib.commands.furnish import cmd_furnish
home_zone = _make_zone("home:alice", width=9, height=9)
player = _make_player("alice", zone=home_zone)
player.home_zone = "home:alice"
await cmd_furnish(player, "")
# Player should get usage message
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "usage" in output.lower() or "furnish" in output.lower()
@pytest.mark.asyncio
async def test_unfurnish_picks_up_item(tmp_path):
"""unfurnish moves furniture from zone to player inventory."""
from mudlib.commands.furnish import cmd_unfurnish
init_housing(tmp_path)
# Create home zone with furniture
home_zone = _make_zone("home:bob", width=9, height=9)
player = _make_player("bob", zone=home_zone, x=4, y=4)
player.home_zone = "home:bob"
# Place a table at player position
table = Thing(name="table", description="A wooden table")
table.move_to(home_zone, x=4, y=4)
# Unfurnish the table
await cmd_unfurnish(player, "table")
# Table should be in inventory
assert table.location is player
assert table in player.contents
assert table not in home_zone._contents
# Player should get feedback
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "table" in output.lower()
@pytest.mark.asyncio
async def test_unfurnish_not_in_home_zone():
"""unfurnish fails if player is not in their home zone."""
from mudlib.commands.furnish import cmd_unfurnish
overworld = _make_zone("overworld")
player = _make_player("bob", zone=overworld)
player.home_zone = "home:bob"
# Place a table
table = Thing(name="table")
table.move_to(overworld, x=5, y=5)
await cmd_unfurnish(player, "table")
# Table should still be on ground
assert table.location is overworld
assert table not in player.contents
# Player should get error message
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "home zone" in output.lower()
@pytest.mark.asyncio
async def test_unfurnish_nothing_at_position():
"""unfurnish fails if no matching furniture at player position."""
from mudlib.commands.furnish import cmd_unfurnish
home_zone = _make_zone("home:bob", width=9, height=9)
player = _make_player("bob", zone=home_zone, x=4, y=4)
player.home_zone = "home:bob"
# No furniture at position
await cmd_unfurnish(player, "table")
# Player should get error message
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert (
"no" in output.lower()
or "don't see" in output.lower()
or "can't find" in output.lower()
)
@pytest.mark.asyncio
async def test_unfurnish_no_args():
"""unfurnish fails with usage message if no args provided."""
from mudlib.commands.furnish import cmd_unfurnish
home_zone = _make_zone("home:bob", width=9, height=9)
player = _make_player("bob", zone=home_zone)
player.home_zone = "home:bob"
await cmd_unfurnish(player, "")
# Player should get usage message
player.writer.write.assert_called()
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "usage" in output.lower() or "unfurnish" in output.lower()
@pytest.mark.asyncio
async def test_furnish_saves_zone(tmp_path):
"""furnish persists furniture to the zone TOML file."""
import tomllib
from mudlib.commands.furnish import cmd_furnish
init_housing(tmp_path)
# Create home zone and player
home_zone = _make_zone("home:charlie", width=9, height=9)
player = _make_player("charlie", zone=home_zone, x=4, y=4)
player.home_zone = "home:charlie"
# Give player a lamp
lamp = Thing(name="lamp", description="A brass lamp")
lamp.move_to(player)
# Furnish it
await cmd_furnish(player, "lamp")
# Check TOML file
zone_file = tmp_path / "charlie.toml"
assert zone_file.exists()
with open(zone_file, "rb") as f:
data = tomllib.load(f)
# Should have furniture entry
furniture = data.get("furniture", [])
assert len(furniture) == 1
assert furniture[0]["template"] == "lamp"
assert furniture[0]["x"] == 4
assert furniture[0]["y"] == 4

271
tests/test_furniture.py Normal file
View file

@ -0,0 +1,271 @@
"""Tests for furniture persistence in home zones."""
import logging
import tomllib
import pytest
from mudlib.entity import Entity
from mudlib.housing import (
create_home_zone,
init_housing,
load_home_zone,
save_home_zone,
)
from mudlib.portal import Portal
from mudlib.thing import Thing
from mudlib.things import ThingTemplate, spawn_thing, thing_templates
from mudlib.zones import zone_registry
@pytest.fixture(autouse=True)
def _clean_registries():
"""Clear registries between tests."""
saved_zones = dict(zone_registry)
saved_templates = dict(thing_templates)
zone_registry.clear()
thing_templates.clear()
yield
zone_registry.clear()
zone_registry.update(saved_zones)
thing_templates.clear()
thing_templates.update(saved_templates)
def test_save_furniture_in_zone_toml(tmp_path):
"""save_home_zone() writes furniture to TOML."""
init_housing(tmp_path)
# Create zone
zone = create_home_zone("Alice")
# Add a thing template
table_template = ThingTemplate(
name="table",
description="A wooden table",
portable=False,
)
thing_templates["table"] = table_template
# Spawn furniture in the zone
spawn_thing(table_template, zone, x=3, y=4)
# Save
save_home_zone("Alice", zone)
# Read the TOML file
zone_file = tmp_path / "alice.toml"
with open(zone_file, "rb") as f:
data = tomllib.load(f)
# Check furniture section
assert "furniture" in data
assert len(data["furniture"]) == 1
assert data["furniture"][0]["template"] == "table"
assert data["furniture"][0]["x"] == 3
assert data["furniture"][0]["y"] == 4
def test_load_furniture_from_toml(tmp_path):
"""load_home_zone() spawns furniture from TOML."""
init_housing(tmp_path)
# Create zone to get the file
_ = create_home_zone("Bob")
zone_file = tmp_path / "bob.toml"
# Add furniture entries to the TOML
with open(zone_file) as f:
content = f.read()
content += """
[[furniture]]
template = "chair"
x = 5
y = 6
"""
with open(zone_file, "w") as f:
f.write(content)
# Add template
chair_template = ThingTemplate(
name="chair",
description="A wooden chair",
portable=True,
)
thing_templates["chair"] = chair_template
# Clear registry and load
zone_registry.clear()
loaded = load_home_zone("Bob")
assert loaded is not None
# Check that furniture was spawned
chairs = [
obj
for obj in loaded._contents
if isinstance(obj, Thing) and obj.name == "chair"
]
assert len(chairs) == 1
assert chairs[0].x == 5
assert chairs[0].y == 6
assert chairs[0].description == "A wooden chair"
def test_furniture_round_trip(tmp_path):
"""Furniture survives save -> load cycle."""
init_housing(tmp_path)
# Create zone
zone = create_home_zone("Charlie")
# Add templates
table_template = ThingTemplate(name="table", description="A table", portable=False)
chair_template = ThingTemplate(name="chair", description="A chair", portable=True)
thing_templates["table"] = table_template
thing_templates["chair"] = chair_template
# Spawn furniture
spawn_thing(table_template, zone, x=3, y=4)
spawn_thing(chair_template, zone, x=3, y=5)
# Save
save_home_zone("Charlie", zone)
# Clear registry and load
zone_registry.clear()
loaded = load_home_zone("Charlie")
assert loaded is not None
# Check furniture
tables = [obj for obj in loaded._contents if obj.name == "table"]
chairs = [obj for obj in loaded._contents if obj.name == "chair"]
assert len(tables) == 1
assert tables[0].x == 3
assert tables[0].y == 4
assert len(chairs) == 1
assert chairs[0].x == 3
assert chairs[0].y == 5
def test_multiple_furniture_items(tmp_path):
"""Multiple furniture items save and load correctly."""
init_housing(tmp_path)
zone = create_home_zone("Dave")
# Add templates
chair_template = ThingTemplate(name="chair", description="A chair", portable=True)
thing_templates["chair"] = chair_template
# Spawn multiple chairs
spawn_thing(chair_template, zone, x=2, y=2)
spawn_thing(chair_template, zone, x=3, y=2)
spawn_thing(chair_template, zone, x=4, y=2)
# Save
save_home_zone("Dave", zone)
# Load
zone_registry.clear()
loaded = load_home_zone("Dave")
assert loaded is not None
chairs = [obj for obj in loaded._contents if obj.name == "chair"]
assert len(chairs) == 3
# Check positions
positions = {(c.x, c.y) for c in chairs}
assert positions == {(2, 2), (3, 2), (4, 2)}
def test_load_unknown_template_skips(tmp_path, caplog):
"""Unknown template name in TOML is skipped with warning."""
caplog.set_level(logging.WARNING)
init_housing(tmp_path)
# Create zone
_ = create_home_zone("Eve")
zone_file = tmp_path / "eve.toml"
# Add furniture with unknown template
with open(zone_file) as f:
content = f.read()
content += """
[[furniture]]
template = "unknown_thing"
x = 1
y = 1
"""
with open(zone_file, "w") as f:
f.write(content)
# Load
zone_registry.clear()
loaded = load_home_zone("Eve")
assert loaded is not None
# Check that no furniture was spawned
things = [obj for obj in loaded._contents if isinstance(obj, Thing)]
assert len(things) == 0
# Check that warning was logged
assert "unknown_thing" in caplog.text.lower()
def test_save_excludes_entities(tmp_path):
"""Entities in zone are NOT saved as furniture."""
init_housing(tmp_path)
zone = create_home_zone("Frank")
# Add an entity to the zone
_ = Entity(name="test_mob", location=zone, x=5, y=5)
# Save
save_home_zone("Frank", zone)
# Read the TOML
zone_file = tmp_path / "frank.toml"
with open(zone_file, "rb") as f:
data = tomllib.load(f)
# Furniture section should not exist or be empty
furniture = data.get("furniture", [])
assert len(furniture) == 0
def test_save_excludes_portals(tmp_path):
"""Portals are NOT saved as furniture."""
init_housing(tmp_path)
zone = create_home_zone("Grace")
# Add a portal to the zone
_ = Portal(
name="exit",
description="An exit",
location=zone,
x=1,
y=1,
target_zone="overworld",
target_x=10,
target_y=10,
)
# Save
save_home_zone("Grace", zone)
# Read the TOML
zone_file = tmp_path / "grace.toml"
with open(zone_file, "rb") as f:
data = tomllib.load(f)
# Furniture section should not exist or be empty
furniture = data.get("furniture", [])
assert len(furniture) == 0

89
tests/test_gametime.py Normal file
View file

@ -0,0 +1,89 @@
"""Tests for the game time system."""
import time
import pytest
from mudlib.gametime import GameTime, get_game_day, get_game_hour, init_game_time
@pytest.fixture(autouse=True)
def _reset_globals():
yield
import mudlib.gametime
mudlib.gametime._game_time = None
def test_get_game_hour_at_epoch():
"""Game hour should be 0 at epoch."""
epoch = time.time()
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_hour() == 0
def test_get_game_hour_after_one_hour():
"""Game hour should advance after one real minute."""
epoch = time.time() - 60 # one real minute ago
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_hour() == 1
def test_get_game_hour_wraps_at_24():
"""Game hour should wrap from 23 back to 0."""
epoch = time.time() - (24 * 60) # 24 real minutes ago
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_hour() == 0
def test_get_game_day_at_epoch():
"""Game day should be 0 at epoch."""
epoch = time.time()
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_day() == 0
def test_get_game_day_after_24_hours():
"""Game day should be 1 after 24 game hours."""
epoch = time.time() - (24 * 60) # 24 real minutes ago = 24 game hours
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_day() == 1
def test_get_game_day_after_48_hours():
"""Game day should be 2 after 48 game hours."""
epoch = time.time() - (48 * 60) # 48 real minutes ago
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_day() == 2
def test_get_game_day_partial_day():
"""Game day should not increment until 24 hours have passed."""
epoch = time.time() - (12 * 60) # 12 real minutes ago = 12 game hours
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
assert gt.get_game_day() == 0
def test_global_get_game_hour():
"""Global get_game_hour should work after init."""
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
hour = get_game_hour()
assert 0 <= hour <= 23
def test_global_get_game_day():
"""Global get_game_day should work after init."""
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
day = get_game_day()
assert day >= 0
def test_global_get_game_day_raises_if_not_initialized():
"""Global get_game_day should raise if not initialized."""
import mudlib.gametime
# reset global state
mudlib.gametime._game_time = None
with pytest.raises(RuntimeError, match="Game time not initialized"):
get_game_day()

268
tests/test_housing.py Normal file
View file

@ -0,0 +1,268 @@
"""Tests for player housing system."""
import tomllib
import pytest
from mudlib.housing import (
HOME_HEIGHT,
HOME_SPAWN_X,
HOME_SPAWN_Y,
HOME_WIDTH,
_home_zone_name,
create_home_zone,
get_or_create_home,
init_housing,
load_home_zone,
save_home_zone,
)
from mudlib.zones import get_zone, zone_registry
@pytest.fixture(autouse=True)
def _clean_zone_registry():
"""Clear zone registry between tests."""
saved = dict(zone_registry)
zone_registry.clear()
yield
zone_registry.clear()
zone_registry.update(saved)
def test_init_housing_creates_directory(tmp_path):
"""init_housing() creates the zones directory."""
zones_dir = tmp_path / "zones"
assert not zones_dir.exists()
init_housing(zones_dir)
assert zones_dir.exists()
assert zones_dir.is_dir()
def test_home_zone_name():
"""_home_zone_name() returns correct format."""
assert _home_zone_name("Alice") == "home:alice"
assert _home_zone_name("bob") == "home:bob"
assert _home_zone_name("Charlie") == "home:charlie"
def test_create_home_zone(tmp_path):
"""create_home_zone() creates a zone with correct properties."""
init_housing(tmp_path)
zone = create_home_zone("Alice")
# Check basic properties
assert zone.name == "home:alice"
assert zone.description == "Alice's home"
assert zone.width == HOME_WIDTH
assert zone.height == HOME_HEIGHT
assert zone.toroidal is False
assert zone.spawn_x == HOME_SPAWN_X
assert zone.spawn_y == HOME_SPAWN_Y
assert zone.safe is True
assert zone.impassable == {"#", "^", "~"}
# Check terrain dimensions
assert len(zone.terrain) == HOME_HEIGHT
assert all(len(row) == HOME_WIDTH for row in zone.terrain)
# Check border is walls
for x in range(HOME_WIDTH):
assert zone.terrain[0][x] == "#" # top
assert zone.terrain[HOME_HEIGHT - 1][x] == "#" # bottom
for y in range(HOME_HEIGHT):
assert zone.terrain[y][0] == "#" # left
assert zone.terrain[y][HOME_WIDTH - 1] == "#" # right
# Check interior is grass
for y in range(1, HOME_HEIGHT - 1):
for x in range(1, HOME_WIDTH - 1):
assert zone.terrain[y][x] == "."
# Check spawn point is passable
assert zone.terrain[HOME_SPAWN_Y][HOME_SPAWN_X] == "."
def test_create_registers_zone(tmp_path):
"""create_home_zone() registers the zone."""
init_housing(tmp_path)
zone = create_home_zone("Bob")
registered = get_zone("home:bob")
assert registered is zone
def test_save_home_zone(tmp_path):
"""save_home_zone() writes a valid TOML file."""
init_housing(tmp_path)
create_home_zone("Charlie")
zone_file = tmp_path / "charlie.toml"
assert zone_file.exists()
# Verify TOML is valid and contains expected data
with open(zone_file, "rb") as f:
data = tomllib.load(f)
assert data["name"] == "home:charlie"
assert data["description"] == "Charlie's home"
assert data["width"] == HOME_WIDTH
assert data["height"] == HOME_HEIGHT
assert data["toroidal"] is False
assert data["spawn_x"] == HOME_SPAWN_X
assert data["spawn_y"] == HOME_SPAWN_Y
assert data["safe"] is True
# Check terrain
rows = data["terrain"]["rows"]
assert len(rows) == HOME_HEIGHT
assert all(len(row) == HOME_WIDTH for row in rows)
# Check impassable
assert set(data["terrain"]["impassable"]["tiles"]) == {"#", "^", "~"}
def test_load_home_zone(tmp_path):
"""load_home_zone() reads a zone from disk."""
init_housing(tmp_path)
# Create and save a zone
_ = create_home_zone("Dave")
# Clear registry
zone_registry.clear()
# Load it back
loaded = load_home_zone("Dave")
assert loaded is not None
assert loaded.name == "home:dave"
assert loaded.description == "Dave's home"
assert loaded.width == HOME_WIDTH
assert loaded.height == HOME_HEIGHT
assert loaded.toroidal is False
assert loaded.spawn_x == HOME_SPAWN_X
assert loaded.spawn_y == HOME_SPAWN_Y
assert loaded.safe is True
assert loaded.impassable == {"#", "^", "~"}
def test_load_registers_zone(tmp_path):
"""load_home_zone() registers the zone."""
init_housing(tmp_path)
create_home_zone("Eve")
zone_registry.clear()
loaded = load_home_zone("Eve")
registered = get_zone("home:eve")
assert registered is loaded
def test_load_nonexistent_returns_none(tmp_path):
"""load_home_zone() returns None if file doesn't exist."""
init_housing(tmp_path)
result = load_home_zone("Nobody")
assert result is None
def test_round_trip(tmp_path):
"""Create -> save -> load produces equivalent zone."""
init_housing(tmp_path)
original = create_home_zone("Frank")
zone_registry.clear()
loaded = load_home_zone("Frank")
assert loaded is not None
# Compare all fields
assert loaded.name == original.name
assert loaded.description == original.description
assert loaded.width == original.width
assert loaded.height == original.height
assert loaded.toroidal == original.toroidal
assert loaded.spawn_x == original.spawn_x
assert loaded.spawn_y == original.spawn_y
assert loaded.safe == original.safe
assert loaded.impassable == original.impassable
# Compare terrain
assert len(loaded.terrain) == len(original.terrain)
for loaded_row, orig_row in zip(loaded.terrain, original.terrain, strict=True):
assert loaded_row == orig_row
def test_get_or_create_home_creates_new(tmp_path):
"""get_or_create_home() creates a zone on first call."""
init_housing(tmp_path)
zone = get_or_create_home("Grace")
assert zone.name == "home:grace"
assert zone.description == "Grace's home"
def test_get_or_create_home_returns_existing_from_registry(tmp_path):
"""get_or_create_home() returns existing zone from registry."""
init_housing(tmp_path)
first = get_or_create_home("Hank")
second = get_or_create_home("Hank")
assert second is first
def test_get_or_create_home_loads_from_disk(tmp_path):
"""get_or_create_home() loads from disk if not in registry."""
init_housing(tmp_path)
create_home_zone("Iris")
zone_registry.clear()
loaded = get_or_create_home("Iris")
assert loaded.name == "home:iris"
def test_case_insensitive_zone_names(tmp_path):
"""Zone names are lowercased consistently."""
init_housing(tmp_path)
zone1 = create_home_zone("JACK")
zone2 = create_home_zone("Jack")
zone3 = create_home_zone("jack")
# All should reference the same zone name
assert zone1.name == "home:jack"
assert zone2.name == "home:jack"
assert zone3.name == "home:jack"
def test_save_preserves_modifications(tmp_path):
"""save_home_zone() preserves modifications to terrain."""
init_housing(tmp_path)
zone = create_home_zone("Kate")
# Modify terrain
zone.terrain[2][2] = "~" # add water
zone.terrain[3][3] = "^" # add mountain
# Save modifications
save_home_zone("Kate", zone)
# Load and verify
zone_registry.clear()
loaded = load_home_zone("Kate")
assert loaded is not None
assert loaded.terrain[2][2] == "~"
assert loaded.terrain[3][3] == "^"

View file

@ -1,5 +1,6 @@
"""Tests for the look command with structured room display.""" """Tests for the look command with structured room display."""
import time
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -7,12 +8,24 @@ import pytest
from mudlib.commands import look # noqa: F401 from mudlib.commands import look # noqa: F401
from mudlib.commands.look import cmd_look from mudlib.commands.look import cmd_look
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.gametime import init_game_time
from mudlib.player import Player from mudlib.player import Player
from mudlib.portal import Portal from mudlib.portal import Portal
from mudlib.thing import Thing from mudlib.thing import Thing
from mudlib.weather import WeatherCondition, init_weather
from mudlib.zone import Zone from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def _reset_globals():
yield
import mudlib.gametime
import mudlib.weather
mudlib.gametime._game_time = None
mudlib.weather._current_weather = None
@pytest.fixture @pytest.fixture
def mock_writer(): def mock_writer():
writer = MagicMock() writer = MagicMock()
@ -203,3 +216,204 @@ async def test_look_nowhere(mock_reader, mock_writer):
output = get_output(player) output = get_output(player)
assert "You are nowhere." in output assert "You are nowhere." in output
@pytest.mark.asyncio
async def test_look_includes_atmosphere_clear():
"""Look should include atmosphere line with clear weather."""
# Initialize game time and weather
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.clear, intensity=0.5)
# Create player
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
output = get_output(player)
# Should see atmosphere line with period and season
assert "[" in output and "]" in output
# Should not have weather description for clear weather
# (the format is: sky. [period, season])
seasons = ["spring]", "summer]", "autumn]", "winter]"]
assert any(season in output for season in seasons)
@pytest.mark.asyncio
async def test_look_includes_atmosphere_with_rain():
"""Look should include atmosphere line with rain."""
# Initialize game time and weather
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.rain, intensity=0.5)
# Create player
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
output = get_output(player)
# Should see atmosphere line with rain description
assert "rain" in output.lower()
# Should see season tag
assert "[" in output and "]" in output
@pytest.mark.asyncio
async def test_look_atmosphere_between_where_and_viewport():
"""Atmosphere line should appear between Where header and viewport."""
# Initialize game time and weather
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.clear, intensity=0.5)
# Create player
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
output = get_output(player)
# Find positions of key sections
where_pos = output.find("Where:")
location_pos = output.find("Location:")
# Atmosphere should be between Where and Location
# Look for a bracket tag (season indicator)
bracket_pos = output.find("[")
assert where_pos < bracket_pos < location_pos
@pytest.mark.asyncio
async def test_look_night_reduces_viewport():
"""Look at night should show smaller viewport than day."""
# Set time to noon (hour 12) - epoch was 12 hours ago
# elapsed_game_hours = (time.time() - epoch) / 60 / 1.0 = 12
# so epoch = time.time() - 12 * 60
init_game_time(epoch=time.time() - 12 * 60, real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.clear, intensity=0.5)
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
day_output = get_output(player)
# Find viewport section (between atmosphere and Location:)
day_lines = day_output.split("\r\n")
# Viewport is after atmosphere (containing '[') and before Location:
viewport_start_idx = None
viewport_end_idx = None
for i, line in enumerate(day_lines):
if "[" in line and viewport_start_idx is None:
viewport_start_idx = i + 1
if line.startswith("Location:"):
viewport_end_idx = i
break
assert viewport_start_idx is not None
assert viewport_end_idx is not None
day_viewport_height = viewport_end_idx - viewport_start_idx
# Reset writer
mock_writer.reset_mock()
# Set time to night (hour 22) - epoch was 22 hours ago
init_game_time(epoch=time.time() - 22 * 60, real_minutes_per_game_hour=1.0)
await cmd_look(player, "")
night_output = get_output(player)
# Find viewport section
night_lines = night_output.split("\r\n")
viewport_start_idx = None
viewport_end_idx = None
for i, line in enumerate(night_lines):
if "[" in line and viewport_start_idx is None:
viewport_start_idx = i + 1
if line.startswith("Location:"):
viewport_end_idx = i
break
assert viewport_start_idx is not None
assert viewport_end_idx is not None
night_viewport_height = viewport_end_idx - viewport_start_idx
# Night should have fewer viewport lines (9 vs 11)
assert night_viewport_height < day_viewport_height
assert day_viewport_height == 11 # full viewport at noon
assert night_viewport_height == 9 # reduced viewport at night

View file

@ -26,6 +26,14 @@ def clear_mobs():
mob_templates.clear() mob_templates.clear()
@pytest.fixture(autouse=True)
def _reset_globals():
yield
import mudlib.gametime
mudlib.gametime._game_time = None
def test_schedule_entry_creation(): def test_schedule_entry_creation():
"""ScheduleEntry can be created with required fields.""" """ScheduleEntry can be created with required fields."""
entry = ScheduleEntry(hour=6, state="working") entry = ScheduleEntry(hour=6, state="working")

View file

@ -0,0 +1,94 @@
"""Tests for player description and home_zone fields."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
def test_player_default_description(mock_reader, mock_writer):
"""Test that Player has default empty description."""
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
assert player.description == ""
def test_player_default_home_zone(mock_reader, mock_writer):
"""Test that Player has default None home_zone."""
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
assert player.home_zone is None
def test_player_default_return_location(mock_reader, mock_writer):
"""Test that Player has default None return_location."""
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
assert player.return_location is None
def test_player_custom_description(mock_reader, mock_writer):
"""Test that Player can have custom description."""
player = Player(
name="Hero",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
description="A brave adventurer",
)
assert player.description == "A brave adventurer"
def test_player_custom_home_zone(mock_reader, mock_writer):
"""Test that Player can have custom home_zone."""
player = Player(
name="Hero",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
home_zone="residential",
)
assert player.home_zone == "residential"
def test_player_custom_return_location(mock_reader, mock_writer):
"""Test that Player can have custom return_location."""
player = Player(
name="Hero",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
return_location=("residential", 10, 20),
)
assert player.return_location == ("residential", 10, 20)
def test_player_all_housing_fields(mock_reader, mock_writer):
"""Test that Player can have all housing fields set together."""
player = Player(
name="Hero",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
description="A homeowner",
home_zone="residential",
return_location=("residential", 5, 15),
)
assert player.description == "A homeowner"
assert player.home_zone == "residential"
assert player.return_location == ("residential", 5, 15)

145
tests/test_render_room.py Normal file
View file

@ -0,0 +1,145 @@
"""Tests for room rendering functions."""
from mudlib.render.room import (
render_atmosphere,
render_entity_lines,
render_exits,
render_location,
render_nearby,
render_where,
)
class MockZone:
"""Mock zone for testing."""
def __init__(self, width=100, height=100, passable_tiles=None):
self.width = width
self.height = height
self.description = "Test Zone"
self._passable = passable_tiles or set()
def is_passable(self, x, y):
return (x, y) in self._passable
class MockEntity:
"""Mock entity for testing."""
def __init__(self, name, posture="standing"):
self.name = name
self.posture = posture
def test_render_where():
"""render_where should format zone description."""
assert render_where("The Overworld") == "Where: The Overworld"
def test_render_location_center():
"""render_location should show center quadrant."""
zone = MockZone(width=90, height=90)
assert render_location(zone, 45, 45) == "Location: center 45, 45"
def test_render_location_northeast():
"""render_location should show northeast quadrant."""
zone = MockZone(width=90, height=90)
assert render_location(zone, 70, 10) == "Location: northeast 70, 10"
def test_render_location_southwest():
"""render_location should show southwest quadrant."""
zone = MockZone(width=90, height=90)
assert render_location(zone, 10, 70) == "Location: southwest 10, 70"
def test_render_nearby_empty():
"""render_nearby should return empty string when no entities."""
assert render_nearby([], None) == ""
def test_render_nearby_with_entities():
"""render_nearby should show count and names."""
entities = [MockEntity("Goku"), MockEntity("Vegeta")]
result = render_nearby(entities, None)
assert result == "Nearby: (2) Goku / Vegeta"
def test_render_exits_all_directions():
"""render_exits should list all passable directions."""
zone = MockZone(passable_tiles={(5, 4), (5, 6), (6, 5), (4, 5)})
assert render_exits(zone, 5, 5) == "Exits: north south east west"
def test_render_exits_partial():
"""render_exits should list only passable directions."""
zone = MockZone(passable_tiles={(5, 4), (6, 5)})
assert render_exits(zone, 5, 5) == "Exits: north east"
def test_render_exits_none():
"""render_exits should show 'Exits:' with no directions if trapped."""
zone = MockZone(passable_tiles=set())
assert render_exits(zone, 5, 5) == "Exits:"
def test_render_entity_lines_empty():
"""render_entity_lines should return empty string when no entities."""
assert render_entity_lines([], None) == ""
def test_render_entity_lines_with_postures():
"""render_entity_lines should show entity names with postures."""
entities = [
MockEntity("Krillin", "resting"),
MockEntity("Piccolo", "standing"),
]
result = render_entity_lines(entities, None)
assert "Krillin is resting here." in result
assert "Piccolo is standing here." in result
def test_render_atmosphere_clear_weather():
"""render_atmosphere should omit weather desc when clear."""
result = render_atmosphere(12, "", "summer")
assert "[day, summer]" in result
# Should have sky description but no weather text
assert result.count(". [") == 1 # only one period before bracket
# Should not have double period before bracket
assert ". . [" not in result
def test_render_atmosphere_with_rain():
"""render_atmosphere should include weather desc when not clear."""
result = render_atmosphere(12, "rain patters steadily", "spring")
assert "rain patters steadily" in result
assert "[day, spring]" in result
def test_render_atmosphere_dawn():
"""render_atmosphere should show dawn period."""
result = render_atmosphere(5, "", "autumn")
assert "[dawn, autumn]" in result
def test_render_atmosphere_dusk():
"""render_atmosphere should show dusk period."""
result = render_atmosphere(18, "", "winter")
assert "[dusk, winter]" in result
def test_render_atmosphere_night():
"""render_atmosphere should show night period."""
result = render_atmosphere(22, "", "spring")
assert "[night, spring]" in result
def test_render_atmosphere_with_heavy_snow():
"""render_atmosphere should include heavy weather descriptions."""
result = render_atmosphere(22, "heavy snow blankets everything", "winter")
assert "heavy snow blankets everything" in result
assert "[night, winter]" in result
# Should have format: sky. weather. [period, season]
parts = result.split(". ")
assert len(parts) == 3 # sky, weather, [period, season]

87
tests/test_seasons.py Normal file
View file

@ -0,0 +1,87 @@
"""Tests for the season system."""
from mudlib import seasons
def test_get_season_basic():
assert seasons.get_season(0) == "spring"
assert seasons.get_season(6) == "spring"
assert seasons.get_season(7) == "summer"
assert seasons.get_season(13) == "summer"
assert seasons.get_season(14) == "autumn"
assert seasons.get_season(20) == "autumn"
assert seasons.get_season(21) == "winter"
assert seasons.get_season(27) == "winter"
def test_get_season_wraps():
assert seasons.get_season(28) == "spring"
assert seasons.get_season(35) == "summer"
assert seasons.get_season(56) == "spring" # 2 years = 56 days
def test_get_season_negative_day():
assert seasons.get_season(-1) == "spring"
assert seasons.get_season(-100) == "spring"
def test_get_season_custom_days_per_season():
# 10 days per season = 40-day year
assert seasons.get_season(0, days_per_season=10) == "spring"
assert seasons.get_season(9, days_per_season=10) == "spring"
assert seasons.get_season(10, days_per_season=10) == "summer"
assert seasons.get_season(19, days_per_season=10) == "summer"
assert seasons.get_season(20, days_per_season=10) == "autumn"
assert seasons.get_season(30, days_per_season=10) == "winter"
assert seasons.get_season(40, days_per_season=10) == "spring"
def test_get_day_of_year():
assert seasons.get_day_of_year(0) == 0
assert seasons.get_day_of_year(27) == 27
assert seasons.get_day_of_year(28) == 0 # new year
assert seasons.get_day_of_year(29) == 1
assert seasons.get_day_of_year(56) == 0 # 2 years
def test_get_day_of_year_custom_days_per_season():
# 10 days per season = 40-day year
assert seasons.get_day_of_year(0, days_per_season=10) == 0
assert seasons.get_day_of_year(39, days_per_season=10) == 39
assert seasons.get_day_of_year(40, days_per_season=10) == 0
assert seasons.get_day_of_year(80, days_per_season=10) == 0
def test_get_season_description_grass():
assert "green" in seasons.get_season_description("spring", "grass")
assert "golden" in seasons.get_season_description("summer", "grass")
assert "brown" in seasons.get_season_description("autumn", "grass")
assert "frost" in seasons.get_season_description("winter", "grass")
def test_get_season_description_forest():
assert "blossom" in seasons.get_season_description("spring", "forest")
assert "canopy" in seasons.get_season_description("summer", "forest")
assert "amber" in seasons.get_season_description("autumn", "forest")
assert "bare" in seasons.get_season_description("winter", "forest")
def test_get_season_description_minimal_variation():
# sand, mountain, water have minimal/no seasonal variation
assert seasons.get_season_description("spring", "sand") == ""
assert seasons.get_season_description("summer", "mountain") == ""
assert seasons.get_season_description("winter", "water") == ""
def test_get_season_description_unknown_terrain():
# unknown terrain returns empty string
assert seasons.get_season_description("spring", "lava") == ""
assert seasons.get_season_description("summer", "unknown") == ""
def test_seasons_constant():
assert seasons.SEASONS == ["spring", "summer", "autumn", "winter"]
def test_days_per_season_constant():
assert seasons.DAYS_PER_SEASON == 7

View file

@ -70,12 +70,13 @@ async def test_shell_greets_and_accepts_commands(temp_db):
readline = "mudlib.server.readline2" readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline: with patch(readline, new_callable=AsyncMock) as mock_readline:
# Simulate: name, create account (y), password, confirm password, look, quit # Simulate: name, create (y), pass, confirm pass, description, look, quit
mock_readline.side_effect = [ mock_readline.side_effect = [
"TestPlayer", "TestPlayer",
"y", "y",
"password", "password",
"password", "password",
"A test character",
"look", "look",
"quit", "quit",
] ]
@ -125,12 +126,13 @@ async def test_shell_handles_quit(temp_db):
readline = "mudlib.server.readline2" readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline: with patch(readline, new_callable=AsyncMock) as mock_readline:
# Simulate: name, create account (y), password, confirm password, quit # Simulate: name, create (y), pass, confirm pass, description, quit
mock_readline.side_effect = [ mock_readline.side_effect = [
"TestPlayer", "TestPlayer",
"y", "y",
"password", "password",
"password", "password",
"A test character",
"quit", "quit",
] ]
await server.shell(reader, writer) await server.shell(reader, writer)

View file

@ -0,0 +1,206 @@
"""Tests for player description and home_zone persistence."""
import os
import tempfile
import pytest
from mudlib.player import Player
from mudlib.store import create_account, init_db, load_player_data, save_player
@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_init_db_creates_description_column(temp_db):
"""init_db creates description column."""
import sqlite3
conn = sqlite3.connect(temp_db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
conn.close()
assert "description" in columns
def test_init_db_creates_home_zone_column(temp_db):
"""init_db creates home_zone column."""
import sqlite3
conn = sqlite3.connect(temp_db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
conn.close()
assert "home_zone" in columns
def test_default_description(temp_db):
"""New accounts have description default to empty string."""
create_account("Alice", "password123")
data = load_player_data("Alice")
assert data is not None
assert data["description"] == ""
def test_default_home_zone(temp_db):
"""New accounts have home_zone default to None."""
create_account("Bob", "password123")
data = load_player_data("Bob")
assert data is not None
assert data["home_zone"] is None
def test_save_and_load_description(temp_db):
"""save_player and load_player_data persist description."""
create_account("Charlie", "password123")
player = Player(name="Charlie", x=0, y=0, description="A brave warrior")
save_player(player)
data = load_player_data("Charlie")
assert data is not None
assert data["description"] == "A brave warrior"
def test_save_and_load_home_zone(temp_db):
"""save_player and load_player_data persist home_zone."""
create_account("Diana", "password123")
player = Player(name="Diana", x=0, y=0, home_zone="residential")
save_player(player)
data = load_player_data("Diana")
assert data is not None
assert data["home_zone"] == "residential"
def test_save_and_load_both_fields(temp_db):
"""save_player and load_player_data persist both description and home_zone."""
create_account("Eve", "password123")
player = Player(
name="Eve",
x=10,
y=20,
description="A skilled mage",
home_zone="wizard_tower",
)
save_player(player)
data = load_player_data("Eve")
assert data is not None
assert data["description"] == "A skilled mage"
assert data["home_zone"] == "wizard_tower"
def test_update_description(temp_db):
"""save_player updates existing description."""
create_account("Frank", "password123")
player = Player(name="Frank", x=0, y=0, description="A novice")
save_player(player)
player.description = "An experienced adventurer"
save_player(player)
data = load_player_data("Frank")
assert data is not None
assert data["description"] == "An experienced adventurer"
def test_update_home_zone(temp_db):
"""save_player updates existing home_zone."""
create_account("Grace", "password123")
player = Player(name="Grace", x=0, y=0, home_zone=None)
save_player(player)
player.home_zone = "residential"
save_player(player)
data = load_player_data("Grace")
assert data is not None
assert data["home_zone"] == "residential"
def test_clear_home_zone(temp_db):
"""save_player can clear home_zone back to None."""
create_account("Henry", "password123")
player = Player(name="Henry", x=0, y=0, home_zone="residential")
save_player(player)
player.home_zone = None
save_player(player)
data = load_player_data("Henry")
assert data is not None
assert data["home_zone"] is None
def test_migration_from_old_schema(temp_db):
"""Loading from DB without description/home_zone columns works."""
import sqlite3
# Create account first
create_account("Iris", "password123")
# Simulate old DB by removing the new columns
conn = sqlite3.connect(temp_db)
cursor = conn.cursor()
# Backup and recreate without new columns
cursor.execute("""
CREATE TABLE accounts_backup AS
SELECT name, password_hash, salt, x, y, pl, stamina,
max_stamina, flying, zone_name, inventory, created_at, last_login
FROM accounts
""")
cursor.execute("DROP TABLE accounts")
cursor.execute("""
CREATE TABLE accounts (
name TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
pl REAL NOT NULL DEFAULT 100.0,
stamina REAL NOT NULL DEFAULT 100.0,
max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0,
zone_name TEXT NOT NULL DEFAULT 'overworld',
inventory TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
)
""")
cursor.execute("""
INSERT INTO accounts
SELECT * FROM accounts_backup
""")
cursor.execute("DROP TABLE accounts_backup")
conn.commit()
conn.close()
# Should still load with default values
data = load_player_data("Iris")
assert data is not None
assert data["description"] == ""
assert data["home_zone"] is None

209
tests/test_terrain_edit.py Normal file
View file

@ -0,0 +1,209 @@
"""Tests for terrain editing command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.terrain import cmd_terrain
from mudlib.housing import init_housing
from mudlib.player import Player
from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
@pytest.fixture(autouse=True)
def _clean_registries():
"""Clear zone registry between tests."""
saved = dict(zone_registry)
zone_registry.clear()
yield
zone_registry.clear()
zone_registry.update(saved)
def _make_home_zone(player_name="alice"):
"""Create a home zone matching housing.py format."""
name = f"home:{player_name}"
terrain = []
for y in range(9):
row = []
for x in range(9):
if x == 0 or x == 8 or y == 0 or y == 8:
row.append("#")
else:
row.append(".")
terrain.append(row)
zone = Zone(
name=name,
description=f"{player_name}'s home",
width=9,
height=9,
terrain=terrain,
toroidal=False,
impassable={"#", "^", "~"},
spawn_x=4,
spawn_y=4,
safe=True,
)
register_zone(name, zone)
return zone
def _make_player(name="alice", zone=None, x=4, y=4):
"""Create a test player with mock writer."""
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
p = Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
p.home_zone = f"home:{name}"
return p
@pytest.mark.asyncio
async def test_terrain_paint_tile(tmp_path):
"""terrain <tile> changes terrain at current position."""
init_housing(tmp_path)
zone = _make_home_zone("alice")
player = _make_player("alice", zone=zone, x=4, y=4)
# Verify starting terrain
assert zone.terrain[4][4] == "."
# Paint a new tile
await cmd_terrain(player, "~")
# Verify terrain changed
assert zone.terrain[4][4] == "~"
# Verify success message
player.writer.write.assert_called()
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "paint" in messages.lower()
@pytest.mark.asyncio
async def test_terrain_not_in_home_zone():
"""terrain command fails when not in home zone."""
# Create home zone but place player in different zone
_make_home_zone("alice")
overworld = Zone(
name="overworld",
description="The overworld",
width=50,
height=50,
terrain=[["."] * 50 for _ in range(50)],
toroidal=True,
impassable=set(),
spawn_x=25,
spawn_y=25,
safe=False,
)
register_zone("overworld", overworld)
player = _make_player("alice", zone=overworld, x=25, y=25)
await cmd_terrain(player, "~")
# Verify error message
player.writer.write.assert_called()
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "home" in messages.lower() or "can't" in messages.lower()
@pytest.mark.asyncio
async def test_terrain_cannot_edit_border():
"""terrain command prevents editing border tiles."""
zone = _make_home_zone("alice")
# Test all border positions
border_positions = [
(0, 0), # top-left corner
(4, 0), # top edge
(8, 0), # top-right corner
(0, 4), # left edge
(8, 4), # right edge
(0, 8), # bottom-left corner
(4, 8), # bottom edge
(8, 8), # bottom-right corner
]
for x, y in border_positions:
player = _make_player("alice", zone=zone, x=x, y=y)
await cmd_terrain(player, ".")
# Verify error message
player.writer.write.assert_called()
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "border" in messages.lower() or "wall" in messages.lower()
# Verify terrain unchanged
assert zone.terrain[y][x] == "#"
@pytest.mark.asyncio
async def test_terrain_no_args():
"""terrain with no args shows usage."""
zone = _make_home_zone("alice")
player = _make_player("alice", zone=zone, x=4, y=4)
await cmd_terrain(player, "")
# Verify usage message
player.writer.write.assert_called()
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "usage" in messages.lower() or "terrain <" in messages.lower()
@pytest.mark.asyncio
async def test_terrain_only_single_char():
"""terrain rejects multi-character arguments."""
zone = _make_home_zone("alice")
player = _make_player("alice", zone=zone, x=4, y=4)
await cmd_terrain(player, "~~")
# Verify error message
player.writer.write.assert_called()
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "single" in messages.lower() or "one character" in messages.lower()
# Verify terrain unchanged
assert zone.terrain[4][4] == "."
@pytest.mark.asyncio
async def test_terrain_saves_zone(tmp_path):
"""terrain command saves zone to TOML after edit."""
import tomllib
init_housing(tmp_path)
zone = _make_home_zone("alice")
player = _make_player("alice", zone=zone, x=4, y=4)
# Paint a tile
await cmd_terrain(player, "~")
# Verify TOML file was updated
zone_file = tmp_path / "alice.toml"
assert zone_file.exists()
with open(zone_file, "rb") as f:
data = tomllib.load(f)
# Check that row 4 contains the water tile at position 4
rows = data["terrain"]["rows"]
assert rows[4][4] == "~"
@pytest.mark.asyncio
async def test_terrain_various_tiles(tmp_path):
"""terrain accepts various tile characters."""
init_housing(tmp_path)
zone = _make_home_zone("alice")
test_tiles = [".", "~", "^", "T", ",", '"', "*", "+", "="]
for tile in test_tiles:
player = _make_player("alice", zone=zone, x=4, y=4)
await cmd_terrain(player, tile)
assert zone.terrain[4][4] == tile

56
tests/test_timeofday.py Normal file
View file

@ -0,0 +1,56 @@
"""Tests for time-of-day system."""
from mudlib.timeofday import get_sky_description, get_time_period
def test_get_time_period_dawn():
assert get_time_period(5) == "dawn"
assert get_time_period(6) == "dawn"
def test_get_time_period_day():
assert get_time_period(7) == "day"
assert get_time_period(12) == "day"
assert get_time_period(17) == "day"
def test_get_time_period_dusk():
assert get_time_period(18) == "dusk"
assert get_time_period(19) == "dusk"
def test_get_time_period_night():
assert get_time_period(20) == "night"
assert get_time_period(21) == "night"
assert get_time_period(23) == "night"
assert get_time_period(0) == "night"
assert get_time_period(1) == "night"
assert get_time_period(4) == "night"
def test_get_sky_description_returns_non_empty():
for hour in range(24):
desc = get_sky_description(hour)
assert desc, f"hour {hour} returned empty description"
assert isinstance(desc, str)
def test_get_sky_description_differs_by_period():
dawn_desc = get_sky_description(5)
day_desc = get_sky_description(12)
dusk_desc = get_sky_description(18)
night_desc = get_sky_description(0)
# each period should have different descriptions
descriptions = {dawn_desc, day_desc, dusk_desc, night_desc}
assert len(descriptions) == 4, "periods should have distinct descriptions"
def test_get_sky_description_edge_cases():
# boundaries between periods
assert get_sky_description(0) # night start
assert get_sky_description(5) # dawn start
assert get_sky_description(7) # day start
assert get_sky_description(18) # dusk start
assert get_sky_description(20) # night start
assert get_sky_description(23) # night end

135
tests/test_visibility.py Normal file
View file

@ -0,0 +1,135 @@
"""Tests for visibility calculations."""
from mudlib.visibility import get_visibility
from mudlib.weather import WeatherCondition, WeatherState
def test_clear_day_full_visibility():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 21
assert height == 11
def test_night_reduces_visibility():
hour = 22 # night (20-4)
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 15 # 21 - 6
assert height == 9 # 11 - 2
def test_thick_fog_during_day():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.8)
width, height = get_visibility(hour, weather)
assert width == 13 # 21 - 8
assert height == 7 # 11 - 4
def test_night_plus_thick_fog_clamps_to_minimum():
hour = 22 # night
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.9)
width, height = get_visibility(hour, weather)
# Night: -6 width, -2 height (21x11 -> 15x9)
# Thick fog: -8 width, -4 height (15x9 -> 7x5)
# Should clamp to minimum 7x5
assert width == 7
assert height == 5
def test_storm_reduces_visibility():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.storm, intensity=0.7)
width, height = get_visibility(hour, weather)
assert width == 17 # 21 - 4
assert height == 9 # 11 - 2
def test_dawn_subtle_dimming():
hour = 5 # dawn (5-6)
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 19 # 21 - 2
assert height == 11 # 11 - 0
def test_dusk_subtle_dimming():
hour = 18 # dusk (18-19)
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 19 # 21 - 2
assert height == 11 # 11 - 0
def test_moderate_fog():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 17 # 21 - 4
assert height == 9 # 11 - 2
def test_light_fog_no_reduction():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.3)
width, height = get_visibility(hour, weather)
assert width == 21 # no reduction for light fog
assert height == 11
def test_cloudy_no_visibility_reduction():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.cloudy, intensity=0.7)
width, height = get_visibility(hour, weather)
assert width == 21
assert height == 11
def test_rain_no_visibility_reduction():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.rain, intensity=0.7)
width, height = get_visibility(hour, weather)
assert width == 21
assert height == 11
def test_snow_no_visibility_reduction():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.snow, intensity=0.7)
width, height = get_visibility(hour, weather)
assert width == 21
assert height == 11
def test_custom_base_dimensions():
hour = 12 # noon
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather, base_width=31, base_height=21)
assert width == 31
assert height == 21
def test_night_custom_base():
hour = 22 # night
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather, base_width=31, base_height=21)
assert width == 25 # 31 - 6
assert height == 19 # 21 - 2
def test_midnight_is_night():
hour = 0 # midnight
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 15 # reduced for night
assert height == 9
def test_early_morning_is_night():
hour = 3 # early morning
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
width, height = get_visibility(hour, weather)
assert width == 15 # reduced for night
assert height == 9

260
tests/test_weather.py Normal file
View file

@ -0,0 +1,260 @@
"""Tests for weather system."""
import random
from mudlib.weather import (
WeatherCondition,
WeatherState,
advance_weather,
get_weather_description,
)
def test_weather_condition_enum():
assert WeatherCondition.clear.value == "clear"
assert WeatherCondition.cloudy.value == "cloudy"
assert WeatherCondition.rain.value == "rain"
assert WeatherCondition.storm.value == "storm"
assert WeatherCondition.snow.value == "snow"
assert WeatherCondition.fog.value == "fog"
def test_weather_state_dataclass():
state = WeatherState(condition=WeatherCondition.rain, intensity=0.5)
assert state.condition == WeatherCondition.rain
assert state.intensity == 0.5
def test_get_weather_description_returns_non_empty():
state = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
description = get_weather_description(state)
assert isinstance(description, str)
assert len(description) > 0
def test_get_weather_description_varies_by_condition():
clear = get_weather_description(
WeatherState(condition=WeatherCondition.clear, intensity=0.5)
)
rain = get_weather_description(
WeatherState(condition=WeatherCondition.rain, intensity=0.5)
)
snow = get_weather_description(
WeatherState(condition=WeatherCondition.snow, intensity=0.5)
)
# Different conditions should produce different descriptions
assert clear != rain
assert rain != snow
assert clear != snow
def test_get_weather_description_varies_by_intensity():
light_rain = get_weather_description(
WeatherState(condition=WeatherCondition.rain, intensity=0.2)
)
heavy_rain = get_weather_description(
WeatherState(condition=WeatherCondition.rain, intensity=0.9)
)
# Different intensities should produce different descriptions
assert light_rain != heavy_rain
def test_advance_weather_returns_new_state():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng = random.Random(42)
new_state = advance_weather(current, season="summer", rng=rng)
assert isinstance(new_state, WeatherState)
assert isinstance(new_state.condition, WeatherCondition)
assert 0.0 <= new_state.intensity <= 1.0
def test_advance_weather_is_deterministic_with_seed():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng1 = random.Random(42)
state1 = advance_weather(current, season="summer", rng=rng1)
rng2 = random.Random(42)
state2 = advance_weather(current, season="summer", rng=rng2)
assert state1.condition == state2.condition
assert state1.intensity == state2.intensity
def test_advance_weather_transitions_naturally():
# Clear can become cloudy
rng = random.Random(42)
for _ in range(100):
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng)
if new_state.condition == WeatherCondition.cloudy:
break
else:
raise AssertionError("clear never transitioned to cloudy in 100 iterations")
# Cloudy can become rain or clear
rng = random.Random(43)
found_rain = False
found_clear = False
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng)
if new_state.condition == WeatherCondition.rain:
found_rain = True
if new_state.condition == WeatherCondition.clear:
found_clear = True
if found_rain and found_clear:
break
assert found_rain or found_clear
def test_storm_transitions_to_rain_or_cloudy():
# Storm should always transition away (doesn't last)
rng = random.Random(44)
found_non_storm = False
for _ in range(50):
current = WeatherState(condition=WeatherCondition.storm, intensity=0.8)
new_state = advance_weather(current, season="summer", rng=rng)
if new_state.condition in (WeatherCondition.rain, WeatherCondition.cloudy):
found_non_storm = True
break
assert found_non_storm, "storm should transition to rain or cloudy"
def test_snow_only_in_winter_autumn():
# Snow in winter
rng = random.Random(45)
found_snow = False
for _ in range(200):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="winter", rng=rng)
if new_state.condition == WeatherCondition.snow:
found_snow = True
break
assert found_snow, "snow should be possible in winter"
# Snow should be rare or impossible in summer
rng = random.Random(46)
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng)
# Should not produce snow in summer
assert new_state.condition != WeatherCondition.snow
def test_climate_temperate_default():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng = random.Random(47)
# Should work without climate parameter (defaults to temperate)
new_state = advance_weather(current, season="summer", rng=rng)
assert isinstance(new_state, WeatherState)
def test_climate_arid_favors_clear():
# Arid should heavily favor clear weather
rng = random.Random(48)
clear_count = 0
for _ in range(100):
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
new_state = advance_weather(current, season="summer", rng=rng, climate="arid")
if new_state.condition == WeatherCondition.clear:
clear_count += 1
# Arid should stay clear most of the time
assert clear_count > 70, f"arid should favor clear, got {clear_count}/100"
def test_climate_arid_no_snow():
# Arid should never produce snow
rng = random.Random(49)
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="winter", rng=rng, climate="arid")
assert new_state.condition != WeatherCondition.snow
def test_climate_arctic_favors_snow_fog_cloudy():
# Arctic should produce snow, fog, or cloudy frequently
rng = random.Random(50)
arctic_conditions = 0
for _ in range(100):
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
new_state = advance_weather(current, season="winter", rng=rng, climate="arctic")
if new_state.condition in (
WeatherCondition.snow,
WeatherCondition.fog,
WeatherCondition.cloudy,
):
arctic_conditions += 1
# Arctic should heavily favor snow/fog/cloudy
assert arctic_conditions > 70, (
f"arctic should favor snow/fog/cloudy, got {arctic_conditions}/100"
)
def test_advance_weather_accepts_all_seasons():
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
rng = random.Random(51)
for season in ["spring", "summer", "autumn", "winter"]:
new_state = advance_weather(current, season=season, rng=rng)
assert isinstance(new_state, WeatherState)
def test_get_weather_ambience_rain():
from mudlib.weather import get_weather_ambience
messages = get_weather_ambience(WeatherCondition.rain)
assert isinstance(messages, list)
assert len(messages) > 0
assert all(isinstance(msg, str) for msg in messages)
def test_get_weather_ambience_storm():
from mudlib.weather import get_weather_ambience
messages = get_weather_ambience(WeatherCondition.storm)
assert isinstance(messages, list)
assert len(messages) > 0
assert all(isinstance(msg, str) for msg in messages)
def test_get_weather_ambience_snow():
from mudlib.weather import get_weather_ambience
messages = get_weather_ambience(WeatherCondition.snow)
assert isinstance(messages, list)
assert len(messages) > 0
assert all(isinstance(msg, str) for msg in messages)
def test_get_weather_ambience_fog():
from mudlib.weather import get_weather_ambience
messages = get_weather_ambience(WeatherCondition.fog)
assert isinstance(messages, list)
assert len(messages) > 0
assert all(isinstance(msg, str) for msg in messages)
def test_get_weather_ambience_clear():
from mudlib.weather import get_weather_ambience
messages = get_weather_ambience(WeatherCondition.clear)
assert isinstance(messages, list)
assert len(messages) == 0
def test_get_weather_ambience_cloudy():
from mudlib.weather import get_weather_ambience
messages = get_weather_ambience(WeatherCondition.cloudy)
assert isinstance(messages, list)
assert len(messages) == 0