Skip to content

Crafting Station

Problem

You want players to combine ingredients at a crafting station to produce new items, using a recipe system.

Solution

Data Models

from dataclasses import dataclass
from uuid import UUID
from pydantic import Field
from maid_engine.core.ecs import Component


@dataclass
class CraftingRecipe:
    """A recipe that transforms ingredients into an output."""

    recipe_id: str
    name: str
    ingredients: dict[str, int]  # item keyword -> quantity needed
    output_template: str          # Template ID for created item
    output_quantity: int = 1
    required_station: str = ""    # Station type (e.g., "forge", "alchemy_bench")
    skill_requirement: int = 0    # Minimum crafting skill level


class CraftingStationComponent(Component):
    """Marks an entity as a crafting station of a specific type."""

    station_type: str = "workbench"  # "forge", "alchemy_bench", "loom", etc.
    station_name: str = "Workbench"
    recipes: list[str] = Field(default_factory=list)  # Available recipe IDs at this station

Recipe Registry

class RecipeRegistry:
    """Central store for all crafting recipes."""

    def __init__(self) -> None:
        self._recipes: dict[str, CraftingRecipe] = {}

    def register(self, recipe: CraftingRecipe) -> None:
        self._recipes[recipe.recipe_id] = recipe

    def get(self, recipe_id: str) -> CraftingRecipe | None:
        return self._recipes.get(recipe_id)

    def find_by_station(self, station_type: str) -> list[CraftingRecipe]:
        return [
            r for r in self._recipes.values()
            if r.required_station == station_type
        ]

    def all_recipes(self) -> list[CraftingRecipe]:
        return list(self._recipes.values())

The Craft Command

from maid_engine.commands.decorators import command, arguments
from maid_engine.commands.arguments import ArgumentSpec, ArgumentType, ParsedArguments
from maid_engine.commands import CommandContext
from maid_stdlib.components import (
    DescriptionComponent,
    InventoryComponent,
    ItemComponent,
)


# Module-level registry (wire this during on_load)
recipe_registry = RecipeRegistry()


def _find_station(ctx: CommandContext) -> CraftingStationComponent | None:
    """Find a crafting station in the player's current room."""
    room_id = ctx.world.get_entity_room(ctx.player_id)
    if not room_id:
        return None
    for entity in ctx.world.entities_in_room(room_id):
        station = entity.try_get(CraftingStationComponent)
        if station:
            return station
    return None


def _count_matching_items(
    ctx: CommandContext, player_inv: InventoryComponent, keyword: str
) -> list[UUID]:
    """Find all item IDs in inventory matching a keyword."""
    matches: list[UUID] = []
    for item_id in player_inv.items:
        item = ctx.world.get_entity(item_id)
        if not item:
            continue
        desc = item.try_get(DescriptionComponent)
        if desc and desc.matches_keyword(keyword):
            matches.append(item_id)
    return matches


@command(name="craft", category="crafting", help_text="Craft an item at a station")
@arguments(
    ArgumentSpec("recipe_name", ArgumentType.STRING, description="Recipe to craft"),
)
async def cmd_craft(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Craft an item using a recipe at a nearby station."""
    station = _find_station(ctx)
    if not station:
        await ctx.session.send("There's no crafting station here.\n")
        return False

    recipe_name: str = args["recipe_name"]

    # Find matching recipe at this station
    available = recipe_registry.find_by_station(station.station_type)
    recipe: CraftingRecipe | None = None
    for r in available:
        if recipe_name.lower() in r.name.lower():
            recipe = r
            break

    if not recipe:
        await ctx.session.send(
            f"No recipe matching '{recipe_name}' at this {station.station_name}.\n"
        )
        return False

    # Check player has all ingredients
    player = ctx.world.get_entity(ctx.player_id)
    if not player:
        return False

    player_inv = player.try_get(InventoryComponent)
    if not player_inv:
        await ctx.session.send("You have no inventory.\n")
        return False

    items_to_consume: list[UUID] = []
    for ingredient, qty_needed in recipe.ingredients.items():
        matches = _count_matching_items(ctx, player_inv, ingredient)
        if len(matches) < qty_needed:
            await ctx.session.send(
                f"You need {qty_needed}x {ingredient} "
                f"(have {len(matches)}).\n"
            )
            return False
        items_to_consume.extend(matches[:qty_needed])

    # Consume ingredients
    for item_id in items_to_consume:
        item = ctx.world.get_entity(item_id)
        item_comp = item.try_get(ItemComponent) if item else None
        weight = item_comp.weight if item_comp else 0.0
        player_inv.remove_item(item_id, weight)
        ctx.world.destroy_entity(item_id)

    # Create output item(s)
    for _ in range(recipe.output_quantity):
        output = ctx.world.create_entity()
        output.add(DescriptionComponent(
            name=recipe.name,
            keywords=[recipe.output_template],
        ))
        output.add(ItemComponent(item_type="crafted", weight=1.0))
        output.add_tag("item")
        player_inv.add_item(output.id, 1.0)

    await ctx.session.send(
        f"You craft {recipe.output_quantity}x {recipe.name}!\n"
    )
    return True


@command(name="recipes", category="crafting",
         help_text="List available recipes at a station")
async def cmd_recipes(ctx: CommandContext) -> bool:
    """List recipes available at the nearby crafting station."""
    station = _find_station(ctx)
    if not station:
        await ctx.session.send("There's no crafting station here.\n")
        return False

    available = recipe_registry.find_by_station(station.station_type)
    if not available:
        await ctx.session.send("No recipes available at this station.\n")
        return True

    lines: list[str] = [f"\n=== {station.station_name} Recipes ===\n"]
    for recipe in available:
        ingredients = ", ".join(
            f"{qty}x {name}" for name, qty in recipe.ingredients.items()
        )
        lines.append(f"  {recipe.name}: {ingredients}\n")
    lines.append("")

    await ctx.session.send("".join(lines))
    return True

Wiring It Up

Register recipes during your content pack's on_load:

from maid_engine.core.engine import GameEngine


async def on_load(self, engine: GameEngine) -> None:
    recipe_registry.register(CraftingRecipe(
        recipe_id="health_potion",
        name="Health Potion",
        ingredients={"red_herb": 2, "empty_vial": 1},
        output_template="health_potion",
        required_station="alchemy_bench",
    ))
    recipe_registry.register(CraftingRecipe(
        recipe_id="iron_sword",
        name="Iron Sword",
        ingredients={"iron_ingot": 3, "leather_strip": 1},
        output_template="iron_sword",
        required_station="forge",
    ))

How It Works

  1. CraftingStationComponent marks an entity as a crafting station of a specific type
  2. RecipeRegistry stores all recipes and filters by station type
  3. The craft command finds a station in the room, matches a recipe, checks ingredients, consumes them, and creates the output
  4. InventoryComponent.remove_item() and World.destroy_entity() handle ingredient consumption
  5. Output items are created as new entities and added to the player's inventory

Variations

  • Skill checks: Add a random failure chance based on skill_requirement vs player skill level
  • Crafting time: Instead of instant creation, start a timer and create the item after N seconds
  • Quality tiers: Roll a quality modifier based on skill, producing "Fine Iron Sword" etc.
  • Discovery: Hide recipes until the player finds a recipe scroll or learns from an NPC
  • Fuel cost: Require the station to have fuel (e.g., coal for a forge) tracked via a component

See Also