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,
    exclude: set[UUID] | None = None,
) -> list[UUID]:
    """Find all item IDs in inventory matching a keyword.

    ``exclude`` holds item IDs already reserved for other ingredients so the
    same physical item is never counted toward two different ingredient slots.
    """
    exclude = exclude or set()
    matches: list[UUID] = []
    for item_id in player_inv.items:
        if item_id in exclude:
            continue
        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] = []
    reserved: set[UUID] = set()
    for ingredient, qty_needed in recipe.ingredients.items():
        # Exclude items already reserved for earlier ingredients so one
        # physical item can't satisfy two ingredient slots at once.
        matches = _count_matching_items(ctx, player_inv, ingredient, exclude=reserved)
        if len(matches) < qty_needed:
            await ctx.session.send(
                f"You need {qty_needed}x {ingredient} "
                f"(have {len(matches)}).\n"
            )
            return False
        chosen = matches[:qty_needed]
        items_to_consume.extend(chosen)
        reserved.update(chosen)

    # Preflight capacity BEFORE consuming anything. Removing ingredients frees
    # slots and weight, but the crafted outputs still have to fit. Checking up
    # front means a full pack can't cause ingredients to be destroyed for an
    # output that can't be added.
    output_weight = 1.0
    consumed_weight = 0.0
    for item_id in items_to_consume:
        item = ctx.world.get_entity(item_id)
        item_comp = item.try_get(ItemComponent) if item else None
        consumed_weight += item_comp.weight if item_comp else 0.0

    slots_after = (
        len(player_inv.items) - len(items_to_consume) + recipe.output_quantity
    )
    weight_after = (
        player_inv.current_weight
        - consumed_weight
        + output_weight * recipe.output_quantity
    )
    if slots_after > player_inv.capacity or weight_after > player_inv.weight_limit:
        await ctx.session.send("You don't have enough room to craft that.\n")
        return False

    # Consume ingredients transactionally. remove_item returns False if the
    # item is unexpectedly gone; if that happens, re-add everything already
    # removed and abort so we never destroy inputs without producing output.
    removed: list[tuple[UUID, float]] = []
    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
        if not player_inv.remove_item(item_id, weight):
            for undo_id, undo_weight in removed:
                player_inv.add_item(undo_id, undo_weight)
            await ctx.session.send("Something went wrong; craft cancelled.\n")
            return False
        removed.append((item_id, weight))

    # Only destroy inputs once all removals succeeded.
    for item_id, _ in removed:
        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=output_weight))
        output.add_tag("item")
        if not player_inv.add_item(output.id, output_weight):
            # Should not happen after the preflight; clean up the orphan so we
            # never leave an entity that is neither carried nor in a room.
            ctx.world.destroy_entity(output.id)

    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

CraftingStationComponent is a custom Component, so register it for persistence in your content pack's register_component_types — otherwise the save/load layer can't serialize station entities and their station_type / recipes will not survive a restart:

from maid_engine.persistence.registry import ComponentRegistry


def register_component_types(self, registry: ComponentRegistry) -> None:
    registry.register(CraftingStationComponent, pack_name="my-pack")

CraftingRecipe is a plain dataclass held in the module-level RecipeRegistry, not an ECS Component, so it is not registered here. Recipes are static content you re-register on every boot (below); only the per-entity CraftingStationComponent needs persistence registration.

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
  6. A capacity preflight runs before ingredients are consumed, so a full pack never destroys ingredients for an output that can't be added
  7. Register CraftingStationComponent via register_component_types so station entities persist across restarts

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