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")
CraftingRecipeis a plain dataclass held in the module-levelRecipeRegistry, not an ECSComponent, so it is not registered here. Recipes are static content you re-register on every boot (below); only the per-entityCraftingStationComponentneeds 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¶
- CraftingStationComponent marks an entity as a crafting station of a specific type
- RecipeRegistry stores all recipes and filters by station type
- The
craftcommand finds a station in the room, matches a recipe, checks ingredients, consumes them, and creates the output InventoryComponent.remove_item()andWorld.destroy_entity()handle ingredient consumption- Output items are created as new entities and added to the player's inventory
- A capacity preflight runs before ingredients are consumed, so a full pack never destroys ingredients for an output that can't be added
- Register
CraftingStationComponentviaregister_component_typesso station entities persist across restarts
Variations¶
- Skill checks: Add a random failure chance based on
skill_requirementvs 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¶
- Custom Command — Command creation from scratch
- ECS Components — Component design
- Content Packs — Pack lifecycle