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:
parent
7342a70ba2
commit
11636e073a
4 changed files with 384 additions and 0 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"
|
||||||
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.")
|
||||||
|
)
|
||||||
|
|
@ -42,6 +42,7 @@ 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.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
|
||||||
|
|
@ -614,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():
|
||||||
|
|
|
||||||
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()
|
||||||
Loading…
Reference in a new issue