Compare commits
No commits in common. "708985e62a7593b24cf4e9649a837dc996b68bbe" and "4d44c4aadd3f299c36af963eac26821b21091d22" have entirely different histories.
708985e62a
...
4d44c4aadd
49 changed files with 15 additions and 4580 deletions
|
|
@ -1,4 +0,0 @@
|
|||
name = "wooden_table"
|
||||
description = "Craft a sturdy table from planks and nails"
|
||||
ingredients = ["plank", "plank", "plank", "nail", "nail"]
|
||||
result = "table"
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
name = "bookshelf"
|
||||
description = "a tall wooden bookshelf lined with dusty volumes"
|
||||
portable = true
|
||||
aliases = ["shelf"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
name = "chair"
|
||||
description = "a simple wooden chair with a woven seat"
|
||||
portable = true
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
name = "lamp"
|
||||
description = "a brass oil lamp with a glass chimney"
|
||||
portable = true
|
||||
aliases = ["lantern"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
name = "nail"
|
||||
description = "a small iron nail"
|
||||
portable = true
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
name = "painting"
|
||||
description = "a framed painting of rolling hills under a twilight sky"
|
||||
portable = true
|
||||
aliases = ["picture"]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
name = "plank"
|
||||
description = "a rough-hewn wooden plank"
|
||||
portable = true
|
||||
aliases = ["board", "wood"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
name = "rug"
|
||||
description = "a colorful woven rug with geometric patterns"
|
||||
portable = true
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
name = "table"
|
||||
description = "a sturdy wooden table with thick oak legs"
|
||||
portable = true
|
||||
|
|
@ -299,15 +299,9 @@ The engine roadmap's later phases, renumbered:
|
|||
- **Phase 15: NPC evolution** -- dialogue trees, behavior, schedules.
|
||||
Grimm's Library librarians may pull some dialogue work into phase 14.
|
||||
- **Phase 16: World systems** -- time of day, weather, seasons
|
||||
- **Phase 17: Player creation + housing** -- character creation flow
|
||||
(description prompt for new players), personal home zones with
|
||||
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
|
||||
- **Phase 17: Player creation + housing** -- player-owned zones, crafting
|
||||
- **Phase 18: The DSL** -- in-world scripting language
|
||||
- **Phase 19: Horizons** -- web client, compression, inter-MUD, AI NPCs
|
||||
|
||||
|
||||
Key references
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
"""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.")
|
||||
)
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
"""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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"""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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
"""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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -4,25 +4,16 @@ from mudlib.commands import CommandDefinition, register
|
|||
from mudlib.commands.things import _format_thing_name
|
||||
from mudlib.effects import get_effects_at
|
||||
from mudlib.entity import Entity
|
||||
from mudlib.gametime import get_game_day, get_game_hour
|
||||
from mudlib.player import Player
|
||||
from mudlib.render.ansi import RESET, colorize_terrain
|
||||
from mudlib.render.room import (
|
||||
render_atmosphere,
|
||||
render_entity_lines,
|
||||
render_exits,
|
||||
render_location,
|
||||
render_nearby,
|
||||
render_where,
|
||||
)
|
||||
from mudlib.seasons import get_season
|
||||
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
|
||||
|
||||
# Viewport dimensions
|
||||
|
|
@ -91,38 +82,16 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
await player.writer.drain()
|
||||
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
|
||||
viewport = zone.get_viewport(player.x, player.y, effective_width, effective_height)
|
||||
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
||||
|
||||
# Calculate center position
|
||||
center_x = effective_width // 2
|
||||
center_y = effective_height // 2
|
||||
center_x = VIEWPORT_WIDTH // 2
|
||||
center_y = VIEWPORT_HEIGHT // 2
|
||||
|
||||
# Get nearby entities (players and mobs) from the zone
|
||||
# Viewport half-diagonal distance for range
|
||||
viewport_range = effective_width // 2 + effective_height // 2
|
||||
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2
|
||||
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
||||
|
||||
# Build a list of (relative_x, relative_y) for other entities
|
||||
|
|
@ -152,13 +121,13 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
rel_y = dy + center_y
|
||||
|
||||
# Check if within viewport bounds
|
||||
if 0 <= rel_x < effective_width and 0 <= rel_y < effective_height:
|
||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||
entity_positions.append((rel_x, rel_y))
|
||||
|
||||
# Build the output with ANSI coloring
|
||||
# priority: player @ > other players * > mobs * > effects > terrain
|
||||
half_width = effective_width // 2
|
||||
half_height = effective_height // 2
|
||||
half_width = VIEWPORT_WIDTH // 2
|
||||
half_height = VIEWPORT_HEIGHT // 2
|
||||
|
||||
output_lines = []
|
||||
for y, row in enumerate(viewport):
|
||||
|
|
@ -191,16 +160,6 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
# Where header
|
||||
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
|
||||
output.append("\r\n".join(output_lines))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
"""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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""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}
|
||||
|
|
@ -42,17 +42,6 @@ class GameTime:
|
|||
|
||||
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)
|
||||
_game_time: GameTime | None = None
|
||||
|
|
@ -99,17 +88,3 @@ def get_game_time() -> tuple[int, int]:
|
|||
if _game_time is None:
|
||||
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -43,9 +43,6 @@ class Player(Entity):
|
|||
unlocked_moves: set[str] = field(default_factory=set)
|
||||
session_start: float = 0.0
|
||||
is_admin: bool = False
|
||||
description: str = ""
|
||||
home_zone: str | None = None
|
||||
return_location: tuple[str, int, int] | None = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from mudlib.timeofday import get_sky_description, get_time_period
|
||||
|
||||
|
||||
def render_where(zone_name: str) -> str:
|
||||
"""Render the zone description line.
|
||||
|
|
@ -139,23 +137,3 @@ def render_entity_lines(entities: list, viewer) -> str:
|
|||
lines.append(f"{entity.name} {msg}")
|
||||
|
||||
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}]"
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
"""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), "")
|
||||
|
|
@ -16,14 +16,10 @@ from telnetlib3.server_shell import readline2
|
|||
import mudlib.combat.commands
|
||||
import mudlib.commands
|
||||
import mudlib.commands.containers
|
||||
import mudlib.commands.crafting
|
||||
import mudlib.commands.describe
|
||||
import mudlib.commands.edit
|
||||
import mudlib.commands.examine
|
||||
import mudlib.commands.fly
|
||||
import mudlib.commands.furnish
|
||||
import mudlib.commands.help
|
||||
import mudlib.commands.home
|
||||
import mudlib.commands.look
|
||||
import mudlib.commands.movement
|
||||
import mudlib.commands.play
|
||||
|
|
@ -34,7 +30,6 @@ import mudlib.commands.reload
|
|||
import mudlib.commands.snapneck
|
||||
import mudlib.commands.spawn
|
||||
import mudlib.commands.talk
|
||||
import mudlib.commands.terrain
|
||||
import mudlib.commands.things
|
||||
import mudlib.commands.use
|
||||
from mudlib.caps import parse_mtts
|
||||
|
|
@ -42,8 +37,6 @@ from mudlib.combat.commands import register_combat_commands
|
|||
from mudlib.combat.engine import process_combat
|
||||
from mudlib.content import load_commands
|
||||
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.effects import clear_expired
|
||||
from mudlib.gametime import get_game_hour, init_game_time
|
||||
|
|
@ -54,7 +47,6 @@ from mudlib.gmcp import (
|
|||
send_msdp_vitals,
|
||||
send_room_info,
|
||||
)
|
||||
from mudlib.housing import init_housing, load_home_zone
|
||||
from mudlib.if_session import broadcast_to_spectators
|
||||
from mudlib.mob_ai import process_mob_movement, process_mobs
|
||||
from mudlib.mobs import load_mob_templates, mob_templates, mobs
|
||||
|
|
@ -72,7 +64,6 @@ from mudlib.store import (
|
|||
load_player_data,
|
||||
load_player_stats,
|
||||
save_player,
|
||||
save_player_description,
|
||||
update_last_login,
|
||||
)
|
||||
from mudlib.thing import Thing
|
||||
|
|
@ -207,7 +198,7 @@ async def handle_login(
|
|||
if authenticate(name, password.strip()):
|
||||
# Success - load player data
|
||||
player_data = load_player_data(name)
|
||||
return {"success": True, "player_data": player_data, "is_new": False}
|
||||
return {"success": True, "player_data": player_data}
|
||||
|
||||
remaining = max_attempts - attempt - 1
|
||||
if remaining > 0:
|
||||
|
|
@ -245,7 +236,7 @@ async def handle_login(
|
|||
await write_func("Account created successfully!\r\n")
|
||||
# Return default data for new account
|
||||
player_data = load_player_data(name)
|
||||
return {"success": True, "player_data": player_data, "is_new": True}
|
||||
return {"success": True, "player_data": player_data}
|
||||
await write_func("Failed to create account.\r\n")
|
||||
return {"success": False, "player_data": None}
|
||||
|
||||
|
|
@ -319,15 +310,6 @@ async def shell(
|
|||
|
||||
# Load player data from database or use defaults for new player
|
||||
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:
|
||||
# New player - find a passable starting position
|
||||
center_x = _overworld.width // 2
|
||||
|
|
@ -342,8 +324,6 @@ async def shell(
|
|||
"flying": False,
|
||||
"zone_name": "overworld",
|
||||
"inventory": [],
|
||||
"description": "",
|
||||
"home_zone": None,
|
||||
}
|
||||
|
||||
# Resolve zone from zone_name using zone registry
|
||||
|
|
@ -366,11 +346,6 @@ async def shell(
|
|||
player_data["x"] = start_x
|
||||
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
|
||||
player = Player(
|
||||
name=player_name,
|
||||
|
|
@ -385,10 +360,6 @@ async def shell(
|
|||
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
|
||||
player.aliases = load_aliases(player_name)
|
||||
|
||||
|
|
@ -580,11 +551,6 @@ async def run_server() -> None:
|
|||
register_zone(zone_name, zone)
|
||||
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
|
||||
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
||||
if content_dir.exists():
|
||||
|
|
@ -615,13 +581,6 @@ async def run_server() -> None:
|
|||
thing_templates.update(loaded_things)
|
||||
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
|
||||
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
|
||||
if dialogue_dir.exists():
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ class PlayerData(TypedDict):
|
|||
flying: bool
|
||||
zone_name: str
|
||||
inventory: list[str]
|
||||
description: str
|
||||
home_zone: str | None
|
||||
|
||||
|
||||
class StatsData(TypedDict):
|
||||
|
|
@ -106,12 +104,6 @@ def init_db(db_path: str | Path) -> None:
|
|||
cursor.execute(
|
||||
"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.close()
|
||||
|
|
@ -226,40 +218,6 @@ def authenticate(name: str, password: str) -> bool:
|
|||
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:
|
||||
"""Save player state to the database.
|
||||
|
||||
|
|
@ -284,7 +242,7 @@ def save_player(player: Player) -> None:
|
|||
"""
|
||||
UPDATE accounts
|
||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
||||
zone_name = ?, inventory = ?, description = ?, home_zone = ?
|
||||
zone_name = ?, inventory = ?
|
||||
WHERE name = ?
|
||||
""",
|
||||
(
|
||||
|
|
@ -296,8 +254,6 @@ def save_player(player: Player) -> None:
|
|||
1 if player.flying else 0,
|
||||
player.location.name if player.location else "overworld",
|
||||
inventory_json,
|
||||
player.description,
|
||||
player.home_zone,
|
||||
player.name,
|
||||
),
|
||||
)
|
||||
|
|
@ -327,8 +283,6 @@ def load_player_data(name: str) -> PlayerData | None:
|
|||
columns = [row[1] for row in cursor.fetchall()]
|
||||
has_zone_name = "zone_name" 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
|
||||
select_cols = "x, y, pl, stamina, max_stamina, flying"
|
||||
|
|
@ -336,10 +290,6 @@ def load_player_data(name: str) -> PlayerData | None:
|
|||
select_cols += ", zone_name"
|
||||
if has_inventory:
|
||||
select_cols += ", inventory"
|
||||
if has_description:
|
||||
select_cols += ", description"
|
||||
if has_home_zone:
|
||||
select_cols += ", home_zone"
|
||||
|
||||
cursor.execute(
|
||||
f"SELECT {select_cols} FROM accounts WHERE name = ?",
|
||||
|
|
@ -364,17 +314,6 @@ def load_player_data(name: str) -> PlayerData | None:
|
|||
inventory: list[str] = []
|
||||
if has_inventory:
|
||||
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 {
|
||||
"x": x,
|
||||
|
|
@ -385,8 +324,6 @@ def load_player_data(name: str) -> PlayerData | None:
|
|||
"flying": bool(flying_int),
|
||||
"zone_name": zone_name,
|
||||
"inventory": inventory,
|
||||
"description": description,
|
||||
"home_zone": home_zone,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
"""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)]
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
"""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 []
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -12,15 +12,6 @@ from mudlib.render.ansi import RESET
|
|||
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
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
"""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"
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
"""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] == "^"
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
"""Tests for the look command with structured room display."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -8,24 +7,12 @@ import pytest
|
|||
from mudlib.commands import look # noqa: F401
|
||||
from mudlib.commands.look import cmd_look
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.gametime import init_game_time
|
||||
from mudlib.player import Player
|
||||
from mudlib.portal import Portal
|
||||
from mudlib.thing import Thing
|
||||
from mudlib.weather import WeatherCondition, init_weather
|
||||
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
|
||||
def mock_writer():
|
||||
writer = MagicMock()
|
||||
|
|
@ -216,204 +203,3 @@ async def test_look_nowhere(mock_reader, mock_writer):
|
|||
output = get_output(player)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -26,14 +26,6 @@ def clear_mobs():
|
|||
mob_templates.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_globals():
|
||||
yield
|
||||
import mudlib.gametime
|
||||
|
||||
mudlib.gametime._game_time = None
|
||||
|
||||
|
||||
def test_schedule_entry_creation():
|
||||
"""ScheduleEntry can be created with required fields."""
|
||||
entry = ScheduleEntry(hour=6, state="working")
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
"""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]
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -70,13 +70,12 @@ async def test_shell_greets_and_accepts_commands(temp_db):
|
|||
|
||||
readline = "mudlib.server.readline2"
|
||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||
# Simulate: name, create (y), pass, confirm pass, description, look, quit
|
||||
# Simulate: name, create account (y), password, confirm password, look, quit
|
||||
mock_readline.side_effect = [
|
||||
"TestPlayer",
|
||||
"y",
|
||||
"password",
|
||||
"password",
|
||||
"A test character",
|
||||
"look",
|
||||
"quit",
|
||||
]
|
||||
|
|
@ -126,13 +125,12 @@ async def test_shell_handles_quit(temp_db):
|
|||
|
||||
readline = "mudlib.server.readline2"
|
||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||
# Simulate: name, create (y), pass, confirm pass, description, quit
|
||||
# Simulate: name, create account (y), password, confirm password, quit
|
||||
mock_readline.side_effect = [
|
||||
"TestPlayer",
|
||||
"y",
|
||||
"password",
|
||||
"password",
|
||||
"A test character",
|
||||
"quit",
|
||||
]
|
||||
await server.shell(reader, writer)
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
"""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
|
||||
Loading…
Reference in a new issue