diff --git a/content/recipes/wooden_table.toml b/content/recipes/wooden_table.toml new file mode 100644 index 0000000..860d839 --- /dev/null +++ b/content/recipes/wooden_table.toml @@ -0,0 +1,4 @@ +name = "wooden_table" +description = "Craft a sturdy table from planks and nails" +ingredients = ["plank", "plank", "plank", "nail", "nail"] +result = "table" diff --git a/src/mudlib/commands/crafting.py b/src/mudlib/commands/crafting.py new file mode 100644 index 0000000..50dec8c --- /dev/null +++ b/src/mudlib/commands/crafting.py @@ -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 \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.") +) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index e1b4eb2..86a306b 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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(): diff --git a/tests/test_command_craft.py b/tests/test_command_craft.py new file mode 100644 index 0000000..0f55699 --- /dev/null +++ b/tests/test_command_craft.py @@ -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()