Add crafting recipe system

Implements Recipe dataclass, recipe loading from TOML files, and recipe
registry. Recipes define ingredients consumed and result produced for
item crafting.
This commit is contained in:
Jared Miller 2026-02-14 17:41:23 -05:00
parent 9f760bc3af
commit ec43ead568
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 156 additions and 0 deletions

57
src/mudlib/crafting.py Normal file
View 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

99
tests/test_crafting.py Normal file
View 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()