Compare commits
21 commits
4d44c4aadd
...
708985e62a
| Author | SHA1 | Date | |
|---|---|---|---|
| 708985e62a | |||
| 11636e073a | |||
| 7342a70ba2 | |||
| 5b6c808050 | |||
| 5d14011684 | |||
| ec43ead568 | |||
| 9f760bc3af | |||
| acfff671fe | |||
| 32c570b777 | |||
| 6229c87945 | |||
| 9fac18ad2b | |||
| 1c22530be7 | |||
| 0f3ae87f33 | |||
| 97d5173522 | |||
| 25339edbf5 | |||
| 4c4d947ce2 | |||
| 9594e23011 | |||
| d91b180824 | |||
| 15d141b53e | |||
| 4b0a7315c1 | |||
| 32f52ef704 |
49 changed files with 4580 additions and 15 deletions
4
content/recipes/wooden_table.toml
Normal file
4
content/recipes/wooden_table.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "wooden_table"
|
||||||
|
description = "Craft a sturdy table from planks and nails"
|
||||||
|
ingredients = ["plank", "plank", "plank", "nail", "nail"]
|
||||||
|
result = "table"
|
||||||
4
content/things/bookshelf.toml
Normal file
4
content/things/bookshelf.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "bookshelf"
|
||||||
|
description = "a tall wooden bookshelf lined with dusty volumes"
|
||||||
|
portable = true
|
||||||
|
aliases = ["shelf"]
|
||||||
3
content/things/chair.toml
Normal file
3
content/things/chair.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name = "chair"
|
||||||
|
description = "a simple wooden chair with a woven seat"
|
||||||
|
portable = true
|
||||||
4
content/things/lamp.toml
Normal file
4
content/things/lamp.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "lamp"
|
||||||
|
description = "a brass oil lamp with a glass chimney"
|
||||||
|
portable = true
|
||||||
|
aliases = ["lantern"]
|
||||||
3
content/things/nail.toml
Normal file
3
content/things/nail.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name = "nail"
|
||||||
|
description = "a small iron nail"
|
||||||
|
portable = true
|
||||||
4
content/things/painting.toml
Normal file
4
content/things/painting.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "painting"
|
||||||
|
description = "a framed painting of rolling hills under a twilight sky"
|
||||||
|
portable = true
|
||||||
|
aliases = ["picture"]
|
||||||
4
content/things/plank.toml
Normal file
4
content/things/plank.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "plank"
|
||||||
|
description = "a rough-hewn wooden plank"
|
||||||
|
portable = true
|
||||||
|
aliases = ["board", "wood"]
|
||||||
3
content/things/rug.toml
Normal file
3
content/things/rug.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name = "rug"
|
||||||
|
description = "a colorful woven rug with geometric patterns"
|
||||||
|
portable = true
|
||||||
3
content/things/table.toml
Normal file
3
content/things/table.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
name = "table"
|
||||||
|
description = "a sturdy wooden table with thick oak legs"
|
||||||
|
portable = true
|
||||||
|
|
@ -299,9 +299,15 @@ The engine roadmap's later phases, renumbered:
|
||||||
- **Phase 15: NPC evolution** -- dialogue trees, behavior, schedules.
|
- **Phase 15: NPC evolution** -- dialogue trees, behavior, schedules.
|
||||||
Grimm's Library librarians may pull some dialogue work into phase 14.
|
Grimm's Library librarians may pull some dialogue work into phase 14.
|
||||||
- **Phase 16: World systems** -- time of day, weather, seasons
|
- **Phase 16: World systems** -- time of day, weather, seasons
|
||||||
- **Phase 17: Player creation + housing** -- player-owned zones, crafting
|
- **Phase 17: Player creation + housing** -- character creation flow
|
||||||
- **Phase 18: The DSL** -- in-world scripting language
|
(description prompt for new players), personal home zones with
|
||||||
- **Phase 19: Horizons** -- web client, compression, inter-MUD, AI NPCs
|
persistence (TOML in ``data/player_zones/``), home command for
|
||||||
|
teleportation to/from personal zone
|
||||||
|
- **Phase 18: Housing expansion + crafting** -- furniture placement,
|
||||||
|
home zone customization (terrain editing, descriptions), crafting
|
||||||
|
system, decorative objects
|
||||||
|
- **Phase 19: The DSL** -- in-world scripting language
|
||||||
|
- **Phase 20: Horizons** -- web client, compression, inter-MUD, AI NPCs
|
||||||
|
|
||||||
|
|
||||||
Key references
|
Key references
|
||||||
|
|
|
||||||
130
src/mudlib/commands/crafting.py
Normal file
130
src/mudlib/commands/crafting.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Crafting commands."""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.crafting import recipes
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.things import spawn_thing, thing_templates
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_craft(player: Player, args: str) -> None:
|
||||||
|
"""Craft an item from a recipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player crafting
|
||||||
|
args: Recipe name
|
||||||
|
"""
|
||||||
|
if not args:
|
||||||
|
await player.send("Usage: craft <recipe>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
recipe_name = args.strip().lower()
|
||||||
|
|
||||||
|
# Find recipe by name (case-insensitive, prefix match)
|
||||||
|
matching_recipes = [
|
||||||
|
name for name in recipes if name.lower().startswith(recipe_name)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not matching_recipes:
|
||||||
|
await player.send(f"Unknown recipe: {args}\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(matching_recipes) > 1:
|
||||||
|
await player.send(
|
||||||
|
f"Ambiguous recipe name. Matches: {', '.join(matching_recipes)}\r\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
recipe = recipes[matching_recipes[0]]
|
||||||
|
|
||||||
|
# Count required ingredients
|
||||||
|
required = Counter(ingredient.lower() for ingredient in recipe.ingredients)
|
||||||
|
|
||||||
|
# Count available ingredients in inventory
|
||||||
|
inventory = player.contents
|
||||||
|
available = Counter(obj.name.lower() for obj in inventory)
|
||||||
|
|
||||||
|
# Check if player has all ingredients
|
||||||
|
missing = []
|
||||||
|
for ingredient, count in required.items():
|
||||||
|
if available[ingredient] < count:
|
||||||
|
needed = count - available[ingredient]
|
||||||
|
missing.append(f"{ingredient} (need {needed} more)")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
await player.send(f"Missing ingredients: {', '.join(missing)}\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if result template exists
|
||||||
|
if recipe.result not in thing_templates:
|
||||||
|
await player.send(
|
||||||
|
f"Error: Recipe result '{recipe.result}' template not found.\r\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Consume ingredients
|
||||||
|
consumed = Counter()
|
||||||
|
for obj in list(inventory):
|
||||||
|
obj_name = obj.name.lower()
|
||||||
|
if obj_name in required and consumed[obj_name] < required[obj_name]:
|
||||||
|
obj.move_to(None) # Remove from world
|
||||||
|
consumed[obj_name] += 1
|
||||||
|
|
||||||
|
# Create result item
|
||||||
|
result_template = thing_templates[recipe.result]
|
||||||
|
spawn_thing(result_template, player)
|
||||||
|
|
||||||
|
await player.send(f"You craft a {result_template.name}.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_recipes(player: Player, args: str) -> None:
|
||||||
|
"""List available recipes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player viewing recipes
|
||||||
|
args: Optional recipe name for details
|
||||||
|
"""
|
||||||
|
if not recipes:
|
||||||
|
await player.send("No recipes available.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args:
|
||||||
|
# Show details for specific recipe
|
||||||
|
recipe_name = args.strip().lower()
|
||||||
|
matching = [name for name in recipes if name.lower().startswith(recipe_name)]
|
||||||
|
|
||||||
|
if not matching:
|
||||||
|
await player.send(f"Unknown recipe: {args}\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(matching) > 1:
|
||||||
|
await player.send(
|
||||||
|
f"Ambiguous recipe name. Matches: {', '.join(matching)}\r\n"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
recipe = recipes[matching[0]]
|
||||||
|
ingredient_counts = Counter(recipe.ingredients)
|
||||||
|
ingredient_list = ", ".join(
|
||||||
|
f"{count}x {name}" if count > 1 else name
|
||||||
|
for name, count in sorted(ingredient_counts.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.send(
|
||||||
|
f"Recipe: {recipe.name}\r\n"
|
||||||
|
f"{recipe.description}\r\n"
|
||||||
|
f"Ingredients: {ingredient_list}\r\n"
|
||||||
|
f"Result: {recipe.result}\r\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# List all recipes
|
||||||
|
await player.send("Available recipes:\r\n")
|
||||||
|
for name, recipe in sorted(recipes.items()):
|
||||||
|
await player.send(f" {name}: {recipe.description}\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(CommandDefinition("craft", cmd_craft, help="Craft items from recipes."))
|
||||||
|
register(
|
||||||
|
CommandDefinition("recipes", cmd_recipes, help="List available crafting recipes.")
|
||||||
|
)
|
||||||
50
src/mudlib/commands/describe.py
Normal file
50
src/mudlib/commands/describe.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Describe command — set home zone description."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.housing import save_home_zone
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_describe(player: Player, args: str) -> None:
|
||||||
|
"""Set the description for your home zone.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
describe <text> — set your home zone description
|
||||||
|
describe — show current description
|
||||||
|
"""
|
||||||
|
zone = player.location
|
||||||
|
|
||||||
|
# Must be in a zone
|
||||||
|
if not isinstance(zone, Zone):
|
||||||
|
await player.send("You aren't anywhere.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Must be in own home zone
|
||||||
|
if zone.name != player.home_zone:
|
||||||
|
await player.send("You can only describe your own home zone.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# No args — show current description
|
||||||
|
if not args.strip():
|
||||||
|
await player.send(f"Current description: {zone.description}\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set new description
|
||||||
|
description = args.strip()
|
||||||
|
if len(description) > 500:
|
||||||
|
await player.send("Description too long (max 500 characters).\r\n")
|
||||||
|
return
|
||||||
|
zone.description = description
|
||||||
|
save_home_zone(player.name, zone)
|
||||||
|
|
||||||
|
await player.send(f"Home zone description set to: {zone.description}\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(
|
||||||
|
CommandDefinition(
|
||||||
|
"describe",
|
||||||
|
cmd_describe,
|
||||||
|
help="Set your home zone description.",
|
||||||
|
)
|
||||||
|
)
|
||||||
92
src/mudlib/commands/furnish.py
Normal file
92
src/mudlib/commands/furnish.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""Furnish and unfurnish commands — place/remove furniture in home zones."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.housing import save_home_zone
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.targeting import find_in_inventory, find_thing_on_tile
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_furnish(player: Player, args: str) -> None:
|
||||||
|
"""Place an item from inventory as furniture in your home zone.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
furnish <item>
|
||||||
|
"""
|
||||||
|
# Validate arguments
|
||||||
|
if not args.strip():
|
||||||
|
await player.send("Usage: furnish <item>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check that player is in their home zone
|
||||||
|
zone = player.location
|
||||||
|
if not isinstance(zone, Zone) or zone.name != player.home_zone:
|
||||||
|
await player.send("You can only furnish items in your home zone.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find item in inventory
|
||||||
|
item_name = args.strip()
|
||||||
|
thing = find_in_inventory(item_name, player)
|
||||||
|
|
||||||
|
if thing is None:
|
||||||
|
await player.send(f"You don't have '{item_name}'.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Place item at player's position
|
||||||
|
thing.move_to(zone, x=player.x, y=player.y)
|
||||||
|
|
||||||
|
# Save the zone
|
||||||
|
save_home_zone(player.name, zone)
|
||||||
|
|
||||||
|
await player.send(f"You place the {thing.name} here.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_unfurnish(player: Player, args: str) -> None:
|
||||||
|
"""Pick up furniture from your home zone into inventory.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
unfurnish <item>
|
||||||
|
"""
|
||||||
|
# Validate arguments
|
||||||
|
if not args.strip():
|
||||||
|
await player.send("Usage: unfurnish <item>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check that player is in their home zone
|
||||||
|
zone = player.location
|
||||||
|
if not isinstance(zone, Zone) or zone.name != player.home_zone:
|
||||||
|
await player.send("You can only unfurnish items in your home zone.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find furniture at player's position
|
||||||
|
item_name = args.strip()
|
||||||
|
thing = find_thing_on_tile(item_name, zone, player.x, player.y)
|
||||||
|
|
||||||
|
if thing is None:
|
||||||
|
await player.send(f"You don't see '{item_name}' here.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick up the item
|
||||||
|
thing.move_to(player)
|
||||||
|
|
||||||
|
# Save the zone
|
||||||
|
save_home_zone(player.name, zone)
|
||||||
|
|
||||||
|
await player.send(f"You pick up the {thing.name}.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(
|
||||||
|
CommandDefinition(
|
||||||
|
"furnish",
|
||||||
|
cmd_furnish,
|
||||||
|
help="Place an item from inventory as furniture in your home zone.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
CommandDefinition(
|
||||||
|
"unfurnish",
|
||||||
|
cmd_unfurnish,
|
||||||
|
help="Pick up furniture from your home zone into inventory.",
|
||||||
|
)
|
||||||
|
)
|
||||||
108
src/mudlib/commands/home.py
Normal file
108
src/mudlib/commands/home.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Home command — teleport to personal zone."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.commands.movement import send_nearby_message
|
||||||
|
from mudlib.housing import get_or_create_home
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.store import save_player_home_zone
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import get_zone
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_home(player: Player, args: str) -> None:
|
||||||
|
"""Teleport to your personal zone, or return from it.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
home — go to your home zone
|
||||||
|
home return — return to where you were before going home
|
||||||
|
"""
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
arg = args.strip().lower()
|
||||||
|
zone = player.location
|
||||||
|
|
||||||
|
if arg == "return":
|
||||||
|
# Return to previous location
|
||||||
|
if player.return_location is None:
|
||||||
|
await player.send("You have nowhere to return to.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_zone_name, target_x, target_y = player.return_location
|
||||||
|
target_zone = get_zone(target_zone_name)
|
||||||
|
if target_zone is None:
|
||||||
|
await player.send("Your return destination no longer exists.\r\n")
|
||||||
|
player.return_location = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Departure message
|
||||||
|
if isinstance(zone, Zone):
|
||||||
|
await send_nearby_message(
|
||||||
|
player,
|
||||||
|
player.x,
|
||||||
|
player.y,
|
||||||
|
f"{player.name} vanishes in a flash.\r\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Move
|
||||||
|
player.move_to(target_zone, x=target_x, y=target_y)
|
||||||
|
player.return_location = None
|
||||||
|
|
||||||
|
# Arrival message
|
||||||
|
await send_nearby_message(
|
||||||
|
player,
|
||||||
|
player.x,
|
||||||
|
player.y,
|
||||||
|
f"{player.name} appears in a flash.\r\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.send("You return to where you were.\r\n")
|
||||||
|
await cmd_look(player, "")
|
||||||
|
return
|
||||||
|
|
||||||
|
if arg:
|
||||||
|
await player.send("Usage: home | home return\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Go home
|
||||||
|
home = get_or_create_home(player.name)
|
||||||
|
|
||||||
|
# Save current location for return trip (only if not already at home)
|
||||||
|
home_zone_name = f"home:{player.name.lower()}"
|
||||||
|
if isinstance(zone, Zone) and zone.name != home_zone_name:
|
||||||
|
player.return_location = (zone.name, player.x, player.y)
|
||||||
|
|
||||||
|
# Departure message
|
||||||
|
if isinstance(zone, Zone):
|
||||||
|
await send_nearby_message(
|
||||||
|
player,
|
||||||
|
player.x,
|
||||||
|
player.y,
|
||||||
|
f"{player.name} vanishes in a flash.\r\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Move to home spawn point
|
||||||
|
player.move_to(home, x=home.spawn_x, y=home.spawn_y)
|
||||||
|
|
||||||
|
# Update home_zone on player
|
||||||
|
player.home_zone = home.name
|
||||||
|
save_player_home_zone(player.name, home.name)
|
||||||
|
|
||||||
|
# Arrival message (usually nobody else is in your home, but just in case)
|
||||||
|
await send_nearby_message(
|
||||||
|
player,
|
||||||
|
player.x,
|
||||||
|
player.y,
|
||||||
|
f"{player.name} appears in a flash.\r\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.send("You arrive at your home.\r\n")
|
||||||
|
await cmd_look(player, "")
|
||||||
|
|
||||||
|
|
||||||
|
register(
|
||||||
|
CommandDefinition(
|
||||||
|
"home",
|
||||||
|
cmd_home,
|
||||||
|
help="Teleport to your personal zone. 'home return' to go back.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -4,16 +4,25 @@ from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.commands.things import _format_thing_name
|
from mudlib.commands.things import _format_thing_name
|
||||||
from mudlib.effects import get_effects_at
|
from mudlib.effects import get_effects_at
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
|
from mudlib.gametime import get_game_day, get_game_hour
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.render.ansi import RESET, colorize_terrain
|
from mudlib.render.ansi import RESET, colorize_terrain
|
||||||
from mudlib.render.room import (
|
from mudlib.render.room import (
|
||||||
|
render_atmosphere,
|
||||||
render_entity_lines,
|
render_entity_lines,
|
||||||
render_exits,
|
render_exits,
|
||||||
render_location,
|
render_location,
|
||||||
render_nearby,
|
render_nearby,
|
||||||
render_where,
|
render_where,
|
||||||
)
|
)
|
||||||
|
from mudlib.seasons import get_season
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.visibility import get_visibility
|
||||||
|
from mudlib.weather import (
|
||||||
|
WeatherCondition,
|
||||||
|
get_current_weather,
|
||||||
|
get_weather_description,
|
||||||
|
)
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
# Viewport dimensions
|
# Viewport dimensions
|
||||||
|
|
@ -82,16 +91,38 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Compute environment state once
|
||||||
|
hour = None
|
||||||
|
day = None
|
||||||
|
weather = None
|
||||||
|
season = None
|
||||||
|
try:
|
||||||
|
hour = get_game_hour()
|
||||||
|
day = get_game_day()
|
||||||
|
weather = get_current_weather()
|
||||||
|
season = get_season(day)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Use hour/weather for visibility
|
||||||
|
if hour is not None and weather is not None:
|
||||||
|
effective_width, effective_height = get_visibility(
|
||||||
|
hour, weather, VIEWPORT_WIDTH, VIEWPORT_HEIGHT
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_width = VIEWPORT_WIDTH
|
||||||
|
effective_height = VIEWPORT_HEIGHT
|
||||||
|
|
||||||
# Get the viewport from the zone
|
# Get the viewport from the zone
|
||||||
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
viewport = zone.get_viewport(player.x, player.y, effective_width, effective_height)
|
||||||
|
|
||||||
# Calculate center position
|
# Calculate center position
|
||||||
center_x = VIEWPORT_WIDTH // 2
|
center_x = effective_width // 2
|
||||||
center_y = VIEWPORT_HEIGHT // 2
|
center_y = effective_height // 2
|
||||||
|
|
||||||
# Get nearby entities (players and mobs) from the zone
|
# Get nearby entities (players and mobs) from the zone
|
||||||
# Viewport half-diagonal distance for range
|
# Viewport half-diagonal distance for range
|
||||||
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2
|
viewport_range = effective_width // 2 + effective_height // 2
|
||||||
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
||||||
|
|
||||||
# Build a list of (relative_x, relative_y) for other entities
|
# Build a list of (relative_x, relative_y) for other entities
|
||||||
|
|
@ -121,13 +152,13 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
rel_y = dy + center_y
|
rel_y = dy + center_y
|
||||||
|
|
||||||
# Check if within viewport bounds
|
# Check if within viewport bounds
|
||||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
if 0 <= rel_x < effective_width and 0 <= rel_y < effective_height:
|
||||||
entity_positions.append((rel_x, rel_y))
|
entity_positions.append((rel_x, rel_y))
|
||||||
|
|
||||||
# Build the output with ANSI coloring
|
# Build the output with ANSI coloring
|
||||||
# priority: player @ > other players * > mobs * > effects > terrain
|
# priority: player @ > other players * > mobs * > effects > terrain
|
||||||
half_width = VIEWPORT_WIDTH // 2
|
half_width = effective_width // 2
|
||||||
half_height = VIEWPORT_HEIGHT // 2
|
half_height = effective_height // 2
|
||||||
|
|
||||||
output_lines = []
|
output_lines = []
|
||||||
for y, row in enumerate(viewport):
|
for y, row in enumerate(viewport):
|
||||||
|
|
@ -160,6 +191,16 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
# Where header
|
# Where header
|
||||||
output.append(render_where(zone.description))
|
output.append(render_where(zone.description))
|
||||||
|
|
||||||
|
# Atmosphere line (between Where and viewport)
|
||||||
|
if hour is not None and weather is not None and season is not None:
|
||||||
|
if weather.condition == WeatherCondition.clear:
|
||||||
|
weather_desc = ""
|
||||||
|
else:
|
||||||
|
weather_desc = get_weather_description(weather)
|
||||||
|
|
||||||
|
atmosphere = render_atmosphere(hour, weather_desc, season)
|
||||||
|
output.append(atmosphere)
|
||||||
|
|
||||||
# Viewport
|
# Viewport
|
||||||
output.append("\r\n".join(output_lines))
|
output.append("\r\n".join(output_lines))
|
||||||
|
|
||||||
|
|
|
||||||
72
src/mudlib/commands/terrain.py
Normal file
72
src/mudlib/commands/terrain.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Terrain editing command for home zones."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.housing import save_home_zone
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_terrain(player: Player, args: str) -> None:
|
||||||
|
"""Paint terrain tiles in your home zone.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
terrain <tile> — paint terrain at your current position
|
||||||
|
|
||||||
|
Common tiles:
|
||||||
|
. grass
|
||||||
|
~ water
|
||||||
|
^ mountain
|
||||||
|
T tree
|
||||||
|
, dirt
|
||||||
|
" tall grass
|
||||||
|
"""
|
||||||
|
zone = player.location
|
||||||
|
|
||||||
|
# Must be in a Zone
|
||||||
|
if not isinstance(zone, Zone):
|
||||||
|
await player.send("You need to be in a zone to edit terrain.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Must be in home zone
|
||||||
|
if zone.name != player.home_zone:
|
||||||
|
await player.send("You can only edit terrain in your home zone.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for args
|
||||||
|
if not args.strip():
|
||||||
|
await player.send(
|
||||||
|
"Usage: terrain <tile>\r\n\r\n"
|
||||||
|
"Common tiles: . (grass), ~ (water), ^ (mountain), "
|
||||||
|
'T (tree), , (dirt), " (tall grass)\r\n'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
tile = args.strip()
|
||||||
|
|
||||||
|
# Must be single character
|
||||||
|
if len(tile) != 1:
|
||||||
|
await player.send("Tile must be a single character.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cannot edit border tiles
|
||||||
|
x, y = player.x, player.y
|
||||||
|
if x == 0 or x == zone.width - 1 or y == 0 or y == zone.height - 1:
|
||||||
|
await player.send("You cannot edit the border walls.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Paint the tile
|
||||||
|
zone.terrain[y][x] = tile
|
||||||
|
|
||||||
|
# Save the zone
|
||||||
|
save_home_zone(player.name, zone)
|
||||||
|
|
||||||
|
await player.send(f"You paint the ground beneath you as '{tile}'.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(
|
||||||
|
CommandDefinition(
|
||||||
|
"terrain",
|
||||||
|
cmd_terrain,
|
||||||
|
help="Paint terrain tiles in your home zone.",
|
||||||
|
)
|
||||||
|
)
|
||||||
57
src/mudlib/crafting.py
Normal file
57
src/mudlib/crafting.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Crafting recipe system."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Recipe:
|
||||||
|
"""A crafting recipe definition."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
ingredients: list[str]
|
||||||
|
result: str
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level registry
|
||||||
|
recipes: dict[str, Recipe] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_recipe(path: Path) -> Recipe:
|
||||||
|
"""Load a recipe from TOML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the recipe TOML file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Recipe instance
|
||||||
|
"""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return Recipe(
|
||||||
|
name=data["name"],
|
||||||
|
description=data["description"],
|
||||||
|
ingredients=data["ingredients"],
|
||||||
|
result=data["result"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_recipes(directory: Path) -> dict[str, Recipe]:
|
||||||
|
"""Load all recipes from a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to directory containing recipe TOML files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of recipes keyed by name
|
||||||
|
"""
|
||||||
|
loaded: dict[str, Recipe] = {}
|
||||||
|
for path in sorted(directory.glob("*.toml")):
|
||||||
|
recipe = load_recipe(path)
|
||||||
|
loaded[recipe.name] = recipe
|
||||||
|
return loaded
|
||||||
36
src/mudlib/creation.py
Normal file
36
src/mudlib/creation.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Character creation flow for new players."""
|
||||||
|
|
||||||
|
|
||||||
|
async def character_creation(
|
||||||
|
name: str,
|
||||||
|
read_func,
|
||||||
|
write_func,
|
||||||
|
) -> dict:
|
||||||
|
"""Run character creation prompts for a new player.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Player name
|
||||||
|
read_func: Async function to read a line of input (returns str or None)
|
||||||
|
write_func: Async function to write output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with creation data: {"description": str}
|
||||||
|
"""
|
||||||
|
await write_func("\r\n--- Character Creation ---\r\n\r\n")
|
||||||
|
await write_func(
|
||||||
|
"Describe yourself in a short sentence or two.\r\n"
|
||||||
|
"(This is what others see when they look at you.)\r\n\r\n"
|
||||||
|
)
|
||||||
|
await write_func("Description: ")
|
||||||
|
|
||||||
|
desc_input = await read_func()
|
||||||
|
description = ""
|
||||||
|
if desc_input is not None:
|
||||||
|
description = desc_input.strip()
|
||||||
|
|
||||||
|
if description:
|
||||||
|
await write_func(f'\r\nYou will be known as: "{description}"\r\n')
|
||||||
|
else:
|
||||||
|
await write_func("\r\nNo description set. You can change it later.\r\n")
|
||||||
|
|
||||||
|
return {"description": description}
|
||||||
|
|
@ -42,6 +42,17 @@ class GameTime:
|
||||||
|
|
||||||
return hour, minute
|
return hour, minute
|
||||||
|
|
||||||
|
def get_game_day(self) -> int:
|
||||||
|
"""Get current game day (0-based).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current game day since epoch
|
||||||
|
"""
|
||||||
|
elapsed_real_seconds = time.time() - self.epoch
|
||||||
|
elapsed_real_minutes = elapsed_real_seconds / 60
|
||||||
|
elapsed_game_hours = elapsed_real_minutes / self.real_minutes_per_game_hour
|
||||||
|
return int(elapsed_game_hours) // 24
|
||||||
|
|
||||||
|
|
||||||
# Global game time instance (initialized at server startup)
|
# Global game time instance (initialized at server startup)
|
||||||
_game_time: GameTime | None = None
|
_game_time: GameTime | None = None
|
||||||
|
|
@ -88,3 +99,17 @@ def get_game_time() -> tuple[int, int]:
|
||||||
if _game_time is None:
|
if _game_time is None:
|
||||||
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
|
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
|
||||||
return _game_time.get_game_time()
|
return _game_time.get_game_time()
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_day() -> int:
|
||||||
|
"""Get current game day from global instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current game day (0-based)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If game time not initialized
|
||||||
|
"""
|
||||||
|
if _game_time is None:
|
||||||
|
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
|
||||||
|
return _game_time.get_game_day()
|
||||||
|
|
|
||||||
237
src/mudlib/housing.py
Normal file
237
src/mudlib/housing.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""Player housing — personal zones."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mudlib.entity import Entity
|
||||||
|
from mudlib.portal import Portal
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.things import spawn_thing, thing_templates
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import get_zone, register_zone
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default home zone dimensions
|
||||||
|
HOME_WIDTH = 9
|
||||||
|
HOME_HEIGHT = 9
|
||||||
|
HOME_SPAWN_X = 4
|
||||||
|
HOME_SPAWN_Y = 4
|
||||||
|
|
||||||
|
# Where player zone files live
|
||||||
|
_zones_dir: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_housing(zones_dir: Path) -> None:
|
||||||
|
"""Set the directory for player zone files."""
|
||||||
|
global _zones_dir
|
||||||
|
_zones_dir = zones_dir
|
||||||
|
_zones_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _home_zone_name(player_name: str) -> str:
|
||||||
|
"""Return the zone registry name for a player's home."""
|
||||||
|
return f"home:{player_name.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_path(player_name: str) -> Path:
|
||||||
|
"""Return the TOML file path for a player's home zone."""
|
||||||
|
if _zones_dir is None:
|
||||||
|
raise RuntimeError("Call init_housing() first")
|
||||||
|
return _zones_dir / f"{player_name.lower()}.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def create_home_zone(player_name: str) -> Zone:
|
||||||
|
"""Create a default home zone for a player.
|
||||||
|
|
||||||
|
Creates the zone, registers it, and saves it to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_name: The player's name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created Zone
|
||||||
|
"""
|
||||||
|
zone_name = _home_zone_name(player_name)
|
||||||
|
|
||||||
|
# Build terrain — simple grass field with a border
|
||||||
|
terrain = []
|
||||||
|
for y in range(HOME_HEIGHT):
|
||||||
|
row = []
|
||||||
|
for x in range(HOME_WIDTH):
|
||||||
|
if x == 0 or x == HOME_WIDTH - 1 or y == 0 or y == HOME_HEIGHT - 1:
|
||||||
|
row.append("#") # wall border
|
||||||
|
else:
|
||||||
|
row.append(".") # open grass
|
||||||
|
terrain.append(row)
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name=zone_name,
|
||||||
|
description=f"{player_name}'s home",
|
||||||
|
width=HOME_WIDTH,
|
||||||
|
height=HOME_HEIGHT,
|
||||||
|
toroidal=False,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"#", "^", "~"},
|
||||||
|
spawn_x=HOME_SPAWN_X,
|
||||||
|
spawn_y=HOME_SPAWN_Y,
|
||||||
|
safe=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
register_zone(zone_name, zone)
|
||||||
|
save_home_zone(player_name, zone)
|
||||||
|
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def save_home_zone(player_name: str, zone: Zone) -> None:
|
||||||
|
"""Save a player's home zone to TOML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_name: The player's name
|
||||||
|
zone: The zone to save
|
||||||
|
"""
|
||||||
|
path = _zone_path(player_name)
|
||||||
|
|
||||||
|
# Build TOML content
|
||||||
|
lines = []
|
||||||
|
escaped_name = zone.name.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
escaped_desc = zone.description.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
lines.append(f'name = "{escaped_name}"')
|
||||||
|
lines.append(f'description = "{escaped_desc}"')
|
||||||
|
lines.append(f"width = {zone.width}")
|
||||||
|
lines.append(f"height = {zone.height}")
|
||||||
|
lines.append(f"toroidal = {'true' if zone.toroidal else 'false'}")
|
||||||
|
lines.append(f"spawn_x = {zone.spawn_x}")
|
||||||
|
lines.append(f"spawn_y = {zone.spawn_y}")
|
||||||
|
lines.append(f"safe = {'true' if zone.safe else 'false'}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[terrain]")
|
||||||
|
lines.append("rows = [")
|
||||||
|
for row in zone.terrain:
|
||||||
|
lines.append(f' "{"".join(row)}",')
|
||||||
|
lines.append("]")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[terrain.impassable]")
|
||||||
|
tiles = ", ".join(f'"{t}"' for t in sorted(zone.impassable))
|
||||||
|
lines.append(f"tiles = [{tiles}]")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Save furniture (Things in the zone, but not Entities or Portals)
|
||||||
|
furniture = [
|
||||||
|
obj
|
||||||
|
for obj in zone._contents
|
||||||
|
if isinstance(obj, Thing)
|
||||||
|
and not isinstance(obj, Entity)
|
||||||
|
and not isinstance(obj, Portal)
|
||||||
|
]
|
||||||
|
|
||||||
|
for item in furniture:
|
||||||
|
lines.append("[[furniture]]")
|
||||||
|
lines.append(f'template = "{item.name}"')
|
||||||
|
lines.append(f"x = {item.x}")
|
||||||
|
lines.append(f"y = {item.y}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
path.write_text("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def load_home_zone(player_name: str) -> Zone | None:
|
||||||
|
"""Load a player's home zone from disk if it exists.
|
||||||
|
|
||||||
|
Also registers it in the zone registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_name: The player's name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Zone if it exists on disk, None otherwise
|
||||||
|
"""
|
||||||
|
path = _zone_path(player_name)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
terrain = [list(row) for row in data.get("terrain", {}).get("rows", [])]
|
||||||
|
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
|
||||||
|
impassable = set(impassable_list) if impassable_list else {"#", "^", "~"}
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description", ""),
|
||||||
|
width=data["width"],
|
||||||
|
height=data["height"],
|
||||||
|
toroidal=data.get("toroidal", False),
|
||||||
|
terrain=terrain,
|
||||||
|
impassable=impassable,
|
||||||
|
spawn_x=data.get("spawn_x", 0),
|
||||||
|
spawn_y=data.get("spawn_y", 0),
|
||||||
|
safe=data.get("safe", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load furniture
|
||||||
|
furniture_list = data.get("furniture", [])
|
||||||
|
for item_data in furniture_list:
|
||||||
|
template_name = item_data.get("template")
|
||||||
|
x = item_data.get("x")
|
||||||
|
y = item_data.get("y")
|
||||||
|
|
||||||
|
if template_name not in thing_templates:
|
||||||
|
log.warning(
|
||||||
|
"Skipping unknown furniture template '%s' in %s",
|
||||||
|
template_name,
|
||||||
|
player_name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(x, int) or not isinstance(y, int):
|
||||||
|
log.warning(
|
||||||
|
"Invalid coordinates for furniture '%s' in %s",
|
||||||
|
template_name,
|
||||||
|
player_name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (0 <= x < zone.width and 0 <= y < zone.height):
|
||||||
|
log.warning(
|
||||||
|
"Out-of-bounds furniture '%s' at (%d,%d) in %s",
|
||||||
|
template_name,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
player_name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
template = thing_templates[template_name]
|
||||||
|
spawn_thing(template, zone, x=x, y=y)
|
||||||
|
|
||||||
|
register_zone(zone.name, zone)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_home(player_name: str) -> Zone:
|
||||||
|
"""Get the player's home zone, creating it if it doesn't exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_name: The player's name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The player's home Zone
|
||||||
|
"""
|
||||||
|
zone_name = _home_zone_name(player_name)
|
||||||
|
|
||||||
|
# Check registry first
|
||||||
|
zone = get_zone(zone_name)
|
||||||
|
if zone is not None:
|
||||||
|
return zone
|
||||||
|
|
||||||
|
# Try loading from disk
|
||||||
|
zone = load_home_zone(player_name)
|
||||||
|
if zone is not None:
|
||||||
|
return zone
|
||||||
|
|
||||||
|
# Create new
|
||||||
|
return create_home_zone(player_name)
|
||||||
|
|
@ -43,6 +43,9 @@ class Player(Entity):
|
||||||
unlocked_moves: set[str] = field(default_factory=set)
|
unlocked_moves: set[str] = field(default_factory=set)
|
||||||
session_start: float = 0.0
|
session_start: float = 0.0
|
||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
|
description: str = ""
|
||||||
|
home_zone: str | None = None
|
||||||
|
return_location: tuple[str, int, int] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mudlib.timeofday import get_sky_description, get_time_period
|
||||||
|
|
||||||
|
|
||||||
def render_where(zone_name: str) -> str:
|
def render_where(zone_name: str) -> str:
|
||||||
"""Render the zone description line.
|
"""Render the zone description line.
|
||||||
|
|
@ -137,3 +139,23 @@ def render_entity_lines(entities: list, viewer) -> str:
|
||||||
lines.append(f"{entity.name} {msg}")
|
lines.append(f"{entity.name} {msg}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_atmosphere(hour: int, weather_desc: str, season: str) -> str:
|
||||||
|
"""Render atmosphere line combining time-of-day and weather.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hour: Game hour (0-23)
|
||||||
|
weather_desc: Weather description (empty string for clear weather)
|
||||||
|
season: Season name ("spring", "summer", "autumn", "winter")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted atmosphere line
|
||||||
|
"""
|
||||||
|
sky = get_sky_description(hour)
|
||||||
|
period = get_time_period(hour)
|
||||||
|
|
||||||
|
if weather_desc:
|
||||||
|
return f"{sky}. {weather_desc}. [{period}, {season}]"
|
||||||
|
else:
|
||||||
|
return f"{sky}. [{period}, {season}]"
|
||||||
|
|
|
||||||
68
src/mudlib/seasons.py
Normal file
68
src/mudlib/seasons.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Season system derived from game day count."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
SEASONS: list[str] = ["spring", "summer", "autumn", "winter"]
|
||||||
|
DAYS_PER_SEASON: int = 7 # 28-day year
|
||||||
|
|
||||||
|
|
||||||
|
def get_season(game_day: int, days_per_season: int = DAYS_PER_SEASON) -> str:
|
||||||
|
"""Return the current season based on game day count.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_day: Current game day (0-based)
|
||||||
|
days_per_season: Number of days per season (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current season: "spring", "summer", "autumn", or "winter"
|
||||||
|
"""
|
||||||
|
if game_day < 0:
|
||||||
|
game_day = 0
|
||||||
|
|
||||||
|
days_per_year = days_per_season * 4
|
||||||
|
day_of_year = game_day % days_per_year
|
||||||
|
season_index = day_of_year // days_per_season
|
||||||
|
|
||||||
|
return SEASONS[season_index]
|
||||||
|
|
||||||
|
|
||||||
|
def get_day_of_year(game_day: int, days_per_season: int = DAYS_PER_SEASON) -> int:
|
||||||
|
"""Return the day within the current year (0 to days_per_season*4 - 1).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_day: Current game day (0-based)
|
||||||
|
days_per_season: Number of days per season (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Day of year (0-based)
|
||||||
|
"""
|
||||||
|
if game_day < 0:
|
||||||
|
game_day = 0
|
||||||
|
|
||||||
|
days_per_year = days_per_season * 4
|
||||||
|
return game_day % days_per_year
|
||||||
|
|
||||||
|
|
||||||
|
def get_season_description(season: str, terrain: str) -> str:
|
||||||
|
"""Return a seasonal description variant for a terrain type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Current season
|
||||||
|
terrain: Terrain type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Seasonal description for this terrain, or empty string if no variation
|
||||||
|
"""
|
||||||
|
# Only grass and forest have seasonal variation
|
||||||
|
descriptions = {
|
||||||
|
("spring", "grass"): "fresh green grass springs up everywhere",
|
||||||
|
("summer", "grass"): "golden grass waves in the breeze",
|
||||||
|
("autumn", "grass"): "the grass turns brown and brittle",
|
||||||
|
("winter", "grass"): "frost clings to brittle brown grass",
|
||||||
|
("spring", "forest"): "the trees burst with pale blossoms",
|
||||||
|
("summer", "forest"): "a thick green canopy spreads overhead",
|
||||||
|
("autumn", "forest"): "the trees blaze with amber and crimson",
|
||||||
|
("winter", "forest"): "bare branches reach toward the sky",
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptions.get((season, terrain), "")
|
||||||
|
|
@ -16,10 +16,14 @@ from telnetlib3.server_shell import readline2
|
||||||
import mudlib.combat.commands
|
import mudlib.combat.commands
|
||||||
import mudlib.commands
|
import mudlib.commands
|
||||||
import mudlib.commands.containers
|
import mudlib.commands.containers
|
||||||
|
import mudlib.commands.crafting
|
||||||
|
import mudlib.commands.describe
|
||||||
import mudlib.commands.edit
|
import mudlib.commands.edit
|
||||||
import mudlib.commands.examine
|
import mudlib.commands.examine
|
||||||
import mudlib.commands.fly
|
import mudlib.commands.fly
|
||||||
|
import mudlib.commands.furnish
|
||||||
import mudlib.commands.help
|
import mudlib.commands.help
|
||||||
|
import mudlib.commands.home
|
||||||
import mudlib.commands.look
|
import mudlib.commands.look
|
||||||
import mudlib.commands.movement
|
import mudlib.commands.movement
|
||||||
import mudlib.commands.play
|
import mudlib.commands.play
|
||||||
|
|
@ -30,6 +34,7 @@ import mudlib.commands.reload
|
||||||
import mudlib.commands.snapneck
|
import mudlib.commands.snapneck
|
||||||
import mudlib.commands.spawn
|
import mudlib.commands.spawn
|
||||||
import mudlib.commands.talk
|
import mudlib.commands.talk
|
||||||
|
import mudlib.commands.terrain
|
||||||
import mudlib.commands.things
|
import mudlib.commands.things
|
||||||
import mudlib.commands.use
|
import mudlib.commands.use
|
||||||
from mudlib.caps import parse_mtts
|
from mudlib.caps import parse_mtts
|
||||||
|
|
@ -37,6 +42,8 @@ from mudlib.combat.commands import register_combat_commands
|
||||||
from mudlib.combat.engine import process_combat
|
from mudlib.combat.engine import process_combat
|
||||||
from mudlib.content import load_commands
|
from mudlib.content import load_commands
|
||||||
from mudlib.corpse import process_decomposing
|
from mudlib.corpse import process_decomposing
|
||||||
|
from mudlib.crafting import load_recipes, recipes
|
||||||
|
from mudlib.creation import character_creation
|
||||||
from mudlib.dialogue import load_all_dialogues
|
from mudlib.dialogue import load_all_dialogues
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
from mudlib.gametime import get_game_hour, init_game_time
|
from mudlib.gametime import get_game_hour, init_game_time
|
||||||
|
|
@ -47,6 +54,7 @@ from mudlib.gmcp import (
|
||||||
send_msdp_vitals,
|
send_msdp_vitals,
|
||||||
send_room_info,
|
send_room_info,
|
||||||
)
|
)
|
||||||
|
from mudlib.housing import init_housing, load_home_zone
|
||||||
from mudlib.if_session import broadcast_to_spectators
|
from mudlib.if_session import broadcast_to_spectators
|
||||||
from mudlib.mob_ai import process_mob_movement, process_mobs
|
from mudlib.mob_ai import process_mob_movement, process_mobs
|
||||||
from mudlib.mobs import load_mob_templates, mob_templates, mobs
|
from mudlib.mobs import load_mob_templates, mob_templates, mobs
|
||||||
|
|
@ -64,6 +72,7 @@ from mudlib.store import (
|
||||||
load_player_data,
|
load_player_data,
|
||||||
load_player_stats,
|
load_player_stats,
|
||||||
save_player,
|
save_player,
|
||||||
|
save_player_description,
|
||||||
update_last_login,
|
update_last_login,
|
||||||
)
|
)
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
|
|
@ -198,7 +207,7 @@ async def handle_login(
|
||||||
if authenticate(name, password.strip()):
|
if authenticate(name, password.strip()):
|
||||||
# Success - load player data
|
# Success - load player data
|
||||||
player_data = load_player_data(name)
|
player_data = load_player_data(name)
|
||||||
return {"success": True, "player_data": player_data}
|
return {"success": True, "player_data": player_data, "is_new": False}
|
||||||
|
|
||||||
remaining = max_attempts - attempt - 1
|
remaining = max_attempts - attempt - 1
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
|
|
@ -236,7 +245,7 @@ async def handle_login(
|
||||||
await write_func("Account created successfully!\r\n")
|
await write_func("Account created successfully!\r\n")
|
||||||
# Return default data for new account
|
# Return default data for new account
|
||||||
player_data = load_player_data(name)
|
player_data = load_player_data(name)
|
||||||
return {"success": True, "player_data": player_data}
|
return {"success": True, "player_data": player_data, "is_new": True}
|
||||||
await write_func("Failed to create account.\r\n")
|
await write_func("Failed to create account.\r\n")
|
||||||
return {"success": False, "player_data": None}
|
return {"success": False, "player_data": None}
|
||||||
|
|
||||||
|
|
@ -310,6 +319,15 @@ async def shell(
|
||||||
|
|
||||||
# Load player data from database or use defaults for new player
|
# Load player data from database or use defaults for new player
|
||||||
player_data: PlayerData | None = login_result["player_data"]
|
player_data: PlayerData | None = login_result["player_data"]
|
||||||
|
|
||||||
|
# Run character creation for new accounts
|
||||||
|
is_new_account = login_result.get("is_new", False)
|
||||||
|
if is_new_account:
|
||||||
|
creation_data = await character_creation(player_name, read_input, write_output)
|
||||||
|
if creation_data.get("description"):
|
||||||
|
save_player_description(player_name, creation_data["description"])
|
||||||
|
if player_data is not None:
|
||||||
|
player_data["description"] = creation_data["description"]
|
||||||
if player_data is None:
|
if player_data is None:
|
||||||
# New player - find a passable starting position
|
# New player - find a passable starting position
|
||||||
center_x = _overworld.width // 2
|
center_x = _overworld.width // 2
|
||||||
|
|
@ -324,6 +342,8 @@ async def shell(
|
||||||
"flying": False,
|
"flying": False,
|
||||||
"zone_name": "overworld",
|
"zone_name": "overworld",
|
||||||
"inventory": [],
|
"inventory": [],
|
||||||
|
"description": "",
|
||||||
|
"home_zone": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve zone from zone_name using zone registry
|
# Resolve zone from zone_name using zone registry
|
||||||
|
|
@ -346,6 +366,11 @@ async def shell(
|
||||||
player_data["x"] = start_x
|
player_data["x"] = start_x
|
||||||
player_data["y"] = start_y
|
player_data["y"] = start_y
|
||||||
|
|
||||||
|
# Load player's home zone if they have one
|
||||||
|
home_zone_name = player_data.get("home_zone")
|
||||||
|
if home_zone_name and home_zone_name.startswith("home:"):
|
||||||
|
load_home_zone(player_name)
|
||||||
|
|
||||||
# Create player instance
|
# Create player instance
|
||||||
player = Player(
|
player = Player(
|
||||||
name=player_name,
|
name=player_name,
|
||||||
|
|
@ -360,6 +385,10 @@ async def shell(
|
||||||
reader=_reader,
|
reader=_reader,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set description and home zone
|
||||||
|
player.description = player_data.get("description", "")
|
||||||
|
player.home_zone = player_data.get("home_zone")
|
||||||
|
|
||||||
# Load aliases from database
|
# Load aliases from database
|
||||||
player.aliases = load_aliases(player_name)
|
player.aliases = load_aliases(player_name)
|
||||||
|
|
||||||
|
|
@ -551,6 +580,11 @@ async def run_server() -> None:
|
||||||
register_zone(zone_name, zone)
|
register_zone(zone_name, zone)
|
||||||
log.info("loaded %d zones from %s", len(loaded_zones), zones_dir)
|
log.info("loaded %d zones from %s", len(loaded_zones), zones_dir)
|
||||||
|
|
||||||
|
# Initialize player housing
|
||||||
|
player_zones_dir = data_dir / "player_zones"
|
||||||
|
init_housing(player_zones_dir)
|
||||||
|
log.info("player housing initialized at %s", player_zones_dir)
|
||||||
|
|
||||||
# Load content-defined commands from TOML files
|
# Load content-defined commands from TOML files
|
||||||
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
||||||
if content_dir.exists():
|
if content_dir.exists():
|
||||||
|
|
@ -581,6 +615,13 @@ async def run_server() -> None:
|
||||||
thing_templates.update(loaded_things)
|
thing_templates.update(loaded_things)
|
||||||
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
|
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
|
||||||
|
|
||||||
|
# Load crafting recipes
|
||||||
|
recipes_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "recipes"
|
||||||
|
if recipes_dir.exists():
|
||||||
|
loaded_recipes = load_recipes(recipes_dir)
|
||||||
|
recipes.update(loaded_recipes)
|
||||||
|
log.info("loaded %d recipes from %s", len(loaded_recipes), recipes_dir)
|
||||||
|
|
||||||
# Load dialogue trees for NPC conversations
|
# Load dialogue trees for NPC conversations
|
||||||
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
|
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
|
||||||
if dialogue_dir.exists():
|
if dialogue_dir.exists():
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class PlayerData(TypedDict):
|
||||||
flying: bool
|
flying: bool
|
||||||
zone_name: str
|
zone_name: str
|
||||||
inventory: list[str]
|
inventory: list[str]
|
||||||
|
description: str
|
||||||
|
home_zone: str | None
|
||||||
|
|
||||||
|
|
||||||
class StatsData(TypedDict):
|
class StatsData(TypedDict):
|
||||||
|
|
@ -104,6 +106,12 @@ def init_db(db_path: str | Path) -> None:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'"
|
"ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'"
|
||||||
)
|
)
|
||||||
|
if "description" not in columns:
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE accounts ADD COLUMN description TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
if "home_zone" not in columns:
|
||||||
|
cursor.execute("ALTER TABLE accounts ADD COLUMN home_zone TEXT")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -218,6 +226,40 @@ def authenticate(name: str, password: str) -> bool:
|
||||||
return hmac.compare_digest(password_hash, stored_hash)
|
return hmac.compare_digest(password_hash, stored_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def save_player_description(name: str, description: str) -> None:
|
||||||
|
"""Save a player's description to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Account name
|
||||||
|
description: Description text
|
||||||
|
"""
|
||||||
|
conn = _get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE accounts SET description = ? WHERE name = ?",
|
||||||
|
(description, name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_player_home_zone(name: str, home_zone: str | None) -> None:
|
||||||
|
"""Save a player's home zone to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Account name
|
||||||
|
home_zone: Home zone name
|
||||||
|
"""
|
||||||
|
conn = _get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE accounts SET home_zone = ? WHERE name = ?",
|
||||||
|
(home_zone, name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def save_player(player: Player) -> None:
|
def save_player(player: Player) -> None:
|
||||||
"""Save player state to the database.
|
"""Save player state to the database.
|
||||||
|
|
||||||
|
|
@ -242,7 +284,7 @@ def save_player(player: Player) -> None:
|
||||||
"""
|
"""
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
||||||
zone_name = ?, inventory = ?
|
zone_name = ?, inventory = ?, description = ?, home_zone = ?
|
||||||
WHERE name = ?
|
WHERE name = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -254,6 +296,8 @@ def save_player(player: Player) -> None:
|
||||||
1 if player.flying else 0,
|
1 if player.flying else 0,
|
||||||
player.location.name if player.location else "overworld",
|
player.location.name if player.location else "overworld",
|
||||||
inventory_json,
|
inventory_json,
|
||||||
|
player.description,
|
||||||
|
player.home_zone,
|
||||||
player.name,
|
player.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -283,6 +327,8 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
has_zone_name = "zone_name" in columns
|
has_zone_name = "zone_name" in columns
|
||||||
has_inventory = "inventory" in columns
|
has_inventory = "inventory" in columns
|
||||||
|
has_description = "description" in columns
|
||||||
|
has_home_zone = "home_zone" in columns
|
||||||
|
|
||||||
# Build SELECT based on available columns
|
# Build SELECT based on available columns
|
||||||
select_cols = "x, y, pl, stamina, max_stamina, flying"
|
select_cols = "x, y, pl, stamina, max_stamina, flying"
|
||||||
|
|
@ -290,6 +336,10 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
select_cols += ", zone_name"
|
select_cols += ", zone_name"
|
||||||
if has_inventory:
|
if has_inventory:
|
||||||
select_cols += ", inventory"
|
select_cols += ", inventory"
|
||||||
|
if has_description:
|
||||||
|
select_cols += ", description"
|
||||||
|
if has_home_zone:
|
||||||
|
select_cols += ", home_zone"
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f"SELECT {select_cols} FROM accounts WHERE name = ?",
|
f"SELECT {select_cols} FROM accounts WHERE name = ?",
|
||||||
|
|
@ -314,6 +364,17 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
inventory: list[str] = []
|
inventory: list[str] = []
|
||||||
if has_inventory:
|
if has_inventory:
|
||||||
inventory = json.loads(result[idx])
|
inventory = json.loads(result[idx])
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
description = ""
|
||||||
|
if has_description:
|
||||||
|
description = result[idx]
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
home_zone = None
|
||||||
|
if has_home_zone:
|
||||||
|
home_zone = result[idx]
|
||||||
|
idx += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"x": x,
|
"x": x,
|
||||||
|
|
@ -324,6 +385,8 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
"flying": bool(flying_int),
|
"flying": bool(flying_int),
|
||||||
"zone_name": zone_name,
|
"zone_name": zone_name,
|
||||||
"inventory": inventory,
|
"inventory": inventory,
|
||||||
|
"description": description,
|
||||||
|
"home_zone": home_zone,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
70
src/mudlib/timeofday.py
Normal file
70
src/mudlib/timeofday.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Time-of-day periods and atmospheric descriptions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_period(hour: int) -> str:
|
||||||
|
"""Map a game hour (0-23) to a time period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hour: Game hour (0-23)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Time period: "dawn", "day", "dusk", or "night"
|
||||||
|
"""
|
||||||
|
if 5 <= hour <= 6:
|
||||||
|
return "dawn"
|
||||||
|
elif 7 <= hour <= 17:
|
||||||
|
return "day"
|
||||||
|
elif 18 <= hour <= 19:
|
||||||
|
return "dusk"
|
||||||
|
else:
|
||||||
|
return "night"
|
||||||
|
|
||||||
|
|
||||||
|
def get_sky_description(hour: int) -> str:
|
||||||
|
"""Return an atmospheric one-liner for the current time of day.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hour: Game hour (0-23)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Short atmospheric description based on time of day
|
||||||
|
"""
|
||||||
|
period = get_time_period(hour)
|
||||||
|
|
||||||
|
# multiple variants per period, pick based on hour for determinism
|
||||||
|
descriptions = {
|
||||||
|
"dawn": [
|
||||||
|
"pale light seeps across the horizon",
|
||||||
|
"the first rays of dawn touch the sky",
|
||||||
|
],
|
||||||
|
"day": [
|
||||||
|
"the sun hangs high overhead",
|
||||||
|
"bright daylight illuminates everything",
|
||||||
|
"warm light fills the air",
|
||||||
|
"midday light is at its strongest",
|
||||||
|
"golden afternoon light slants across the land",
|
||||||
|
"the day is bright and warm",
|
||||||
|
"light glints off distant surfaces",
|
||||||
|
"the sun begins its slow descent",
|
||||||
|
"late afternoon shadows grow longer",
|
||||||
|
"the sun drifts toward the western horizon",
|
||||||
|
"the day's light begins to soften",
|
||||||
|
],
|
||||||
|
"dusk": [
|
||||||
|
"the sky burns orange and violet",
|
||||||
|
"twilight deepens across the landscape",
|
||||||
|
],
|
||||||
|
"night": [
|
||||||
|
"stars wheel slowly overhead",
|
||||||
|
"darkness blankets the world",
|
||||||
|
"the night is deep and still",
|
||||||
|
"moonlight casts pale shadows",
|
||||||
|
"the hours before dawn stretch long and dark",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
variants = descriptions[period]
|
||||||
|
# use hour to pick variant deterministically
|
||||||
|
return variants[hour % len(variants)]
|
||||||
54
src/mudlib/visibility.py
Normal file
54
src/mudlib/visibility.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Visibility calculations based on time and weather."""
|
||||||
|
|
||||||
|
from mudlib.weather import WeatherCondition, WeatherState
|
||||||
|
|
||||||
|
|
||||||
|
def get_visibility(
|
||||||
|
hour: int,
|
||||||
|
weather: WeatherState,
|
||||||
|
base_width: int = 21,
|
||||||
|
base_height: int = 11,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Calculate effective viewport dimensions.
|
||||||
|
|
||||||
|
Night, fog, and storms reduce visibility. Multiple effects stack
|
||||||
|
but never reduce below minimum (7x5).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (effective_width, effective_height)
|
||||||
|
"""
|
||||||
|
width = base_width
|
||||||
|
height = base_height
|
||||||
|
|
||||||
|
# Time-based reductions
|
||||||
|
if hour >= 20 or hour <= 4:
|
||||||
|
# Night (hours 20-4)
|
||||||
|
width -= 6
|
||||||
|
height -= 2
|
||||||
|
elif 5 <= hour <= 6:
|
||||||
|
# Dawn (hours 5-6)
|
||||||
|
width -= 2
|
||||||
|
elif 18 <= hour <= 19:
|
||||||
|
# Dusk (hours 18-19)
|
||||||
|
width -= 2
|
||||||
|
|
||||||
|
# Weather-based reductions
|
||||||
|
if weather.condition == WeatherCondition.fog:
|
||||||
|
if weather.intensity >= 0.7:
|
||||||
|
# Thick fog
|
||||||
|
width -= 8
|
||||||
|
height -= 4
|
||||||
|
elif weather.intensity >= 0.4:
|
||||||
|
# Moderate fog
|
||||||
|
width -= 4
|
||||||
|
height -= 2
|
||||||
|
elif weather.condition == WeatherCondition.storm:
|
||||||
|
# Storm
|
||||||
|
width -= 4
|
||||||
|
height -= 2
|
||||||
|
|
||||||
|
# Clamp to minimum
|
||||||
|
width = max(7, width)
|
||||||
|
height = max(5, height)
|
||||||
|
|
||||||
|
return width, height
|
||||||
292
src/mudlib/weather.py
Normal file
292
src/mudlib/weather.py
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"""Weather system with procedural transitions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherCondition(enum.Enum):
|
||||||
|
clear = "clear"
|
||||||
|
cloudy = "cloudy"
|
||||||
|
rain = "rain"
|
||||||
|
storm = "storm"
|
||||||
|
snow = "snow"
|
||||||
|
fog = "fog"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WeatherState:
|
||||||
|
condition: WeatherCondition
|
||||||
|
intensity: float # 0.0 to 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_description(state: WeatherState) -> str:
|
||||||
|
"""Return atmospheric text for the current weather."""
|
||||||
|
condition = state.condition
|
||||||
|
intensity = state.intensity
|
||||||
|
|
||||||
|
if condition == WeatherCondition.clear:
|
||||||
|
return "the sky is clear"
|
||||||
|
|
||||||
|
elif condition == WeatherCondition.cloudy:
|
||||||
|
if intensity < 0.4:
|
||||||
|
return "thin clouds drift overhead"
|
||||||
|
elif intensity < 0.7:
|
||||||
|
return "clouds fill the sky"
|
||||||
|
else:
|
||||||
|
return "heavy clouds loom darkly"
|
||||||
|
|
||||||
|
elif condition == WeatherCondition.rain:
|
||||||
|
if intensity < 0.3:
|
||||||
|
return "a light drizzle falls"
|
||||||
|
elif intensity < 0.6:
|
||||||
|
return "rain patters steadily"
|
||||||
|
else:
|
||||||
|
return "rain hammers down relentlessly"
|
||||||
|
|
||||||
|
elif condition == WeatherCondition.storm:
|
||||||
|
if intensity < 0.5:
|
||||||
|
return "thunder rumbles in the distance"
|
||||||
|
else:
|
||||||
|
return "lightning splits the sky as the storm rages"
|
||||||
|
|
||||||
|
elif condition == WeatherCondition.snow:
|
||||||
|
if intensity < 0.3:
|
||||||
|
return "light snow drifts down"
|
||||||
|
elif intensity < 0.6:
|
||||||
|
return "snow falls steadily"
|
||||||
|
else:
|
||||||
|
return "heavy snow blankets everything"
|
||||||
|
|
||||||
|
elif condition == WeatherCondition.fog:
|
||||||
|
if intensity < 0.4:
|
||||||
|
return "thin mist hangs in the air"
|
||||||
|
elif intensity < 0.7:
|
||||||
|
return "fog obscures the distance"
|
||||||
|
else:
|
||||||
|
return "thick fog shrouds everything"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# Climate profiles: transition weights for each condition
|
||||||
|
# Format: {from_condition: {to_condition: weight, ...}, ...}
|
||||||
|
CLIMATE_PROFILES = {
|
||||||
|
"temperate": {
|
||||||
|
WeatherCondition.clear: {
|
||||||
|
"clear": 50,
|
||||||
|
"cloudy": 40,
|
||||||
|
"fog": 10,
|
||||||
|
},
|
||||||
|
WeatherCondition.cloudy: {
|
||||||
|
"clear": 30,
|
||||||
|
"cloudy": 35,
|
||||||
|
"rain": 20,
|
||||||
|
"snow": 10,
|
||||||
|
"fog": 5,
|
||||||
|
},
|
||||||
|
WeatherCondition.rain: {
|
||||||
|
"cloudy": 35,
|
||||||
|
"rain": 30,
|
||||||
|
"storm": 15,
|
||||||
|
"snow": 10,
|
||||||
|
"fog": 10,
|
||||||
|
},
|
||||||
|
WeatherCondition.storm: {
|
||||||
|
"rain": 60,
|
||||||
|
"cloudy": 40,
|
||||||
|
},
|
||||||
|
WeatherCondition.snow: {
|
||||||
|
"snow": 40,
|
||||||
|
"cloudy": 35,
|
||||||
|
"fog": 25,
|
||||||
|
},
|
||||||
|
WeatherCondition.fog: {
|
||||||
|
"fog": 30,
|
||||||
|
"cloudy": 40,
|
||||||
|
"clear": 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"arid": {
|
||||||
|
WeatherCondition.clear: {
|
||||||
|
"clear": 90,
|
||||||
|
"cloudy": 10,
|
||||||
|
},
|
||||||
|
WeatherCondition.cloudy: {
|
||||||
|
"clear": 70,
|
||||||
|
"cloudy": 20,
|
||||||
|
"rain": 5,
|
||||||
|
"fog": 5,
|
||||||
|
},
|
||||||
|
WeatherCondition.rain: {
|
||||||
|
"cloudy": 60,
|
||||||
|
"rain": 20,
|
||||||
|
"clear": 20,
|
||||||
|
},
|
||||||
|
WeatherCondition.storm: {
|
||||||
|
"rain": 50,
|
||||||
|
"cloudy": 50,
|
||||||
|
},
|
||||||
|
WeatherCondition.snow: {
|
||||||
|
"cloudy": 100,
|
||||||
|
},
|
||||||
|
WeatherCondition.fog: {
|
||||||
|
"clear": 60,
|
||||||
|
"fog": 20,
|
||||||
|
"cloudy": 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"arctic": {
|
||||||
|
WeatherCondition.clear: {
|
||||||
|
"cloudy": 50,
|
||||||
|
"fog": 30,
|
||||||
|
"clear": 20,
|
||||||
|
},
|
||||||
|
WeatherCondition.cloudy: {
|
||||||
|
"snow": 40,
|
||||||
|
"cloudy": 35,
|
||||||
|
"fog": 25,
|
||||||
|
},
|
||||||
|
WeatherCondition.rain: {
|
||||||
|
"snow": 40,
|
||||||
|
"cloudy": 40,
|
||||||
|
"fog": 20,
|
||||||
|
},
|
||||||
|
WeatherCondition.storm: {
|
||||||
|
"snow": 60,
|
||||||
|
"cloudy": 40,
|
||||||
|
},
|
||||||
|
WeatherCondition.snow: {
|
||||||
|
"snow": 50,
|
||||||
|
"cloudy": 30,
|
||||||
|
"fog": 20,
|
||||||
|
},
|
||||||
|
WeatherCondition.fog: {
|
||||||
|
"fog": 40,
|
||||||
|
"cloudy": 40,
|
||||||
|
"snow": 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def advance_weather(
|
||||||
|
current: WeatherState,
|
||||||
|
season: str = "summer",
|
||||||
|
rng: random.Random | None = None,
|
||||||
|
climate: str = "temperate",
|
||||||
|
) -> WeatherState:
|
||||||
|
"""Advance weather by one step (one game hour)."""
|
||||||
|
if rng is None:
|
||||||
|
rng = random.Random()
|
||||||
|
|
||||||
|
# Get climate profile
|
||||||
|
profile = CLIMATE_PROFILES.get(climate, CLIMATE_PROFILES["temperate"])
|
||||||
|
|
||||||
|
# Get transition weights for current condition
|
||||||
|
transitions = profile.get(current.condition, {})
|
||||||
|
|
||||||
|
# Filter out snow if not winter/autumn
|
||||||
|
if season not in ("winter", "autumn"):
|
||||||
|
transitions = {k: v for k, v in transitions.items() if k != "snow"}
|
||||||
|
|
||||||
|
# If no valid transitions, stay in current condition
|
||||||
|
if not transitions:
|
||||||
|
return WeatherState(
|
||||||
|
condition=current.condition, intensity=rng.uniform(0.0, 1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Weighted random choice
|
||||||
|
conditions = list(transitions.keys())
|
||||||
|
weights = list(transitions.values())
|
||||||
|
|
||||||
|
# Convert string keys to WeatherCondition enums
|
||||||
|
resolved_conditions = []
|
||||||
|
for cond in conditions:
|
||||||
|
if isinstance(cond, str):
|
||||||
|
resolved_conditions.append(WeatherCondition[cond])
|
||||||
|
else:
|
||||||
|
resolved_conditions.append(cond)
|
||||||
|
|
||||||
|
new_condition = rng.choices(resolved_conditions, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
# Generate appropriate intensity for the new condition
|
||||||
|
if new_condition == WeatherCondition.clear:
|
||||||
|
new_intensity = 0.5 # Clear has no meaningful intensity variation
|
||||||
|
elif new_condition == WeatherCondition.storm:
|
||||||
|
new_intensity = rng.uniform(0.5, 1.0) # Storms are always intense
|
||||||
|
else:
|
||||||
|
new_intensity = rng.uniform(0.0, 1.0)
|
||||||
|
|
||||||
|
return WeatherState(condition=new_condition, intensity=new_intensity)
|
||||||
|
|
||||||
|
|
||||||
|
# Global weather state
|
||||||
|
_current_weather: WeatherState | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_weather(
|
||||||
|
condition: WeatherCondition = WeatherCondition.clear, intensity: float = 0.5
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the global weather state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
condition: Initial weather condition
|
||||||
|
intensity: Initial intensity (0.0 to 1.0)
|
||||||
|
"""
|
||||||
|
global _current_weather
|
||||||
|
_current_weather = WeatherState(condition=condition, intensity=intensity)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_weather() -> WeatherState:
|
||||||
|
"""Get the current global weather state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current weather state (defaults to clear if not initialized)
|
||||||
|
"""
|
||||||
|
if _current_weather is None:
|
||||||
|
return WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
return _current_weather
|
||||||
|
|
||||||
|
|
||||||
|
def tick_weather(season: str = "summer", climate: str = "temperate") -> None:
|
||||||
|
"""Advance weather by one step. Called once per game hour.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
season: Current season
|
||||||
|
climate: Climate profile to use
|
||||||
|
"""
|
||||||
|
global _current_weather
|
||||||
|
current = get_current_weather()
|
||||||
|
_current_weather = advance_weather(current, season=season, climate=climate)
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_ambience(condition: WeatherCondition) -> list[str]:
|
||||||
|
"""Return ambient messages appropriate for the weather."""
|
||||||
|
if condition == WeatherCondition.rain:
|
||||||
|
return [
|
||||||
|
"rain patters on the ground around you.",
|
||||||
|
"water drips from above.",
|
||||||
|
"you hear the steady rhythm of rainfall.",
|
||||||
|
]
|
||||||
|
elif condition == WeatherCondition.storm:
|
||||||
|
return [
|
||||||
|
"thunder cracks overhead.",
|
||||||
|
"lightning flashes in the distance.",
|
||||||
|
"the wind howls fiercely.",
|
||||||
|
]
|
||||||
|
elif condition == WeatherCondition.snow:
|
||||||
|
return [
|
||||||
|
"snowflakes drift silently down.",
|
||||||
|
"the world is muffled under falling snow.",
|
||||||
|
]
|
||||||
|
elif condition == WeatherCondition.fog:
|
||||||
|
return [
|
||||||
|
"mist swirls around your feet.",
|
||||||
|
"shapes loom and fade in the fog.",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# clear or cloudy: no extra ambience
|
||||||
|
return []
|
||||||
242
tests/test_command_craft.py
Normal file
242
tests/test_command_craft.py
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"""Tests for crafting commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.crafting import cmd_craft, cmd_recipes
|
||||||
|
from mudlib.crafting import Recipe, recipes
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.things import ThingTemplate, spawn_thing, thing_templates
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registries():
|
||||||
|
"""Snapshot and restore registries to prevent test leakage."""
|
||||||
|
saved_zones = dict(zone_registry)
|
||||||
|
saved_templates = dict(thing_templates)
|
||||||
|
saved_recipes = dict(recipes)
|
||||||
|
zone_registry.clear()
|
||||||
|
thing_templates.clear()
|
||||||
|
recipes.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved_zones)
|
||||||
|
thing_templates.clear()
|
||||||
|
thing_templates.update(saved_templates)
|
||||||
|
recipes.clear()
|
||||||
|
recipes.update(saved_recipes)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_zone(name="overworld", width=20, height=20):
|
||||||
|
"""Create a test zone."""
|
||||||
|
terrain = [["." for _ in range(width)] for _ in range(height)]
|
||||||
|
zone = Zone(
|
||||||
|
name=name,
|
||||||
|
description=name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=True,
|
||||||
|
)
|
||||||
|
register_zone(name, zone)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(name="tester", zone=None, x=5, y=5):
|
||||||
|
"""Create a test player with mock writer."""
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
return Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_craft_success():
|
||||||
|
"""Craft with ingredients: consumed, result in inventory."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
# Register templates
|
||||||
|
plank_template = ThingTemplate(name="plank", description="A wooden plank")
|
||||||
|
nail_template = ThingTemplate(name="nail", description="A small nail")
|
||||||
|
table_template = ThingTemplate(name="table", description="A sturdy table")
|
||||||
|
thing_templates["plank"] = plank_template
|
||||||
|
thing_templates["nail"] = nail_template
|
||||||
|
thing_templates["table"] = table_template
|
||||||
|
|
||||||
|
# Add ingredients to player inventory
|
||||||
|
plank1 = spawn_thing(plank_template, player)
|
||||||
|
plank2 = spawn_thing(plank_template, player)
|
||||||
|
nail1 = spawn_thing(nail_template, player)
|
||||||
|
|
||||||
|
# Register recipe
|
||||||
|
recipe = Recipe(
|
||||||
|
name="wooden_table",
|
||||||
|
description="Craft a table",
|
||||||
|
ingredients=["plank", "plank", "nail"],
|
||||||
|
result="table",
|
||||||
|
)
|
||||||
|
recipes["wooden_table"] = recipe
|
||||||
|
|
||||||
|
# Craft the item
|
||||||
|
await cmd_craft(player, "wooden_table")
|
||||||
|
|
||||||
|
# Check success message was sent
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "table" in output.lower()
|
||||||
|
|
||||||
|
# Check ingredients were consumed
|
||||||
|
inventory = player.contents
|
||||||
|
assert plank1 not in inventory
|
||||||
|
assert plank2 not in inventory
|
||||||
|
assert nail1 not in inventory
|
||||||
|
|
||||||
|
# Check result was added to inventory
|
||||||
|
table_in_inventory = [obj for obj in inventory if obj.name == "table"]
|
||||||
|
assert len(table_in_inventory) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_craft_missing_ingredients():
|
||||||
|
"""Error message listing what's missing."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
# Register templates
|
||||||
|
plank_template = ThingTemplate(name="plank", description="A wooden plank")
|
||||||
|
table_template = ThingTemplate(name="table", description="A sturdy table")
|
||||||
|
thing_templates["plank"] = plank_template
|
||||||
|
thing_templates["table"] = table_template
|
||||||
|
|
||||||
|
# Add only one plank (recipe needs two)
|
||||||
|
spawn_thing(plank_template, player)
|
||||||
|
|
||||||
|
# Register recipe needing two planks
|
||||||
|
recipe = Recipe(
|
||||||
|
name="wooden_table",
|
||||||
|
description="Craft a table",
|
||||||
|
ingredients=["plank", "plank"],
|
||||||
|
result="table",
|
||||||
|
)
|
||||||
|
recipes["wooden_table"] = recipe
|
||||||
|
|
||||||
|
# Try to craft
|
||||||
|
await cmd_craft(player, "wooden_table")
|
||||||
|
|
||||||
|
# Check error message
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "missing" in output.lower() or "need" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_craft_unknown_recipe():
|
||||||
|
"""Error for nonexistent recipe."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
await cmd_craft(player, "nonexistent_recipe")
|
||||||
|
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "unknown" in output.lower() or "not found" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_craft_no_args():
|
||||||
|
"""Error with usage when no args provided."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
await cmd_craft(player, "")
|
||||||
|
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "usage" in output.lower() or "craft" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_craft_unknown_result_template():
|
||||||
|
"""Recipe result template not in thing_templates."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
# Register template for ingredient
|
||||||
|
plank_template = ThingTemplate(name="plank", description="A wooden plank")
|
||||||
|
thing_templates["plank"] = plank_template
|
||||||
|
|
||||||
|
# Add ingredient to inventory
|
||||||
|
spawn_thing(plank_template, player)
|
||||||
|
|
||||||
|
# Register recipe with unknown result template
|
||||||
|
recipe = Recipe(
|
||||||
|
name="broken_recipe",
|
||||||
|
description="This recipe has a broken result",
|
||||||
|
ingredients=["plank"],
|
||||||
|
result="unknown_item", # This template doesn't exist
|
||||||
|
)
|
||||||
|
recipes["broken_recipe"] = recipe
|
||||||
|
|
||||||
|
# Try to craft
|
||||||
|
await cmd_craft(player, "broken_recipe")
|
||||||
|
|
||||||
|
# Check error message
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "error" in output.lower() or "unknown" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipes_list():
|
||||||
|
"""Shows available recipes."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
# Register some recipes
|
||||||
|
recipes["wooden_table"] = Recipe(
|
||||||
|
name="wooden_table",
|
||||||
|
description="Craft a table",
|
||||||
|
ingredients=["plank", "plank"],
|
||||||
|
result="table",
|
||||||
|
)
|
||||||
|
recipes["wooden_chair"] = Recipe(
|
||||||
|
name="wooden_chair",
|
||||||
|
description="Craft a chair",
|
||||||
|
ingredients=["plank"],
|
||||||
|
result="chair",
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_recipes(player, "")
|
||||||
|
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "wooden_table" in output.lower()
|
||||||
|
assert "wooden_chair" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipes_detail():
|
||||||
|
"""Shows specific recipe details."""
|
||||||
|
zone = _make_zone()
|
||||||
|
player = _make_player(zone=zone)
|
||||||
|
|
||||||
|
# Register a recipe
|
||||||
|
recipes["wooden_table"] = Recipe(
|
||||||
|
name="wooden_table",
|
||||||
|
description="Craft a sturdy table",
|
||||||
|
ingredients=["plank", "plank", "nail", "nail"],
|
||||||
|
result="table",
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_recipes(player, "wooden_table")
|
||||||
|
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "wooden_table" in output.lower()
|
||||||
|
assert "plank" in output.lower()
|
||||||
|
assert "nail" in output.lower()
|
||||||
|
assert "table" in output.lower()
|
||||||
151
tests/test_command_describe.py
Normal file
151
tests/test_command_describe.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""Tests for the describe command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.describe import cmd_describe
|
||||||
|
from mudlib.housing import init_housing
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registries():
|
||||||
|
saved = dict(zone_registry)
|
||||||
|
zone_registry.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _init_housing(tmp_path):
|
||||||
|
from mudlib.store import create_account, init_db
|
||||||
|
|
||||||
|
init_housing(tmp_path / "player_zones")
|
||||||
|
init_db(tmp_path / "test.db")
|
||||||
|
# Create accounts for test players
|
||||||
|
for name in ["alice", "bob", "charlie"]:
|
||||||
|
create_account(name, "testpass")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_home_zone(player_name="alice"):
|
||||||
|
name = f"home:{player_name}"
|
||||||
|
terrain = []
|
||||||
|
for y in range(9):
|
||||||
|
row = []
|
||||||
|
for x in range(9):
|
||||||
|
if x == 0 or x == 8 or y == 0 or y == 8:
|
||||||
|
row.append("#")
|
||||||
|
else:
|
||||||
|
row.append(".")
|
||||||
|
terrain.append(row)
|
||||||
|
zone = Zone(
|
||||||
|
name=name,
|
||||||
|
description=f"{player_name}'s home",
|
||||||
|
width=9,
|
||||||
|
height=9,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=False,
|
||||||
|
impassable={"#", "^", "~"},
|
||||||
|
spawn_x=4,
|
||||||
|
spawn_y=4,
|
||||||
|
safe=True,
|
||||||
|
)
|
||||||
|
register_zone(name, zone)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(name="alice", zone=None, x=4, y=4):
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
p = Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
|
||||||
|
p.home_zone = f"home:{name}"
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_sets_description(_init_housing):
|
||||||
|
"""describe <text> sets zone.description."""
|
||||||
|
home = _make_home_zone("alice")
|
||||||
|
player = _make_player("alice", zone=home)
|
||||||
|
|
||||||
|
await cmd_describe(player, "a cozy cottage by the sea")
|
||||||
|
|
||||||
|
assert home.description == "a cozy cottage by the sea"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_not_in_home_zone(_init_housing):
|
||||||
|
"""describe fails if not in player's own home zone."""
|
||||||
|
_make_home_zone("alice")
|
||||||
|
other_zone = Zone(
|
||||||
|
name="overworld",
|
||||||
|
description="The world",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
toroidal=True,
|
||||||
|
)
|
||||||
|
register_zone("overworld", other_zone)
|
||||||
|
player = _make_player("alice", zone=other_zone)
|
||||||
|
|
||||||
|
await cmd_describe(player, "trying to describe the overworld")
|
||||||
|
|
||||||
|
# Should show error message
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
||||||
|
assert "only" in output.lower() and "home" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_no_args_shows_current(_init_housing):
|
||||||
|
"""describe with no args shows current description."""
|
||||||
|
home = _make_home_zone("alice")
|
||||||
|
home.description = "a warm and welcoming place"
|
||||||
|
player = _make_player("alice", zone=home)
|
||||||
|
|
||||||
|
await cmd_describe(player, "")
|
||||||
|
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
||||||
|
assert "a warm and welcoming place" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_saves_zone(_init_housing):
|
||||||
|
"""describe saves the zone to disk."""
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from mudlib.housing import _zone_path
|
||||||
|
|
||||||
|
home = _make_home_zone("alice")
|
||||||
|
player = _make_player("alice", zone=home)
|
||||||
|
|
||||||
|
await cmd_describe(player, "a newly described home")
|
||||||
|
|
||||||
|
# Check TOML file was saved
|
||||||
|
zone_path = _zone_path("alice")
|
||||||
|
assert zone_path.exists()
|
||||||
|
|
||||||
|
with open(zone_path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
assert data["description"] == "a newly described home"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_describe_multiword(_init_housing):
|
||||||
|
"""describe handles multiword descriptions."""
|
||||||
|
home = _make_home_zone("bob")
|
||||||
|
player = _make_player("bob", zone=home)
|
||||||
|
|
||||||
|
await cmd_describe(player, "a cozy cottage with warm lighting")
|
||||||
|
|
||||||
|
assert home.description == "a cozy cottage with warm lighting"
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
||||||
|
assert "description" in output.lower() or "set" in output.lower()
|
||||||
162
tests/test_command_home.py
Normal file
162
tests/test_command_home.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"""Tests for the home command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.home import cmd_home
|
||||||
|
from mudlib.housing import init_housing
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import get_zone, register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_zone_registry():
|
||||||
|
saved = dict(zone_registry)
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _init_housing(tmp_path):
|
||||||
|
from mudlib.store import create_account, init_db
|
||||||
|
|
||||||
|
init_housing(tmp_path / "player_zones")
|
||||||
|
init_db(tmp_path / "test.db")
|
||||||
|
# Create accounts for test players
|
||||||
|
for name in ["alice", "bob", "charlie", "diane", "eve", "frank", "grace"]:
|
||||||
|
create_account(name, "testpass")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_zone(name="overworld", width=20, height=20):
|
||||||
|
terrain = [["." for _ in range(width)] for _ in range(height)]
|
||||||
|
zone = Zone(
|
||||||
|
name=name,
|
||||||
|
description=name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=True,
|
||||||
|
)
|
||||||
|
register_zone(name, zone)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(name="tester", zone=None, x=5, y=5):
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
return Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_creates_zone(_init_housing):
|
||||||
|
"""Player with no home calls home and gets teleported to new zone."""
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("alice", zone=overworld)
|
||||||
|
|
||||||
|
# Initially no home zone exists
|
||||||
|
assert get_zone("home:alice") is None
|
||||||
|
|
||||||
|
await cmd_home(player, "")
|
||||||
|
|
||||||
|
# Now home zone exists and player is in it
|
||||||
|
home = get_zone("home:alice")
|
||||||
|
assert home is not None
|
||||||
|
assert player.location is home
|
||||||
|
assert player.home_zone == "home:alice"
|
||||||
|
assert player.return_location == ("overworld", 5, 5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_return(_init_housing):
|
||||||
|
"""Player goes home then home return, ends up back where they were."""
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("bob", zone=overworld, x=10, y=15)
|
||||||
|
|
||||||
|
# Go home
|
||||||
|
await cmd_home(player, "")
|
||||||
|
home = get_zone("home:bob")
|
||||||
|
assert player.location is home
|
||||||
|
|
||||||
|
# Return
|
||||||
|
await cmd_home(player, "return")
|
||||||
|
assert player.location is overworld
|
||||||
|
assert player.x == 10
|
||||||
|
assert player.y == 15
|
||||||
|
assert player.return_location is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_return_without_location(_init_housing):
|
||||||
|
"""home return with no saved location shows error."""
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("charlie", zone=overworld)
|
||||||
|
|
||||||
|
# No return location set
|
||||||
|
assert player.return_location is None
|
||||||
|
|
||||||
|
await cmd_home(player, "return")
|
||||||
|
|
||||||
|
# Should get error message
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
||||||
|
assert "nowhere" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_already_at_home(_init_housing):
|
||||||
|
"""Calling home while already at home doesn't overwrite return_location."""
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("diane", zone=overworld, x=7, y=8)
|
||||||
|
|
||||||
|
# Go home first time
|
||||||
|
await cmd_home(player, "")
|
||||||
|
assert player.return_location == ("overworld", 7, 8)
|
||||||
|
|
||||||
|
# Call home again while at home
|
||||||
|
await cmd_home(player, "")
|
||||||
|
|
||||||
|
# return_location should still point to overworld, not home
|
||||||
|
assert player.return_location == ("overworld", 7, 8)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_invalid_args(_init_housing):
|
||||||
|
"""home foo shows usage."""
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("eve", zone=overworld)
|
||||||
|
|
||||||
|
await cmd_home(player, "foo")
|
||||||
|
|
||||||
|
assert player.writer.write.called
|
||||||
|
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
||||||
|
assert "usage" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_home_departure_arrival_messages(_init_housing):
|
||||||
|
"""Check nearby messages are sent."""
|
||||||
|
from mudlib.player import players
|
||||||
|
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("frank", zone=overworld, x=10, y=10)
|
||||||
|
other = _make_player("grace", zone=overworld, x=10, y=10)
|
||||||
|
|
||||||
|
players["frank"] = player
|
||||||
|
players["grace"] = other
|
||||||
|
|
||||||
|
# Go home
|
||||||
|
await cmd_home(player, "")
|
||||||
|
|
||||||
|
# Other player should have seen departure message
|
||||||
|
assert other.writer.write.called
|
||||||
|
output = "".join(c[0][0] for c in other.writer.write.call_args_list)
|
||||||
|
assert "frank" in output.lower()
|
||||||
|
assert "vanishes" in output.lower()
|
||||||
|
|
||||||
|
players.clear()
|
||||||
|
|
@ -12,6 +12,15 @@ from mudlib.render.ansi import RESET
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_test_commands():
|
||||||
|
"""Snapshot and restore command registry to prevent test leakage."""
|
||||||
|
snapshot = dict(commands._registry)
|
||||||
|
yield
|
||||||
|
commands._registry.clear()
|
||||||
|
commands._registry.update(snapshot)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_writer():
|
def mock_writer():
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
|
|
|
||||||
99
tests/test_crafting.py
Normal file
99
tests/test_crafting.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Tests for the crafting recipe system."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.crafting import Recipe, load_recipe, load_recipes, recipes
|
||||||
|
from mudlib.things import thing_templates
|
||||||
|
from mudlib.zones import zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registries():
|
||||||
|
"""Snapshot and restore registries to prevent test leakage."""
|
||||||
|
saved_zones = dict(zone_registry)
|
||||||
|
saved_templates = dict(thing_templates)
|
||||||
|
saved_recipes = dict(recipes)
|
||||||
|
zone_registry.clear()
|
||||||
|
thing_templates.clear()
|
||||||
|
recipes.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved_zones)
|
||||||
|
thing_templates.clear()
|
||||||
|
thing_templates.update(saved_templates)
|
||||||
|
recipes.clear()
|
||||||
|
recipes.update(saved_recipes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_recipe_from_toml():
|
||||||
|
"""Parse a recipe TOML file."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write("""
|
||||||
|
name = "wooden_table"
|
||||||
|
description = "Craft a sturdy table from planks and nails"
|
||||||
|
ingredients = ["plank", "plank", "plank", "nail", "nail"]
|
||||||
|
result = "table"
|
||||||
|
""")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe = load_recipe(path)
|
||||||
|
assert recipe.name == "wooden_table"
|
||||||
|
assert recipe.description == "Craft a sturdy table from planks and nails"
|
||||||
|
assert recipe.ingredients == ["plank", "plank", "plank", "nail", "nail"]
|
||||||
|
assert recipe.result == "table"
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_recipes_directory():
|
||||||
|
"""Load all recipes from a directory."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_path = Path(tmpdir)
|
||||||
|
|
||||||
|
# Create two recipe files
|
||||||
|
(tmp_path / "table.toml").write_text("""
|
||||||
|
name = "wooden_table"
|
||||||
|
description = "Craft a table"
|
||||||
|
ingredients = ["plank", "plank"]
|
||||||
|
result = "table"
|
||||||
|
""")
|
||||||
|
(tmp_path / "chair.toml").write_text("""
|
||||||
|
name = "wooden_chair"
|
||||||
|
description = "Craft a chair"
|
||||||
|
ingredients = ["plank"]
|
||||||
|
result = "chair"
|
||||||
|
""")
|
||||||
|
|
||||||
|
loaded = load_recipes(tmp_path)
|
||||||
|
assert len(loaded) == 2
|
||||||
|
assert "wooden_table" in loaded
|
||||||
|
assert "wooden_chair" in loaded
|
||||||
|
assert loaded["wooden_table"].result == "table"
|
||||||
|
assert loaded["wooden_chair"].result == "chair"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipe_fields():
|
||||||
|
"""Verify all recipe fields are populated correctly."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write("""
|
||||||
|
name = "test_recipe"
|
||||||
|
description = "A test recipe"
|
||||||
|
ingredients = ["item_a", "item_b", "item_c"]
|
||||||
|
result = "item_result"
|
||||||
|
""")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe = load_recipe(path)
|
||||||
|
assert isinstance(recipe, Recipe)
|
||||||
|
assert recipe.name == "test_recipe"
|
||||||
|
assert recipe.description == "A test recipe"
|
||||||
|
assert recipe.ingredients == ["item_a", "item_b", "item_c"]
|
||||||
|
assert recipe.result == "item_result"
|
||||||
|
assert len(recipe.ingredients) == 3
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
96
tests/test_creation.py
Normal file
96
tests/test_creation.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Tests for character creation flow."""
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.creation import character_creation
|
||||||
|
|
||||||
|
|
||||||
|
async def make_io(inputs):
|
||||||
|
"""Create mock read/write functions."""
|
||||||
|
input_queue = deque(inputs)
|
||||||
|
output = []
|
||||||
|
|
||||||
|
async def read_func():
|
||||||
|
if input_queue:
|
||||||
|
return input_queue.popleft()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def write_func(msg):
|
||||||
|
output.append(msg)
|
||||||
|
|
||||||
|
return read_func, write_func, output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_character_creation_with_description():
|
||||||
|
"""Test providing a description returns it."""
|
||||||
|
read_func, write_func, output = await make_io(
|
||||||
|
["A wandering swordsman with a mysterious past."]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await character_creation("TestPlayer", read_func, write_func)
|
||||||
|
|
||||||
|
assert result["description"] == "A wandering swordsman with a mysterious past."
|
||||||
|
assert any("Character Creation" in msg for msg in output)
|
||||||
|
assert any("Description:" in msg for msg in output)
|
||||||
|
assert any("A wandering swordsman with a mysterious past." in msg for msg in output)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_character_creation_empty_input():
|
||||||
|
"""Test empty input returns empty string."""
|
||||||
|
read_func, write_func, output = await make_io([""])
|
||||||
|
|
||||||
|
result = await character_creation("TestPlayer", read_func, write_func)
|
||||||
|
|
||||||
|
assert result["description"] == ""
|
||||||
|
assert any("No description set" in msg for msg in output)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_character_creation_none_input():
|
||||||
|
"""Test None input (disconnect) returns empty string."""
|
||||||
|
read_func, write_func, output = await make_io([None])
|
||||||
|
|
||||||
|
result = await character_creation("TestPlayer", read_func, write_func)
|
||||||
|
|
||||||
|
assert result["description"] == ""
|
||||||
|
assert any("No description set" in msg for msg in output)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_character_creation_prompts():
|
||||||
|
"""Test the output includes the prompts."""
|
||||||
|
read_func, write_func, output = await make_io(["Test description"])
|
||||||
|
|
||||||
|
await character_creation("TestPlayer", read_func, write_func)
|
||||||
|
|
||||||
|
output_text = "".join(output)
|
||||||
|
assert "Character Creation" in output_text
|
||||||
|
assert "Describe yourself" in output_text
|
||||||
|
assert "This is what others see when they look at you" in output_text
|
||||||
|
assert "Description:" in output_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_character_creation_confirmation():
|
||||||
|
"""Test the confirmation message includes the description."""
|
||||||
|
read_func, write_func, output = await make_io(["A fierce warrior"])
|
||||||
|
|
||||||
|
await character_creation("TestPlayer", read_func, write_func)
|
||||||
|
|
||||||
|
output_text = "".join(output)
|
||||||
|
assert "You will be known as:" in output_text
|
||||||
|
assert "A fierce warrior" in output_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_character_creation_strips_whitespace():
|
||||||
|
"""Test that leading/trailing whitespace is stripped."""
|
||||||
|
read_func, write_func, output = await make_io([" A nimble thief "])
|
||||||
|
|
||||||
|
result = await character_creation("TestPlayer", read_func, write_func)
|
||||||
|
|
||||||
|
assert result["description"] == "A nimble thief"
|
||||||
263
tests/test_furnish.py
Normal file
263
tests/test_furnish.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"""Tests for furnish and unfurnish commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.housing import init_housing
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registries():
|
||||||
|
"""Clear zone registry between tests."""
|
||||||
|
saved = dict(zone_registry)
|
||||||
|
zone_registry.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_zone(name="overworld", width=20, height=20):
|
||||||
|
"""Create a zone for testing."""
|
||||||
|
terrain = [["." for _ in range(width)] for _ in range(height)]
|
||||||
|
zone = Zone(
|
||||||
|
name=name,
|
||||||
|
description=name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=True,
|
||||||
|
)
|
||||||
|
register_zone(name, zone)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(name="tester", zone=None, x=5, y=5):
|
||||||
|
"""Create a player with mock writer."""
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
return Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_furnish_places_item(tmp_path):
|
||||||
|
"""furnish moves an item from inventory to the zone at player position."""
|
||||||
|
from mudlib.commands.furnish import cmd_furnish
|
||||||
|
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create home zone and player
|
||||||
|
home_zone = _make_zone("home:alice", width=9, height=9)
|
||||||
|
player = _make_player("alice", zone=home_zone, x=4, y=4)
|
||||||
|
player.home_zone = "home:alice"
|
||||||
|
|
||||||
|
# Give player a chair
|
||||||
|
chair = Thing(name="chair", description="A wooden chair")
|
||||||
|
chair.move_to(player)
|
||||||
|
|
||||||
|
# Furnish the chair
|
||||||
|
await cmd_furnish(player, "chair")
|
||||||
|
|
||||||
|
# Chair should be in the zone at player position
|
||||||
|
assert chair.location is home_zone
|
||||||
|
assert chair.x == 4
|
||||||
|
assert chair.y == 4
|
||||||
|
assert chair not in player.contents
|
||||||
|
|
||||||
|
# Player should get feedback
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "chair" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_furnish_not_in_home_zone():
|
||||||
|
"""furnish fails if player is not in their home zone."""
|
||||||
|
from mudlib.commands.furnish import cmd_furnish
|
||||||
|
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("alice", zone=overworld)
|
||||||
|
player.home_zone = "home:alice"
|
||||||
|
|
||||||
|
chair = Thing(name="chair")
|
||||||
|
chair.move_to(player)
|
||||||
|
|
||||||
|
await cmd_furnish(player, "chair")
|
||||||
|
|
||||||
|
# Chair should still be in inventory
|
||||||
|
assert chair.location is player
|
||||||
|
assert chair in player.contents
|
||||||
|
|
||||||
|
# Player should get error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "home zone" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_furnish_item_not_in_inventory():
|
||||||
|
"""furnish fails if item is not in player inventory."""
|
||||||
|
from mudlib.commands.furnish import cmd_furnish
|
||||||
|
|
||||||
|
home_zone = _make_zone("home:alice", width=9, height=9)
|
||||||
|
player = _make_player("alice", zone=home_zone)
|
||||||
|
player.home_zone = "home:alice"
|
||||||
|
|
||||||
|
await cmd_furnish(player, "chair")
|
||||||
|
|
||||||
|
# Player should get error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "don't have" in output.lower() or "not carrying" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_furnish_no_args():
|
||||||
|
"""furnish fails with usage message if no args provided."""
|
||||||
|
from mudlib.commands.furnish import cmd_furnish
|
||||||
|
|
||||||
|
home_zone = _make_zone("home:alice", width=9, height=9)
|
||||||
|
player = _make_player("alice", zone=home_zone)
|
||||||
|
player.home_zone = "home:alice"
|
||||||
|
|
||||||
|
await cmd_furnish(player, "")
|
||||||
|
|
||||||
|
# Player should get usage message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "usage" in output.lower() or "furnish" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unfurnish_picks_up_item(tmp_path):
|
||||||
|
"""unfurnish moves furniture from zone to player inventory."""
|
||||||
|
from mudlib.commands.furnish import cmd_unfurnish
|
||||||
|
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create home zone with furniture
|
||||||
|
home_zone = _make_zone("home:bob", width=9, height=9)
|
||||||
|
player = _make_player("bob", zone=home_zone, x=4, y=4)
|
||||||
|
player.home_zone = "home:bob"
|
||||||
|
|
||||||
|
# Place a table at player position
|
||||||
|
table = Thing(name="table", description="A wooden table")
|
||||||
|
table.move_to(home_zone, x=4, y=4)
|
||||||
|
|
||||||
|
# Unfurnish the table
|
||||||
|
await cmd_unfurnish(player, "table")
|
||||||
|
|
||||||
|
# Table should be in inventory
|
||||||
|
assert table.location is player
|
||||||
|
assert table in player.contents
|
||||||
|
assert table not in home_zone._contents
|
||||||
|
|
||||||
|
# Player should get feedback
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "table" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unfurnish_not_in_home_zone():
|
||||||
|
"""unfurnish fails if player is not in their home zone."""
|
||||||
|
from mudlib.commands.furnish import cmd_unfurnish
|
||||||
|
|
||||||
|
overworld = _make_zone("overworld")
|
||||||
|
player = _make_player("bob", zone=overworld)
|
||||||
|
player.home_zone = "home:bob"
|
||||||
|
|
||||||
|
# Place a table
|
||||||
|
table = Thing(name="table")
|
||||||
|
table.move_to(overworld, x=5, y=5)
|
||||||
|
|
||||||
|
await cmd_unfurnish(player, "table")
|
||||||
|
|
||||||
|
# Table should still be on ground
|
||||||
|
assert table.location is overworld
|
||||||
|
assert table not in player.contents
|
||||||
|
|
||||||
|
# Player should get error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "home zone" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unfurnish_nothing_at_position():
|
||||||
|
"""unfurnish fails if no matching furniture at player position."""
|
||||||
|
from mudlib.commands.furnish import cmd_unfurnish
|
||||||
|
|
||||||
|
home_zone = _make_zone("home:bob", width=9, height=9)
|
||||||
|
player = _make_player("bob", zone=home_zone, x=4, y=4)
|
||||||
|
player.home_zone = "home:bob"
|
||||||
|
|
||||||
|
# No furniture at position
|
||||||
|
await cmd_unfurnish(player, "table")
|
||||||
|
|
||||||
|
# Player should get error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert (
|
||||||
|
"no" in output.lower()
|
||||||
|
or "don't see" in output.lower()
|
||||||
|
or "can't find" in output.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unfurnish_no_args():
|
||||||
|
"""unfurnish fails with usage message if no args provided."""
|
||||||
|
from mudlib.commands.furnish import cmd_unfurnish
|
||||||
|
|
||||||
|
home_zone = _make_zone("home:bob", width=9, height=9)
|
||||||
|
player = _make_player("bob", zone=home_zone)
|
||||||
|
player.home_zone = "home:bob"
|
||||||
|
|
||||||
|
await cmd_unfurnish(player, "")
|
||||||
|
|
||||||
|
# Player should get usage message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
output = "".join(call.args[0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "usage" in output.lower() or "unfurnish" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_furnish_saves_zone(tmp_path):
|
||||||
|
"""furnish persists furniture to the zone TOML file."""
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from mudlib.commands.furnish import cmd_furnish
|
||||||
|
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create home zone and player
|
||||||
|
home_zone = _make_zone("home:charlie", width=9, height=9)
|
||||||
|
player = _make_player("charlie", zone=home_zone, x=4, y=4)
|
||||||
|
player.home_zone = "home:charlie"
|
||||||
|
|
||||||
|
# Give player a lamp
|
||||||
|
lamp = Thing(name="lamp", description="A brass lamp")
|
||||||
|
lamp.move_to(player)
|
||||||
|
|
||||||
|
# Furnish it
|
||||||
|
await cmd_furnish(player, "lamp")
|
||||||
|
|
||||||
|
# Check TOML file
|
||||||
|
zone_file = tmp_path / "charlie.toml"
|
||||||
|
assert zone_file.exists()
|
||||||
|
|
||||||
|
with open(zone_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
# Should have furniture entry
|
||||||
|
furniture = data.get("furniture", [])
|
||||||
|
assert len(furniture) == 1
|
||||||
|
assert furniture[0]["template"] == "lamp"
|
||||||
|
assert furniture[0]["x"] == 4
|
||||||
|
assert furniture[0]["y"] == 4
|
||||||
271
tests/test_furniture.py
Normal file
271
tests/test_furniture.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""Tests for furniture persistence in home zones."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Entity
|
||||||
|
from mudlib.housing import (
|
||||||
|
create_home_zone,
|
||||||
|
init_housing,
|
||||||
|
load_home_zone,
|
||||||
|
save_home_zone,
|
||||||
|
)
|
||||||
|
from mudlib.portal import Portal
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.things import ThingTemplate, spawn_thing, thing_templates
|
||||||
|
from mudlib.zones import zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registries():
|
||||||
|
"""Clear registries between tests."""
|
||||||
|
saved_zones = dict(zone_registry)
|
||||||
|
saved_templates = dict(thing_templates)
|
||||||
|
zone_registry.clear()
|
||||||
|
thing_templates.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved_zones)
|
||||||
|
thing_templates.clear()
|
||||||
|
thing_templates.update(saved_templates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_furniture_in_zone_toml(tmp_path):
|
||||||
|
"""save_home_zone() writes furniture to TOML."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create zone
|
||||||
|
zone = create_home_zone("Alice")
|
||||||
|
|
||||||
|
# Add a thing template
|
||||||
|
table_template = ThingTemplate(
|
||||||
|
name="table",
|
||||||
|
description="A wooden table",
|
||||||
|
portable=False,
|
||||||
|
)
|
||||||
|
thing_templates["table"] = table_template
|
||||||
|
|
||||||
|
# Spawn furniture in the zone
|
||||||
|
spawn_thing(table_template, zone, x=3, y=4)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
save_home_zone("Alice", zone)
|
||||||
|
|
||||||
|
# Read the TOML file
|
||||||
|
zone_file = tmp_path / "alice.toml"
|
||||||
|
with open(zone_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
# Check furniture section
|
||||||
|
assert "furniture" in data
|
||||||
|
assert len(data["furniture"]) == 1
|
||||||
|
assert data["furniture"][0]["template"] == "table"
|
||||||
|
assert data["furniture"][0]["x"] == 3
|
||||||
|
assert data["furniture"][0]["y"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_furniture_from_toml(tmp_path):
|
||||||
|
"""load_home_zone() spawns furniture from TOML."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create zone to get the file
|
||||||
|
_ = create_home_zone("Bob")
|
||||||
|
zone_file = tmp_path / "bob.toml"
|
||||||
|
|
||||||
|
# Add furniture entries to the TOML
|
||||||
|
with open(zone_file) as f:
|
||||||
|
content = f.read()
|
||||||
|
content += """
|
||||||
|
[[furniture]]
|
||||||
|
template = "chair"
|
||||||
|
x = 5
|
||||||
|
y = 6
|
||||||
|
"""
|
||||||
|
with open(zone_file, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Add template
|
||||||
|
chair_template = ThingTemplate(
|
||||||
|
name="chair",
|
||||||
|
description="A wooden chair",
|
||||||
|
portable=True,
|
||||||
|
)
|
||||||
|
thing_templates["chair"] = chair_template
|
||||||
|
|
||||||
|
# Clear registry and load
|
||||||
|
zone_registry.clear()
|
||||||
|
loaded = load_home_zone("Bob")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
|
||||||
|
# Check that furniture was spawned
|
||||||
|
chairs = [
|
||||||
|
obj
|
||||||
|
for obj in loaded._contents
|
||||||
|
if isinstance(obj, Thing) and obj.name == "chair"
|
||||||
|
]
|
||||||
|
assert len(chairs) == 1
|
||||||
|
assert chairs[0].x == 5
|
||||||
|
assert chairs[0].y == 6
|
||||||
|
assert chairs[0].description == "A wooden chair"
|
||||||
|
|
||||||
|
|
||||||
|
def test_furniture_round_trip(tmp_path):
|
||||||
|
"""Furniture survives save -> load cycle."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create zone
|
||||||
|
zone = create_home_zone("Charlie")
|
||||||
|
|
||||||
|
# Add templates
|
||||||
|
table_template = ThingTemplate(name="table", description="A table", portable=False)
|
||||||
|
chair_template = ThingTemplate(name="chair", description="A chair", portable=True)
|
||||||
|
thing_templates["table"] = table_template
|
||||||
|
thing_templates["chair"] = chair_template
|
||||||
|
|
||||||
|
# Spawn furniture
|
||||||
|
spawn_thing(table_template, zone, x=3, y=4)
|
||||||
|
spawn_thing(chair_template, zone, x=3, y=5)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
save_home_zone("Charlie", zone)
|
||||||
|
|
||||||
|
# Clear registry and load
|
||||||
|
zone_registry.clear()
|
||||||
|
loaded = load_home_zone("Charlie")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
|
||||||
|
# Check furniture
|
||||||
|
tables = [obj for obj in loaded._contents if obj.name == "table"]
|
||||||
|
chairs = [obj for obj in loaded._contents if obj.name == "chair"]
|
||||||
|
|
||||||
|
assert len(tables) == 1
|
||||||
|
assert tables[0].x == 3
|
||||||
|
assert tables[0].y == 4
|
||||||
|
|
||||||
|
assert len(chairs) == 1
|
||||||
|
assert chairs[0].x == 3
|
||||||
|
assert chairs[0].y == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_furniture_items(tmp_path):
|
||||||
|
"""Multiple furniture items save and load correctly."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = create_home_zone("Dave")
|
||||||
|
|
||||||
|
# Add templates
|
||||||
|
chair_template = ThingTemplate(name="chair", description="A chair", portable=True)
|
||||||
|
thing_templates["chair"] = chair_template
|
||||||
|
|
||||||
|
# Spawn multiple chairs
|
||||||
|
spawn_thing(chair_template, zone, x=2, y=2)
|
||||||
|
spawn_thing(chair_template, zone, x=3, y=2)
|
||||||
|
spawn_thing(chair_template, zone, x=4, y=2)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
save_home_zone("Dave", zone)
|
||||||
|
|
||||||
|
# Load
|
||||||
|
zone_registry.clear()
|
||||||
|
loaded = load_home_zone("Dave")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
|
||||||
|
chairs = [obj for obj in loaded._contents if obj.name == "chair"]
|
||||||
|
assert len(chairs) == 3
|
||||||
|
|
||||||
|
# Check positions
|
||||||
|
positions = {(c.x, c.y) for c in chairs}
|
||||||
|
assert positions == {(2, 2), (3, 2), (4, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_unknown_template_skips(tmp_path, caplog):
|
||||||
|
"""Unknown template name in TOML is skipped with warning."""
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create zone
|
||||||
|
_ = create_home_zone("Eve")
|
||||||
|
zone_file = tmp_path / "eve.toml"
|
||||||
|
|
||||||
|
# Add furniture with unknown template
|
||||||
|
with open(zone_file) as f:
|
||||||
|
content = f.read()
|
||||||
|
content += """
|
||||||
|
[[furniture]]
|
||||||
|
template = "unknown_thing"
|
||||||
|
x = 1
|
||||||
|
y = 1
|
||||||
|
"""
|
||||||
|
with open(zone_file, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Load
|
||||||
|
zone_registry.clear()
|
||||||
|
loaded = load_home_zone("Eve")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
|
||||||
|
# Check that no furniture was spawned
|
||||||
|
things = [obj for obj in loaded._contents if isinstance(obj, Thing)]
|
||||||
|
assert len(things) == 0
|
||||||
|
|
||||||
|
# Check that warning was logged
|
||||||
|
assert "unknown_thing" in caplog.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_excludes_entities(tmp_path):
|
||||||
|
"""Entities in zone are NOT saved as furniture."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = create_home_zone("Frank")
|
||||||
|
|
||||||
|
# Add an entity to the zone
|
||||||
|
_ = Entity(name="test_mob", location=zone, x=5, y=5)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
save_home_zone("Frank", zone)
|
||||||
|
|
||||||
|
# Read the TOML
|
||||||
|
zone_file = tmp_path / "frank.toml"
|
||||||
|
with open(zone_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
# Furniture section should not exist or be empty
|
||||||
|
furniture = data.get("furniture", [])
|
||||||
|
assert len(furniture) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_excludes_portals(tmp_path):
|
||||||
|
"""Portals are NOT saved as furniture."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = create_home_zone("Grace")
|
||||||
|
|
||||||
|
# Add a portal to the zone
|
||||||
|
_ = Portal(
|
||||||
|
name="exit",
|
||||||
|
description="An exit",
|
||||||
|
location=zone,
|
||||||
|
x=1,
|
||||||
|
y=1,
|
||||||
|
target_zone="overworld",
|
||||||
|
target_x=10,
|
||||||
|
target_y=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
save_home_zone("Grace", zone)
|
||||||
|
|
||||||
|
# Read the TOML
|
||||||
|
zone_file = tmp_path / "grace.toml"
|
||||||
|
with open(zone_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
# Furniture section should not exist or be empty
|
||||||
|
furniture = data.get("furniture", [])
|
||||||
|
assert len(furniture) == 0
|
||||||
89
tests/test_gametime.py
Normal file
89
tests/test_gametime.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Tests for the game time system."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.gametime import GameTime, get_game_day, get_game_hour, init_game_time
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_globals():
|
||||||
|
yield
|
||||||
|
import mudlib.gametime
|
||||||
|
|
||||||
|
mudlib.gametime._game_time = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_hour_at_epoch():
|
||||||
|
"""Game hour should be 0 at epoch."""
|
||||||
|
epoch = time.time()
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_hour() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_hour_after_one_hour():
|
||||||
|
"""Game hour should advance after one real minute."""
|
||||||
|
epoch = time.time() - 60 # one real minute ago
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_hour() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_hour_wraps_at_24():
|
||||||
|
"""Game hour should wrap from 23 back to 0."""
|
||||||
|
epoch = time.time() - (24 * 60) # 24 real minutes ago
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_hour() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_day_at_epoch():
|
||||||
|
"""Game day should be 0 at epoch."""
|
||||||
|
epoch = time.time()
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_day() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_day_after_24_hours():
|
||||||
|
"""Game day should be 1 after 24 game hours."""
|
||||||
|
epoch = time.time() - (24 * 60) # 24 real minutes ago = 24 game hours
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_day() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_day_after_48_hours():
|
||||||
|
"""Game day should be 2 after 48 game hours."""
|
||||||
|
epoch = time.time() - (48 * 60) # 48 real minutes ago
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_day() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_day_partial_day():
|
||||||
|
"""Game day should not increment until 24 hours have passed."""
|
||||||
|
epoch = time.time() - (12 * 60) # 12 real minutes ago = 12 game hours
|
||||||
|
gt = GameTime(epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
assert gt.get_game_day() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_get_game_hour():
|
||||||
|
"""Global get_game_hour should work after init."""
|
||||||
|
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
|
||||||
|
hour = get_game_hour()
|
||||||
|
assert 0 <= hour <= 23
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_get_game_day():
|
||||||
|
"""Global get_game_day should work after init."""
|
||||||
|
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
|
||||||
|
day = get_game_day()
|
||||||
|
assert day >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_get_game_day_raises_if_not_initialized():
|
||||||
|
"""Global get_game_day should raise if not initialized."""
|
||||||
|
import mudlib.gametime
|
||||||
|
|
||||||
|
# reset global state
|
||||||
|
mudlib.gametime._game_time = None
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Game time not initialized"):
|
||||||
|
get_game_day()
|
||||||
268
tests/test_housing.py
Normal file
268
tests/test_housing.py
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"""Tests for player housing system."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.housing import (
|
||||||
|
HOME_HEIGHT,
|
||||||
|
HOME_SPAWN_X,
|
||||||
|
HOME_SPAWN_Y,
|
||||||
|
HOME_WIDTH,
|
||||||
|
_home_zone_name,
|
||||||
|
create_home_zone,
|
||||||
|
get_or_create_home,
|
||||||
|
init_housing,
|
||||||
|
load_home_zone,
|
||||||
|
save_home_zone,
|
||||||
|
)
|
||||||
|
from mudlib.zones import get_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_zone_registry():
|
||||||
|
"""Clear zone registry between tests."""
|
||||||
|
saved = dict(zone_registry)
|
||||||
|
zone_registry.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_housing_creates_directory(tmp_path):
|
||||||
|
"""init_housing() creates the zones directory."""
|
||||||
|
zones_dir = tmp_path / "zones"
|
||||||
|
assert not zones_dir.exists()
|
||||||
|
|
||||||
|
init_housing(zones_dir)
|
||||||
|
|
||||||
|
assert zones_dir.exists()
|
||||||
|
assert zones_dir.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_zone_name():
|
||||||
|
"""_home_zone_name() returns correct format."""
|
||||||
|
assert _home_zone_name("Alice") == "home:alice"
|
||||||
|
assert _home_zone_name("bob") == "home:bob"
|
||||||
|
assert _home_zone_name("Charlie") == "home:charlie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_home_zone(tmp_path):
|
||||||
|
"""create_home_zone() creates a zone with correct properties."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = create_home_zone("Alice")
|
||||||
|
|
||||||
|
# Check basic properties
|
||||||
|
assert zone.name == "home:alice"
|
||||||
|
assert zone.description == "Alice's home"
|
||||||
|
assert zone.width == HOME_WIDTH
|
||||||
|
assert zone.height == HOME_HEIGHT
|
||||||
|
assert zone.toroidal is False
|
||||||
|
assert zone.spawn_x == HOME_SPAWN_X
|
||||||
|
assert zone.spawn_y == HOME_SPAWN_Y
|
||||||
|
assert zone.safe is True
|
||||||
|
assert zone.impassable == {"#", "^", "~"}
|
||||||
|
|
||||||
|
# Check terrain dimensions
|
||||||
|
assert len(zone.terrain) == HOME_HEIGHT
|
||||||
|
assert all(len(row) == HOME_WIDTH for row in zone.terrain)
|
||||||
|
|
||||||
|
# Check border is walls
|
||||||
|
for x in range(HOME_WIDTH):
|
||||||
|
assert zone.terrain[0][x] == "#" # top
|
||||||
|
assert zone.terrain[HOME_HEIGHT - 1][x] == "#" # bottom
|
||||||
|
for y in range(HOME_HEIGHT):
|
||||||
|
assert zone.terrain[y][0] == "#" # left
|
||||||
|
assert zone.terrain[y][HOME_WIDTH - 1] == "#" # right
|
||||||
|
|
||||||
|
# Check interior is grass
|
||||||
|
for y in range(1, HOME_HEIGHT - 1):
|
||||||
|
for x in range(1, HOME_WIDTH - 1):
|
||||||
|
assert zone.terrain[y][x] == "."
|
||||||
|
|
||||||
|
# Check spawn point is passable
|
||||||
|
assert zone.terrain[HOME_SPAWN_Y][HOME_SPAWN_X] == "."
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_registers_zone(tmp_path):
|
||||||
|
"""create_home_zone() registers the zone."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = create_home_zone("Bob")
|
||||||
|
|
||||||
|
registered = get_zone("home:bob")
|
||||||
|
assert registered is zone
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_home_zone(tmp_path):
|
||||||
|
"""save_home_zone() writes a valid TOML file."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
create_home_zone("Charlie")
|
||||||
|
zone_file = tmp_path / "charlie.toml"
|
||||||
|
|
||||||
|
assert zone_file.exists()
|
||||||
|
|
||||||
|
# Verify TOML is valid and contains expected data
|
||||||
|
with open(zone_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
assert data["name"] == "home:charlie"
|
||||||
|
assert data["description"] == "Charlie's home"
|
||||||
|
assert data["width"] == HOME_WIDTH
|
||||||
|
assert data["height"] == HOME_HEIGHT
|
||||||
|
assert data["toroidal"] is False
|
||||||
|
assert data["spawn_x"] == HOME_SPAWN_X
|
||||||
|
assert data["spawn_y"] == HOME_SPAWN_Y
|
||||||
|
assert data["safe"] is True
|
||||||
|
|
||||||
|
# Check terrain
|
||||||
|
rows = data["terrain"]["rows"]
|
||||||
|
assert len(rows) == HOME_HEIGHT
|
||||||
|
assert all(len(row) == HOME_WIDTH for row in rows)
|
||||||
|
|
||||||
|
# Check impassable
|
||||||
|
assert set(data["terrain"]["impassable"]["tiles"]) == {"#", "^", "~"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_home_zone(tmp_path):
|
||||||
|
"""load_home_zone() reads a zone from disk."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
# Create and save a zone
|
||||||
|
_ = create_home_zone("Dave")
|
||||||
|
|
||||||
|
# Clear registry
|
||||||
|
zone_registry.clear()
|
||||||
|
|
||||||
|
# Load it back
|
||||||
|
loaded = load_home_zone("Dave")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.name == "home:dave"
|
||||||
|
assert loaded.description == "Dave's home"
|
||||||
|
assert loaded.width == HOME_WIDTH
|
||||||
|
assert loaded.height == HOME_HEIGHT
|
||||||
|
assert loaded.toroidal is False
|
||||||
|
assert loaded.spawn_x == HOME_SPAWN_X
|
||||||
|
assert loaded.spawn_y == HOME_SPAWN_Y
|
||||||
|
assert loaded.safe is True
|
||||||
|
assert loaded.impassable == {"#", "^", "~"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_registers_zone(tmp_path):
|
||||||
|
"""load_home_zone() registers the zone."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
create_home_zone("Eve")
|
||||||
|
zone_registry.clear()
|
||||||
|
|
||||||
|
loaded = load_home_zone("Eve")
|
||||||
|
|
||||||
|
registered = get_zone("home:eve")
|
||||||
|
assert registered is loaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_nonexistent_returns_none(tmp_path):
|
||||||
|
"""load_home_zone() returns None if file doesn't exist."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
result = load_home_zone("Nobody")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_trip(tmp_path):
|
||||||
|
"""Create -> save -> load produces equivalent zone."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
original = create_home_zone("Frank")
|
||||||
|
zone_registry.clear()
|
||||||
|
|
||||||
|
loaded = load_home_zone("Frank")
|
||||||
|
assert loaded is not None
|
||||||
|
|
||||||
|
# Compare all fields
|
||||||
|
assert loaded.name == original.name
|
||||||
|
assert loaded.description == original.description
|
||||||
|
assert loaded.width == original.width
|
||||||
|
assert loaded.height == original.height
|
||||||
|
assert loaded.toroidal == original.toroidal
|
||||||
|
assert loaded.spawn_x == original.spawn_x
|
||||||
|
assert loaded.spawn_y == original.spawn_y
|
||||||
|
assert loaded.safe == original.safe
|
||||||
|
assert loaded.impassable == original.impassable
|
||||||
|
|
||||||
|
# Compare terrain
|
||||||
|
assert len(loaded.terrain) == len(original.terrain)
|
||||||
|
for loaded_row, orig_row in zip(loaded.terrain, original.terrain, strict=True):
|
||||||
|
assert loaded_row == orig_row
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_home_creates_new(tmp_path):
|
||||||
|
"""get_or_create_home() creates a zone on first call."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = get_or_create_home("Grace")
|
||||||
|
|
||||||
|
assert zone.name == "home:grace"
|
||||||
|
assert zone.description == "Grace's home"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_home_returns_existing_from_registry(tmp_path):
|
||||||
|
"""get_or_create_home() returns existing zone from registry."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
first = get_or_create_home("Hank")
|
||||||
|
second = get_or_create_home("Hank")
|
||||||
|
|
||||||
|
assert second is first
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_home_loads_from_disk(tmp_path):
|
||||||
|
"""get_or_create_home() loads from disk if not in registry."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
create_home_zone("Iris")
|
||||||
|
zone_registry.clear()
|
||||||
|
|
||||||
|
loaded = get_or_create_home("Iris")
|
||||||
|
|
||||||
|
assert loaded.name == "home:iris"
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_insensitive_zone_names(tmp_path):
|
||||||
|
"""Zone names are lowercased consistently."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone1 = create_home_zone("JACK")
|
||||||
|
zone2 = create_home_zone("Jack")
|
||||||
|
zone3 = create_home_zone("jack")
|
||||||
|
|
||||||
|
# All should reference the same zone name
|
||||||
|
assert zone1.name == "home:jack"
|
||||||
|
assert zone2.name == "home:jack"
|
||||||
|
assert zone3.name == "home:jack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_preserves_modifications(tmp_path):
|
||||||
|
"""save_home_zone() preserves modifications to terrain."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
|
||||||
|
zone = create_home_zone("Kate")
|
||||||
|
|
||||||
|
# Modify terrain
|
||||||
|
zone.terrain[2][2] = "~" # add water
|
||||||
|
zone.terrain[3][3] = "^" # add mountain
|
||||||
|
|
||||||
|
# Save modifications
|
||||||
|
save_home_zone("Kate", zone)
|
||||||
|
|
||||||
|
# Load and verify
|
||||||
|
zone_registry.clear()
|
||||||
|
loaded = load_home_zone("Kate")
|
||||||
|
assert loaded is not None
|
||||||
|
|
||||||
|
assert loaded.terrain[2][2] == "~"
|
||||||
|
assert loaded.terrain[3][3] == "^"
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Tests for the look command with structured room display."""
|
"""Tests for the look command with structured room display."""
|
||||||
|
|
||||||
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -7,12 +8,24 @@ import pytest
|
||||||
from mudlib.commands import look # noqa: F401
|
from mudlib.commands import look # noqa: F401
|
||||||
from mudlib.commands.look import cmd_look
|
from mudlib.commands.look import cmd_look
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.gametime import init_game_time
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.portal import Portal
|
from mudlib.portal import Portal
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.weather import WeatherCondition, init_weather
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_globals():
|
||||||
|
yield
|
||||||
|
import mudlib.gametime
|
||||||
|
import mudlib.weather
|
||||||
|
|
||||||
|
mudlib.gametime._game_time = None
|
||||||
|
mudlib.weather._current_weather = None
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_writer():
|
def mock_writer():
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
|
|
@ -203,3 +216,204 @@ async def test_look_nowhere(mock_reader, mock_writer):
|
||||||
output = get_output(player)
|
output = get_output(player)
|
||||||
|
|
||||||
assert "You are nowhere." in output
|
assert "You are nowhere." in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_includes_atmosphere_clear():
|
||||||
|
"""Look should include atmosphere line with clear weather."""
|
||||||
|
# Initialize game time and weather
|
||||||
|
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
|
||||||
|
init_weather(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
|
||||||
|
# Create player
|
||||||
|
mock_reader = MagicMock()
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
||||||
|
zone = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
description="The Test Zone",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"^", "~"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
|
||||||
|
)
|
||||||
|
player.location = zone
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see atmosphere line with period and season
|
||||||
|
assert "[" in output and "]" in output
|
||||||
|
# Should not have weather description for clear weather
|
||||||
|
# (the format is: sky. [period, season])
|
||||||
|
seasons = ["spring]", "summer]", "autumn]", "winter]"]
|
||||||
|
assert any(season in output for season in seasons)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_includes_atmosphere_with_rain():
|
||||||
|
"""Look should include atmosphere line with rain."""
|
||||||
|
# Initialize game time and weather
|
||||||
|
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
|
||||||
|
init_weather(condition=WeatherCondition.rain, intensity=0.5)
|
||||||
|
|
||||||
|
# Create player
|
||||||
|
mock_reader = MagicMock()
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
||||||
|
zone = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
description="The Test Zone",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"^", "~"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
|
||||||
|
)
|
||||||
|
player.location = zone
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see atmosphere line with rain description
|
||||||
|
assert "rain" in output.lower()
|
||||||
|
# Should see season tag
|
||||||
|
assert "[" in output and "]" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_atmosphere_between_where_and_viewport():
|
||||||
|
"""Atmosphere line should appear between Where header and viewport."""
|
||||||
|
# Initialize game time and weather
|
||||||
|
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
|
||||||
|
init_weather(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
|
||||||
|
# Create player
|
||||||
|
mock_reader = MagicMock()
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
||||||
|
zone = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
description="The Test Zone",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"^", "~"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
|
||||||
|
)
|
||||||
|
player.location = zone
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Find positions of key sections
|
||||||
|
where_pos = output.find("Where:")
|
||||||
|
location_pos = output.find("Location:")
|
||||||
|
|
||||||
|
# Atmosphere should be between Where and Location
|
||||||
|
# Look for a bracket tag (season indicator)
|
||||||
|
bracket_pos = output.find("[")
|
||||||
|
|
||||||
|
assert where_pos < bracket_pos < location_pos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_night_reduces_viewport():
|
||||||
|
"""Look at night should show smaller viewport than day."""
|
||||||
|
|
||||||
|
# Set time to noon (hour 12) - epoch was 12 hours ago
|
||||||
|
# elapsed_game_hours = (time.time() - epoch) / 60 / 1.0 = 12
|
||||||
|
# so epoch = time.time() - 12 * 60
|
||||||
|
init_game_time(epoch=time.time() - 12 * 60, real_minutes_per_game_hour=1.0)
|
||||||
|
init_weather(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
|
||||||
|
mock_reader = MagicMock()
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
||||||
|
zone = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
description="The Test Zone",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"^", "~"},
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
|
||||||
|
)
|
||||||
|
player.location = zone
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
day_output = get_output(player)
|
||||||
|
|
||||||
|
# Find viewport section (between atmosphere and Location:)
|
||||||
|
day_lines = day_output.split("\r\n")
|
||||||
|
# Viewport is after atmosphere (containing '[') and before Location:
|
||||||
|
viewport_start_idx = None
|
||||||
|
viewport_end_idx = None
|
||||||
|
for i, line in enumerate(day_lines):
|
||||||
|
if "[" in line and viewport_start_idx is None:
|
||||||
|
viewport_start_idx = i + 1
|
||||||
|
if line.startswith("Location:"):
|
||||||
|
viewport_end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
assert viewport_start_idx is not None
|
||||||
|
assert viewport_end_idx is not None
|
||||||
|
day_viewport_height = viewport_end_idx - viewport_start_idx
|
||||||
|
|
||||||
|
# Reset writer
|
||||||
|
mock_writer.reset_mock()
|
||||||
|
|
||||||
|
# Set time to night (hour 22) - epoch was 22 hours ago
|
||||||
|
init_game_time(epoch=time.time() - 22 * 60, real_minutes_per_game_hour=1.0)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
night_output = get_output(player)
|
||||||
|
|
||||||
|
# Find viewport section
|
||||||
|
night_lines = night_output.split("\r\n")
|
||||||
|
viewport_start_idx = None
|
||||||
|
viewport_end_idx = None
|
||||||
|
for i, line in enumerate(night_lines):
|
||||||
|
if "[" in line and viewport_start_idx is None:
|
||||||
|
viewport_start_idx = i + 1
|
||||||
|
if line.startswith("Location:"):
|
||||||
|
viewport_end_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
assert viewport_start_idx is not None
|
||||||
|
assert viewport_end_idx is not None
|
||||||
|
night_viewport_height = viewport_end_idx - viewport_start_idx
|
||||||
|
|
||||||
|
# Night should have fewer viewport lines (9 vs 11)
|
||||||
|
assert night_viewport_height < day_viewport_height
|
||||||
|
assert day_viewport_height == 11 # full viewport at noon
|
||||||
|
assert night_viewport_height == 9 # reduced viewport at night
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ def clear_mobs():
|
||||||
mob_templates.clear()
|
mob_templates.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_globals():
|
||||||
|
yield
|
||||||
|
import mudlib.gametime
|
||||||
|
|
||||||
|
mudlib.gametime._game_time = None
|
||||||
|
|
||||||
|
|
||||||
def test_schedule_entry_creation():
|
def test_schedule_entry_creation():
|
||||||
"""ScheduleEntry can be created with required fields."""
|
"""ScheduleEntry can be created with required fields."""
|
||||||
entry = ScheduleEntry(hour=6, state="working")
|
entry = ScheduleEntry(hour=6, state="working")
|
||||||
|
|
|
||||||
94
tests/test_player_fields.py
Normal file
94
tests/test_player_fields.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Tests for player description and home_zone fields."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_default_description(mock_reader, mock_writer):
|
||||||
|
"""Test that Player has default empty description."""
|
||||||
|
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
assert player.description == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_default_home_zone(mock_reader, mock_writer):
|
||||||
|
"""Test that Player has default None home_zone."""
|
||||||
|
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
assert player.home_zone is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_default_return_location(mock_reader, mock_writer):
|
||||||
|
"""Test that Player has default None return_location."""
|
||||||
|
player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
assert player.return_location is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_custom_description(mock_reader, mock_writer):
|
||||||
|
"""Test that Player can have custom description."""
|
||||||
|
player = Player(
|
||||||
|
name="Hero",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
description="A brave adventurer",
|
||||||
|
)
|
||||||
|
assert player.description == "A brave adventurer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_custom_home_zone(mock_reader, mock_writer):
|
||||||
|
"""Test that Player can have custom home_zone."""
|
||||||
|
player = Player(
|
||||||
|
name="Hero",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
home_zone="residential",
|
||||||
|
)
|
||||||
|
assert player.home_zone == "residential"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_custom_return_location(mock_reader, mock_writer):
|
||||||
|
"""Test that Player can have custom return_location."""
|
||||||
|
player = Player(
|
||||||
|
name="Hero",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
return_location=("residential", 10, 20),
|
||||||
|
)
|
||||||
|
assert player.return_location == ("residential", 10, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_all_housing_fields(mock_reader, mock_writer):
|
||||||
|
"""Test that Player can have all housing fields set together."""
|
||||||
|
player = Player(
|
||||||
|
name="Hero",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
description="A homeowner",
|
||||||
|
home_zone="residential",
|
||||||
|
return_location=("residential", 5, 15),
|
||||||
|
)
|
||||||
|
assert player.description == "A homeowner"
|
||||||
|
assert player.home_zone == "residential"
|
||||||
|
assert player.return_location == ("residential", 5, 15)
|
||||||
145
tests/test_render_room.py
Normal file
145
tests/test_render_room.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Tests for room rendering functions."""
|
||||||
|
|
||||||
|
from mudlib.render.room import (
|
||||||
|
render_atmosphere,
|
||||||
|
render_entity_lines,
|
||||||
|
render_exits,
|
||||||
|
render_location,
|
||||||
|
render_nearby,
|
||||||
|
render_where,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockZone:
|
||||||
|
"""Mock zone for testing."""
|
||||||
|
|
||||||
|
def __init__(self, width=100, height=100, passable_tiles=None):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.description = "Test Zone"
|
||||||
|
self._passable = passable_tiles or set()
|
||||||
|
|
||||||
|
def is_passable(self, x, y):
|
||||||
|
return (x, y) in self._passable
|
||||||
|
|
||||||
|
|
||||||
|
class MockEntity:
|
||||||
|
"""Mock entity for testing."""
|
||||||
|
|
||||||
|
def __init__(self, name, posture="standing"):
|
||||||
|
self.name = name
|
||||||
|
self.posture = posture
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_where():
|
||||||
|
"""render_where should format zone description."""
|
||||||
|
assert render_where("The Overworld") == "Where: The Overworld"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_location_center():
|
||||||
|
"""render_location should show center quadrant."""
|
||||||
|
zone = MockZone(width=90, height=90)
|
||||||
|
assert render_location(zone, 45, 45) == "Location: center 45, 45"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_location_northeast():
|
||||||
|
"""render_location should show northeast quadrant."""
|
||||||
|
zone = MockZone(width=90, height=90)
|
||||||
|
assert render_location(zone, 70, 10) == "Location: northeast 70, 10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_location_southwest():
|
||||||
|
"""render_location should show southwest quadrant."""
|
||||||
|
zone = MockZone(width=90, height=90)
|
||||||
|
assert render_location(zone, 10, 70) == "Location: southwest 10, 70"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_nearby_empty():
|
||||||
|
"""render_nearby should return empty string when no entities."""
|
||||||
|
assert render_nearby([], None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_nearby_with_entities():
|
||||||
|
"""render_nearby should show count and names."""
|
||||||
|
entities = [MockEntity("Goku"), MockEntity("Vegeta")]
|
||||||
|
result = render_nearby(entities, None)
|
||||||
|
assert result == "Nearby: (2) Goku / Vegeta"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exits_all_directions():
|
||||||
|
"""render_exits should list all passable directions."""
|
||||||
|
zone = MockZone(passable_tiles={(5, 4), (5, 6), (6, 5), (4, 5)})
|
||||||
|
assert render_exits(zone, 5, 5) == "Exits: north south east west"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exits_partial():
|
||||||
|
"""render_exits should list only passable directions."""
|
||||||
|
zone = MockZone(passable_tiles={(5, 4), (6, 5)})
|
||||||
|
assert render_exits(zone, 5, 5) == "Exits: north east"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exits_none():
|
||||||
|
"""render_exits should show 'Exits:' with no directions if trapped."""
|
||||||
|
zone = MockZone(passable_tiles=set())
|
||||||
|
assert render_exits(zone, 5, 5) == "Exits:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_entity_lines_empty():
|
||||||
|
"""render_entity_lines should return empty string when no entities."""
|
||||||
|
assert render_entity_lines([], None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_entity_lines_with_postures():
|
||||||
|
"""render_entity_lines should show entity names with postures."""
|
||||||
|
entities = [
|
||||||
|
MockEntity("Krillin", "resting"),
|
||||||
|
MockEntity("Piccolo", "standing"),
|
||||||
|
]
|
||||||
|
result = render_entity_lines(entities, None)
|
||||||
|
assert "Krillin is resting here." in result
|
||||||
|
assert "Piccolo is standing here." in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_atmosphere_clear_weather():
|
||||||
|
"""render_atmosphere should omit weather desc when clear."""
|
||||||
|
result = render_atmosphere(12, "", "summer")
|
||||||
|
assert "[day, summer]" in result
|
||||||
|
# Should have sky description but no weather text
|
||||||
|
assert result.count(". [") == 1 # only one period before bracket
|
||||||
|
# Should not have double period before bracket
|
||||||
|
assert ". . [" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_atmosphere_with_rain():
|
||||||
|
"""render_atmosphere should include weather desc when not clear."""
|
||||||
|
result = render_atmosphere(12, "rain patters steadily", "spring")
|
||||||
|
assert "rain patters steadily" in result
|
||||||
|
assert "[day, spring]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_atmosphere_dawn():
|
||||||
|
"""render_atmosphere should show dawn period."""
|
||||||
|
result = render_atmosphere(5, "", "autumn")
|
||||||
|
assert "[dawn, autumn]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_atmosphere_dusk():
|
||||||
|
"""render_atmosphere should show dusk period."""
|
||||||
|
result = render_atmosphere(18, "", "winter")
|
||||||
|
assert "[dusk, winter]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_atmosphere_night():
|
||||||
|
"""render_atmosphere should show night period."""
|
||||||
|
result = render_atmosphere(22, "", "spring")
|
||||||
|
assert "[night, spring]" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_atmosphere_with_heavy_snow():
|
||||||
|
"""render_atmosphere should include heavy weather descriptions."""
|
||||||
|
result = render_atmosphere(22, "heavy snow blankets everything", "winter")
|
||||||
|
assert "heavy snow blankets everything" in result
|
||||||
|
assert "[night, winter]" in result
|
||||||
|
# Should have format: sky. weather. [period, season]
|
||||||
|
parts = result.split(". ")
|
||||||
|
assert len(parts) == 3 # sky, weather, [period, season]
|
||||||
87
tests/test_seasons.py
Normal file
87
tests/test_seasons.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Tests for the season system."""
|
||||||
|
|
||||||
|
from mudlib import seasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_basic():
|
||||||
|
assert seasons.get_season(0) == "spring"
|
||||||
|
assert seasons.get_season(6) == "spring"
|
||||||
|
assert seasons.get_season(7) == "summer"
|
||||||
|
assert seasons.get_season(13) == "summer"
|
||||||
|
assert seasons.get_season(14) == "autumn"
|
||||||
|
assert seasons.get_season(20) == "autumn"
|
||||||
|
assert seasons.get_season(21) == "winter"
|
||||||
|
assert seasons.get_season(27) == "winter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_wraps():
|
||||||
|
assert seasons.get_season(28) == "spring"
|
||||||
|
assert seasons.get_season(35) == "summer"
|
||||||
|
assert seasons.get_season(56) == "spring" # 2 years = 56 days
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_negative_day():
|
||||||
|
assert seasons.get_season(-1) == "spring"
|
||||||
|
assert seasons.get_season(-100) == "spring"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_custom_days_per_season():
|
||||||
|
# 10 days per season = 40-day year
|
||||||
|
assert seasons.get_season(0, days_per_season=10) == "spring"
|
||||||
|
assert seasons.get_season(9, days_per_season=10) == "spring"
|
||||||
|
assert seasons.get_season(10, days_per_season=10) == "summer"
|
||||||
|
assert seasons.get_season(19, days_per_season=10) == "summer"
|
||||||
|
assert seasons.get_season(20, days_per_season=10) == "autumn"
|
||||||
|
assert seasons.get_season(30, days_per_season=10) == "winter"
|
||||||
|
assert seasons.get_season(40, days_per_season=10) == "spring"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_day_of_year():
|
||||||
|
assert seasons.get_day_of_year(0) == 0
|
||||||
|
assert seasons.get_day_of_year(27) == 27
|
||||||
|
assert seasons.get_day_of_year(28) == 0 # new year
|
||||||
|
assert seasons.get_day_of_year(29) == 1
|
||||||
|
assert seasons.get_day_of_year(56) == 0 # 2 years
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_day_of_year_custom_days_per_season():
|
||||||
|
# 10 days per season = 40-day year
|
||||||
|
assert seasons.get_day_of_year(0, days_per_season=10) == 0
|
||||||
|
assert seasons.get_day_of_year(39, days_per_season=10) == 39
|
||||||
|
assert seasons.get_day_of_year(40, days_per_season=10) == 0
|
||||||
|
assert seasons.get_day_of_year(80, days_per_season=10) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_description_grass():
|
||||||
|
assert "green" in seasons.get_season_description("spring", "grass")
|
||||||
|
assert "golden" in seasons.get_season_description("summer", "grass")
|
||||||
|
assert "brown" in seasons.get_season_description("autumn", "grass")
|
||||||
|
assert "frost" in seasons.get_season_description("winter", "grass")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_description_forest():
|
||||||
|
assert "blossom" in seasons.get_season_description("spring", "forest")
|
||||||
|
assert "canopy" in seasons.get_season_description("summer", "forest")
|
||||||
|
assert "amber" in seasons.get_season_description("autumn", "forest")
|
||||||
|
assert "bare" in seasons.get_season_description("winter", "forest")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_description_minimal_variation():
|
||||||
|
# sand, mountain, water have minimal/no seasonal variation
|
||||||
|
assert seasons.get_season_description("spring", "sand") == ""
|
||||||
|
assert seasons.get_season_description("summer", "mountain") == ""
|
||||||
|
assert seasons.get_season_description("winter", "water") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_season_description_unknown_terrain():
|
||||||
|
# unknown terrain returns empty string
|
||||||
|
assert seasons.get_season_description("spring", "lava") == ""
|
||||||
|
assert seasons.get_season_description("summer", "unknown") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_seasons_constant():
|
||||||
|
assert seasons.SEASONS == ["spring", "summer", "autumn", "winter"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_per_season_constant():
|
||||||
|
assert seasons.DAYS_PER_SEASON == 7
|
||||||
|
|
@ -70,12 +70,13 @@ async def test_shell_greets_and_accepts_commands(temp_db):
|
||||||
|
|
||||||
readline = "mudlib.server.readline2"
|
readline = "mudlib.server.readline2"
|
||||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||||
# Simulate: name, create account (y), password, confirm password, look, quit
|
# Simulate: name, create (y), pass, confirm pass, description, look, quit
|
||||||
mock_readline.side_effect = [
|
mock_readline.side_effect = [
|
||||||
"TestPlayer",
|
"TestPlayer",
|
||||||
"y",
|
"y",
|
||||||
"password",
|
"password",
|
||||||
"password",
|
"password",
|
||||||
|
"A test character",
|
||||||
"look",
|
"look",
|
||||||
"quit",
|
"quit",
|
||||||
]
|
]
|
||||||
|
|
@ -125,12 +126,13 @@ async def test_shell_handles_quit(temp_db):
|
||||||
|
|
||||||
readline = "mudlib.server.readline2"
|
readline = "mudlib.server.readline2"
|
||||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||||
# Simulate: name, create account (y), password, confirm password, quit
|
# Simulate: name, create (y), pass, confirm pass, description, quit
|
||||||
mock_readline.side_effect = [
|
mock_readline.side_effect = [
|
||||||
"TestPlayer",
|
"TestPlayer",
|
||||||
"y",
|
"y",
|
||||||
"password",
|
"password",
|
||||||
"password",
|
"password",
|
||||||
|
"A test character",
|
||||||
"quit",
|
"quit",
|
||||||
]
|
]
|
||||||
await server.shell(reader, writer)
|
await server.shell(reader, writer)
|
||||||
|
|
|
||||||
206
tests/test_store_description.py
Normal file
206
tests/test_store_description.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Tests for player description and home_zone persistence."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.store import create_account, init_db, load_player_data, save_player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db():
|
||||||
|
"""Create a temporary database for testing."""
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
|
||||||
|
db_path = f.name
|
||||||
|
|
||||||
|
init_db(db_path)
|
||||||
|
|
||||||
|
yield db_path
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_creates_description_column(temp_db):
|
||||||
|
"""init_db creates description column."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(temp_db)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert "description" in columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_creates_home_zone_column(temp_db):
|
||||||
|
"""init_db creates home_zone column."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(temp_db)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert "home_zone" in columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_description(temp_db):
|
||||||
|
"""New accounts have description default to empty string."""
|
||||||
|
create_account("Alice", "password123")
|
||||||
|
data = load_player_data("Alice")
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert data["description"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_home_zone(temp_db):
|
||||||
|
"""New accounts have home_zone default to None."""
|
||||||
|
create_account("Bob", "password123")
|
||||||
|
data = load_player_data("Bob")
|
||||||
|
|
||||||
|
assert data is not None
|
||||||
|
assert data["home_zone"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_description(temp_db):
|
||||||
|
"""save_player and load_player_data persist description."""
|
||||||
|
create_account("Charlie", "password123")
|
||||||
|
|
||||||
|
player = Player(name="Charlie", x=0, y=0, description="A brave warrior")
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
data = load_player_data("Charlie")
|
||||||
|
assert data is not None
|
||||||
|
assert data["description"] == "A brave warrior"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_home_zone(temp_db):
|
||||||
|
"""save_player and load_player_data persist home_zone."""
|
||||||
|
create_account("Diana", "password123")
|
||||||
|
|
||||||
|
player = Player(name="Diana", x=0, y=0, home_zone="residential")
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
data = load_player_data("Diana")
|
||||||
|
assert data is not None
|
||||||
|
assert data["home_zone"] == "residential"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_load_both_fields(temp_db):
|
||||||
|
"""save_player and load_player_data persist both description and home_zone."""
|
||||||
|
create_account("Eve", "password123")
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="Eve",
|
||||||
|
x=10,
|
||||||
|
y=20,
|
||||||
|
description="A skilled mage",
|
||||||
|
home_zone="wizard_tower",
|
||||||
|
)
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
data = load_player_data("Eve")
|
||||||
|
assert data is not None
|
||||||
|
assert data["description"] == "A skilled mage"
|
||||||
|
assert data["home_zone"] == "wizard_tower"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_description(temp_db):
|
||||||
|
"""save_player updates existing description."""
|
||||||
|
create_account("Frank", "password123")
|
||||||
|
|
||||||
|
player = Player(name="Frank", x=0, y=0, description="A novice")
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
player.description = "An experienced adventurer"
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
data = load_player_data("Frank")
|
||||||
|
assert data is not None
|
||||||
|
assert data["description"] == "An experienced adventurer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_home_zone(temp_db):
|
||||||
|
"""save_player updates existing home_zone."""
|
||||||
|
create_account("Grace", "password123")
|
||||||
|
|
||||||
|
player = Player(name="Grace", x=0, y=0, home_zone=None)
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
player.home_zone = "residential"
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
data = load_player_data("Grace")
|
||||||
|
assert data is not None
|
||||||
|
assert data["home_zone"] == "residential"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_home_zone(temp_db):
|
||||||
|
"""save_player can clear home_zone back to None."""
|
||||||
|
create_account("Henry", "password123")
|
||||||
|
|
||||||
|
player = Player(name="Henry", x=0, y=0, home_zone="residential")
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
player.home_zone = None
|
||||||
|
save_player(player)
|
||||||
|
|
||||||
|
data = load_player_data("Henry")
|
||||||
|
assert data is not None
|
||||||
|
assert data["home_zone"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_from_old_schema(temp_db):
|
||||||
|
"""Loading from DB without description/home_zone columns works."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
# Create account first
|
||||||
|
create_account("Iris", "password123")
|
||||||
|
|
||||||
|
# Simulate old DB by removing the new columns
|
||||||
|
conn = sqlite3.connect(temp_db)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Backup and recreate without new columns
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE accounts_backup AS
|
||||||
|
SELECT name, password_hash, salt, x, y, pl, stamina,
|
||||||
|
max_stamina, flying, zone_name, inventory, created_at, last_login
|
||||||
|
FROM accounts
|
||||||
|
""")
|
||||||
|
cursor.execute("DROP TABLE accounts")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
name TEXT PRIMARY KEY COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
x INTEGER NOT NULL DEFAULT 0,
|
||||||
|
y INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pl REAL NOT NULL DEFAULT 100.0,
|
||||||
|
stamina REAL NOT NULL DEFAULT 100.0,
|
||||||
|
max_stamina REAL NOT NULL DEFAULT 100.0,
|
||||||
|
flying INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zone_name TEXT NOT NULL DEFAULT 'overworld',
|
||||||
|
inventory TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_login TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO accounts
|
||||||
|
SELECT * FROM accounts_backup
|
||||||
|
""")
|
||||||
|
cursor.execute("DROP TABLE accounts_backup")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Should still load with default values
|
||||||
|
data = load_player_data("Iris")
|
||||||
|
assert data is not None
|
||||||
|
assert data["description"] == ""
|
||||||
|
assert data["home_zone"] is None
|
||||||
209
tests/test_terrain_edit.py
Normal file
209
tests/test_terrain_edit.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
"""Tests for terrain editing command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.terrain import cmd_terrain
|
||||||
|
from mudlib.housing import init_housing
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_registries():
|
||||||
|
"""Clear zone registry between tests."""
|
||||||
|
saved = dict(zone_registry)
|
||||||
|
zone_registry.clear()
|
||||||
|
yield
|
||||||
|
zone_registry.clear()
|
||||||
|
zone_registry.update(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_home_zone(player_name="alice"):
|
||||||
|
"""Create a home zone matching housing.py format."""
|
||||||
|
name = f"home:{player_name}"
|
||||||
|
terrain = []
|
||||||
|
for y in range(9):
|
||||||
|
row = []
|
||||||
|
for x in range(9):
|
||||||
|
if x == 0 or x == 8 or y == 0 or y == 8:
|
||||||
|
row.append("#")
|
||||||
|
else:
|
||||||
|
row.append(".")
|
||||||
|
terrain.append(row)
|
||||||
|
zone = Zone(
|
||||||
|
name=name,
|
||||||
|
description=f"{player_name}'s home",
|
||||||
|
width=9,
|
||||||
|
height=9,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=False,
|
||||||
|
impassable={"#", "^", "~"},
|
||||||
|
spawn_x=4,
|
||||||
|
spawn_y=4,
|
||||||
|
safe=True,
|
||||||
|
)
|
||||||
|
register_zone(name, zone)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
def _make_player(name="alice", zone=None, x=4, y=4):
|
||||||
|
"""Create a test player with mock writer."""
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.write = MagicMock()
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
p = Player(name=name, location=zone, x=x, y=y, writer=mock_writer)
|
||||||
|
p.home_zone = f"home:{name}"
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_paint_tile(tmp_path):
|
||||||
|
"""terrain <tile> changes terrain at current position."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
zone = _make_home_zone("alice")
|
||||||
|
player = _make_player("alice", zone=zone, x=4, y=4)
|
||||||
|
|
||||||
|
# Verify starting terrain
|
||||||
|
assert zone.terrain[4][4] == "."
|
||||||
|
|
||||||
|
# Paint a new tile
|
||||||
|
await cmd_terrain(player, "~")
|
||||||
|
|
||||||
|
# Verify terrain changed
|
||||||
|
assert zone.terrain[4][4] == "~"
|
||||||
|
|
||||||
|
# Verify success message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "paint" in messages.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_not_in_home_zone():
|
||||||
|
"""terrain command fails when not in home zone."""
|
||||||
|
# Create home zone but place player in different zone
|
||||||
|
_make_home_zone("alice")
|
||||||
|
overworld = Zone(
|
||||||
|
name="overworld",
|
||||||
|
description="The overworld",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=[["."] * 50 for _ in range(50)],
|
||||||
|
toroidal=True,
|
||||||
|
impassable=set(),
|
||||||
|
spawn_x=25,
|
||||||
|
spawn_y=25,
|
||||||
|
safe=False,
|
||||||
|
)
|
||||||
|
register_zone("overworld", overworld)
|
||||||
|
player = _make_player("alice", zone=overworld, x=25, y=25)
|
||||||
|
|
||||||
|
await cmd_terrain(player, "~")
|
||||||
|
|
||||||
|
# Verify error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "home" in messages.lower() or "can't" in messages.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_cannot_edit_border():
|
||||||
|
"""terrain command prevents editing border tiles."""
|
||||||
|
zone = _make_home_zone("alice")
|
||||||
|
|
||||||
|
# Test all border positions
|
||||||
|
border_positions = [
|
||||||
|
(0, 0), # top-left corner
|
||||||
|
(4, 0), # top edge
|
||||||
|
(8, 0), # top-right corner
|
||||||
|
(0, 4), # left edge
|
||||||
|
(8, 4), # right edge
|
||||||
|
(0, 8), # bottom-left corner
|
||||||
|
(4, 8), # bottom edge
|
||||||
|
(8, 8), # bottom-right corner
|
||||||
|
]
|
||||||
|
|
||||||
|
for x, y in border_positions:
|
||||||
|
player = _make_player("alice", zone=zone, x=x, y=y)
|
||||||
|
|
||||||
|
await cmd_terrain(player, ".")
|
||||||
|
|
||||||
|
# Verify error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "border" in messages.lower() or "wall" in messages.lower()
|
||||||
|
|
||||||
|
# Verify terrain unchanged
|
||||||
|
assert zone.terrain[y][x] == "#"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_no_args():
|
||||||
|
"""terrain with no args shows usage."""
|
||||||
|
zone = _make_home_zone("alice")
|
||||||
|
player = _make_player("alice", zone=zone, x=4, y=4)
|
||||||
|
|
||||||
|
await cmd_terrain(player, "")
|
||||||
|
|
||||||
|
# Verify usage message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "usage" in messages.lower() or "terrain <" in messages.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_only_single_char():
|
||||||
|
"""terrain rejects multi-character arguments."""
|
||||||
|
zone = _make_home_zone("alice")
|
||||||
|
player = _make_player("alice", zone=zone, x=4, y=4)
|
||||||
|
|
||||||
|
await cmd_terrain(player, "~~")
|
||||||
|
|
||||||
|
# Verify error message
|
||||||
|
player.writer.write.assert_called()
|
||||||
|
messages = "".join(call[0][0] for call in player.writer.write.call_args_list)
|
||||||
|
assert "single" in messages.lower() or "one character" in messages.lower()
|
||||||
|
|
||||||
|
# Verify terrain unchanged
|
||||||
|
assert zone.terrain[4][4] == "."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_saves_zone(tmp_path):
|
||||||
|
"""terrain command saves zone to TOML after edit."""
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
init_housing(tmp_path)
|
||||||
|
zone = _make_home_zone("alice")
|
||||||
|
player = _make_player("alice", zone=zone, x=4, y=4)
|
||||||
|
|
||||||
|
# Paint a tile
|
||||||
|
await cmd_terrain(player, "~")
|
||||||
|
|
||||||
|
# Verify TOML file was updated
|
||||||
|
zone_file = tmp_path / "alice.toml"
|
||||||
|
assert zone_file.exists()
|
||||||
|
|
||||||
|
with open(zone_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
# Check that row 4 contains the water tile at position 4
|
||||||
|
rows = data["terrain"]["rows"]
|
||||||
|
assert rows[4][4] == "~"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terrain_various_tiles(tmp_path):
|
||||||
|
"""terrain accepts various tile characters."""
|
||||||
|
init_housing(tmp_path)
|
||||||
|
zone = _make_home_zone("alice")
|
||||||
|
|
||||||
|
test_tiles = [".", "~", "^", "T", ",", '"', "*", "+", "="]
|
||||||
|
|
||||||
|
for tile in test_tiles:
|
||||||
|
player = _make_player("alice", zone=zone, x=4, y=4)
|
||||||
|
await cmd_terrain(player, tile)
|
||||||
|
assert zone.terrain[4][4] == tile
|
||||||
56
tests/test_timeofday.py
Normal file
56
tests/test_timeofday.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Tests for time-of-day system."""
|
||||||
|
|
||||||
|
from mudlib.timeofday import get_sky_description, get_time_period
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_time_period_dawn():
|
||||||
|
assert get_time_period(5) == "dawn"
|
||||||
|
assert get_time_period(6) == "dawn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_time_period_day():
|
||||||
|
assert get_time_period(7) == "day"
|
||||||
|
assert get_time_period(12) == "day"
|
||||||
|
assert get_time_period(17) == "day"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_time_period_dusk():
|
||||||
|
assert get_time_period(18) == "dusk"
|
||||||
|
assert get_time_period(19) == "dusk"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_time_period_night():
|
||||||
|
assert get_time_period(20) == "night"
|
||||||
|
assert get_time_period(21) == "night"
|
||||||
|
assert get_time_period(23) == "night"
|
||||||
|
assert get_time_period(0) == "night"
|
||||||
|
assert get_time_period(1) == "night"
|
||||||
|
assert get_time_period(4) == "night"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sky_description_returns_non_empty():
|
||||||
|
for hour in range(24):
|
||||||
|
desc = get_sky_description(hour)
|
||||||
|
assert desc, f"hour {hour} returned empty description"
|
||||||
|
assert isinstance(desc, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sky_description_differs_by_period():
|
||||||
|
dawn_desc = get_sky_description(5)
|
||||||
|
day_desc = get_sky_description(12)
|
||||||
|
dusk_desc = get_sky_description(18)
|
||||||
|
night_desc = get_sky_description(0)
|
||||||
|
|
||||||
|
# each period should have different descriptions
|
||||||
|
descriptions = {dawn_desc, day_desc, dusk_desc, night_desc}
|
||||||
|
assert len(descriptions) == 4, "periods should have distinct descriptions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sky_description_edge_cases():
|
||||||
|
# boundaries between periods
|
||||||
|
assert get_sky_description(0) # night start
|
||||||
|
assert get_sky_description(5) # dawn start
|
||||||
|
assert get_sky_description(7) # day start
|
||||||
|
assert get_sky_description(18) # dusk start
|
||||||
|
assert get_sky_description(20) # night start
|
||||||
|
assert get_sky_description(23) # night end
|
||||||
135
tests/test_visibility.py
Normal file
135
tests/test_visibility.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Tests for visibility calculations."""
|
||||||
|
|
||||||
|
from mudlib.visibility import get_visibility
|
||||||
|
from mudlib.weather import WeatherCondition, WeatherState
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_day_full_visibility():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_reduces_visibility():
|
||||||
|
hour = 22 # night (20-4)
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 15 # 21 - 6
|
||||||
|
assert height == 9 # 11 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_thick_fog_during_day():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.8)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 13 # 21 - 8
|
||||||
|
assert height == 7 # 11 - 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_plus_thick_fog_clamps_to_minimum():
|
||||||
|
hour = 22 # night
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.9)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
# Night: -6 width, -2 height (21x11 -> 15x9)
|
||||||
|
# Thick fog: -8 width, -4 height (15x9 -> 7x5)
|
||||||
|
# Should clamp to minimum 7x5
|
||||||
|
assert width == 7
|
||||||
|
assert height == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_storm_reduces_visibility():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.storm, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 17 # 21 - 4
|
||||||
|
assert height == 9 # 11 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dawn_subtle_dimming():
|
||||||
|
hour = 5 # dawn (5-6)
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 19 # 21 - 2
|
||||||
|
assert height == 11 # 11 - 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dusk_subtle_dimming():
|
||||||
|
hour = 18 # dusk (18-19)
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 19 # 21 - 2
|
||||||
|
assert height == 11 # 11 - 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_moderate_fog():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 17 # 21 - 4
|
||||||
|
assert height == 9 # 11 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_light_fog_no_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.fog, intensity=0.3)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21 # no reduction for light fog
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudy_no_visibility_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.cloudy, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_rain_no_visibility_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.rain, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_snow_no_visibility_reduction():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.snow, intensity=0.7)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 21
|
||||||
|
assert height == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_base_dimensions():
|
||||||
|
hour = 12 # noon
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather, base_width=31, base_height=21)
|
||||||
|
assert width == 31
|
||||||
|
assert height == 21
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_custom_base():
|
||||||
|
hour = 22 # night
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather, base_width=31, base_height=21)
|
||||||
|
assert width == 25 # 31 - 6
|
||||||
|
assert height == 19 # 21 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_midnight_is_night():
|
||||||
|
hour = 0 # midnight
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 15 # reduced for night
|
||||||
|
assert height == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_early_morning_is_night():
|
||||||
|
hour = 3 # early morning
|
||||||
|
weather = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
width, height = get_visibility(hour, weather)
|
||||||
|
assert width == 15 # reduced for night
|
||||||
|
assert height == 9
|
||||||
260
tests/test_weather.py
Normal file
260
tests/test_weather.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
"""Tests for weather system."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from mudlib.weather import (
|
||||||
|
WeatherCondition,
|
||||||
|
WeatherState,
|
||||||
|
advance_weather,
|
||||||
|
get_weather_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weather_condition_enum():
|
||||||
|
assert WeatherCondition.clear.value == "clear"
|
||||||
|
assert WeatherCondition.cloudy.value == "cloudy"
|
||||||
|
assert WeatherCondition.rain.value == "rain"
|
||||||
|
assert WeatherCondition.storm.value == "storm"
|
||||||
|
assert WeatherCondition.snow.value == "snow"
|
||||||
|
assert WeatherCondition.fog.value == "fog"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weather_state_dataclass():
|
||||||
|
state = WeatherState(condition=WeatherCondition.rain, intensity=0.5)
|
||||||
|
assert state.condition == WeatherCondition.rain
|
||||||
|
assert state.intensity == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_description_returns_non_empty():
|
||||||
|
state = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
description = get_weather_description(state)
|
||||||
|
assert isinstance(description, str)
|
||||||
|
assert len(description) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_description_varies_by_condition():
|
||||||
|
clear = get_weather_description(
|
||||||
|
WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
)
|
||||||
|
rain = get_weather_description(
|
||||||
|
WeatherState(condition=WeatherCondition.rain, intensity=0.5)
|
||||||
|
)
|
||||||
|
snow = get_weather_description(
|
||||||
|
WeatherState(condition=WeatherCondition.snow, intensity=0.5)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Different conditions should produce different descriptions
|
||||||
|
assert clear != rain
|
||||||
|
assert rain != snow
|
||||||
|
assert clear != snow
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_description_varies_by_intensity():
|
||||||
|
light_rain = get_weather_description(
|
||||||
|
WeatherState(condition=WeatherCondition.rain, intensity=0.2)
|
||||||
|
)
|
||||||
|
heavy_rain = get_weather_description(
|
||||||
|
WeatherState(condition=WeatherCondition.rain, intensity=0.9)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Different intensities should produce different descriptions
|
||||||
|
assert light_rain != heavy_rain
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_weather_returns_new_state():
|
||||||
|
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
rng = random.Random(42)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng)
|
||||||
|
|
||||||
|
assert isinstance(new_state, WeatherState)
|
||||||
|
assert isinstance(new_state.condition, WeatherCondition)
|
||||||
|
assert 0.0 <= new_state.intensity <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_weather_is_deterministic_with_seed():
|
||||||
|
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
|
||||||
|
rng1 = random.Random(42)
|
||||||
|
state1 = advance_weather(current, season="summer", rng=rng1)
|
||||||
|
|
||||||
|
rng2 = random.Random(42)
|
||||||
|
state2 = advance_weather(current, season="summer", rng=rng2)
|
||||||
|
|
||||||
|
assert state1.condition == state2.condition
|
||||||
|
assert state1.intensity == state2.intensity
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_weather_transitions_naturally():
|
||||||
|
# Clear can become cloudy
|
||||||
|
rng = random.Random(42)
|
||||||
|
for _ in range(100):
|
||||||
|
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng)
|
||||||
|
if new_state.condition == WeatherCondition.cloudy:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise AssertionError("clear never transitioned to cloudy in 100 iterations")
|
||||||
|
|
||||||
|
# Cloudy can become rain or clear
|
||||||
|
rng = random.Random(43)
|
||||||
|
found_rain = False
|
||||||
|
found_clear = False
|
||||||
|
for _ in range(100):
|
||||||
|
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng)
|
||||||
|
if new_state.condition == WeatherCondition.rain:
|
||||||
|
found_rain = True
|
||||||
|
if new_state.condition == WeatherCondition.clear:
|
||||||
|
found_clear = True
|
||||||
|
if found_rain and found_clear:
|
||||||
|
break
|
||||||
|
assert found_rain or found_clear
|
||||||
|
|
||||||
|
|
||||||
|
def test_storm_transitions_to_rain_or_cloudy():
|
||||||
|
# Storm should always transition away (doesn't last)
|
||||||
|
rng = random.Random(44)
|
||||||
|
found_non_storm = False
|
||||||
|
for _ in range(50):
|
||||||
|
current = WeatherState(condition=WeatherCondition.storm, intensity=0.8)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng)
|
||||||
|
if new_state.condition in (WeatherCondition.rain, WeatherCondition.cloudy):
|
||||||
|
found_non_storm = True
|
||||||
|
break
|
||||||
|
assert found_non_storm, "storm should transition to rain or cloudy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snow_only_in_winter_autumn():
|
||||||
|
# Snow in winter
|
||||||
|
rng = random.Random(45)
|
||||||
|
found_snow = False
|
||||||
|
for _ in range(200):
|
||||||
|
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="winter", rng=rng)
|
||||||
|
if new_state.condition == WeatherCondition.snow:
|
||||||
|
found_snow = True
|
||||||
|
break
|
||||||
|
assert found_snow, "snow should be possible in winter"
|
||||||
|
|
||||||
|
# Snow should be rare or impossible in summer
|
||||||
|
rng = random.Random(46)
|
||||||
|
for _ in range(100):
|
||||||
|
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng)
|
||||||
|
# Should not produce snow in summer
|
||||||
|
assert new_state.condition != WeatherCondition.snow
|
||||||
|
|
||||||
|
|
||||||
|
def test_climate_temperate_default():
|
||||||
|
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
rng = random.Random(47)
|
||||||
|
|
||||||
|
# Should work without climate parameter (defaults to temperate)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng)
|
||||||
|
assert isinstance(new_state, WeatherState)
|
||||||
|
|
||||||
|
|
||||||
|
def test_climate_arid_favors_clear():
|
||||||
|
# Arid should heavily favor clear weather
|
||||||
|
rng = random.Random(48)
|
||||||
|
clear_count = 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="summer", rng=rng, climate="arid")
|
||||||
|
if new_state.condition == WeatherCondition.clear:
|
||||||
|
clear_count += 1
|
||||||
|
|
||||||
|
# Arid should stay clear most of the time
|
||||||
|
assert clear_count > 70, f"arid should favor clear, got {clear_count}/100"
|
||||||
|
|
||||||
|
|
||||||
|
def test_climate_arid_no_snow():
|
||||||
|
# Arid should never produce snow
|
||||||
|
rng = random.Random(49)
|
||||||
|
for _ in range(100):
|
||||||
|
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="winter", rng=rng, climate="arid")
|
||||||
|
assert new_state.condition != WeatherCondition.snow
|
||||||
|
|
||||||
|
|
||||||
|
def test_climate_arctic_favors_snow_fog_cloudy():
|
||||||
|
# Arctic should produce snow, fog, or cloudy frequently
|
||||||
|
rng = random.Random(50)
|
||||||
|
arctic_conditions = 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
current = WeatherState(condition=WeatherCondition.cloudy, intensity=0.5)
|
||||||
|
new_state = advance_weather(current, season="winter", rng=rng, climate="arctic")
|
||||||
|
if new_state.condition in (
|
||||||
|
WeatherCondition.snow,
|
||||||
|
WeatherCondition.fog,
|
||||||
|
WeatherCondition.cloudy,
|
||||||
|
):
|
||||||
|
arctic_conditions += 1
|
||||||
|
|
||||||
|
# Arctic should heavily favor snow/fog/cloudy
|
||||||
|
assert arctic_conditions > 70, (
|
||||||
|
f"arctic should favor snow/fog/cloudy, got {arctic_conditions}/100"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_weather_accepts_all_seasons():
|
||||||
|
current = WeatherState(condition=WeatherCondition.clear, intensity=0.5)
|
||||||
|
rng = random.Random(51)
|
||||||
|
|
||||||
|
for season in ["spring", "summer", "autumn", "winter"]:
|
||||||
|
new_state = advance_weather(current, season=season, rng=rng)
|
||||||
|
assert isinstance(new_state, WeatherState)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_ambience_rain():
|
||||||
|
from mudlib.weather import get_weather_ambience
|
||||||
|
|
||||||
|
messages = get_weather_ambience(WeatherCondition.rain)
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) > 0
|
||||||
|
assert all(isinstance(msg, str) for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_ambience_storm():
|
||||||
|
from mudlib.weather import get_weather_ambience
|
||||||
|
|
||||||
|
messages = get_weather_ambience(WeatherCondition.storm)
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) > 0
|
||||||
|
assert all(isinstance(msg, str) for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_ambience_snow():
|
||||||
|
from mudlib.weather import get_weather_ambience
|
||||||
|
|
||||||
|
messages = get_weather_ambience(WeatherCondition.snow)
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) > 0
|
||||||
|
assert all(isinstance(msg, str) for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_ambience_fog():
|
||||||
|
from mudlib.weather import get_weather_ambience
|
||||||
|
|
||||||
|
messages = get_weather_ambience(WeatherCondition.fog)
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) > 0
|
||||||
|
assert all(isinstance(msg, str) for msg in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_ambience_clear():
|
||||||
|
from mudlib.weather import get_weather_ambience
|
||||||
|
|
||||||
|
messages = get_weather_ambience(WeatherCondition.clear)
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_weather_ambience_cloudy():
|
||||||
|
from mudlib.weather import get_weather_ambience
|
||||||
|
|
||||||
|
messages = get_weather_ambience(WeatherCondition.cloudy)
|
||||||
|
assert isinstance(messages, list)
|
||||||
|
assert len(messages) == 0
|
||||||
Loading…
Reference in a new issue