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¶
- 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
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