Add craft and recipes commands

Implements craft command to create items from recipes by consuming
ingredients from player inventory. Recipes command lists available
recipes or shows details for a specific recipe. Registers commands
and loads recipes at server startup.
This commit is contained in:
Jared Miller 2026-02-14 17:43:56 -05:00
parent 7342a70ba2
commit 11636e073a
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 384 additions and 0 deletions

View file

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

View file

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

View file

@ -42,6 +42,7 @@ from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.corpse import process_decomposing
from mudlib.crafting import load_recipes, recipes
from mudlib.creation import character_creation
from mudlib.dialogue import load_all_dialogues
from mudlib.effects import clear_expired
@ -614,6 +615,13 @@ async def run_server() -> None:
thing_templates.update(loaded_things)
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
# Load crafting recipes
recipes_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "recipes"
if recipes_dir.exists():
loaded_recipes = load_recipes(recipes_dir)
recipes.update(loaded_recipes)
log.info("loaded %d recipes from %s", len(loaded_recipes), recipes_dir)
# Load dialogue trees for NPC conversations
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
if dialogue_dir.exists():

242
tests/test_command_craft.py Normal file
View file

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