Shop NPC¶
Problem¶
You want a merchant NPC that players can buy from and sell to, with a managed inventory and gold economy.
Solution¶
Components¶
Define a ShopComponent to hold the merchant's stock and pricing:
from uuid import UUID
from pydantic import Field
from maid_engine.core.ecs import Component
class ShopComponent(Component):
"""Marks an NPC as a merchant with buyable inventory."""
shop_name: str = "General Store"
buy_multiplier: float = 1.0 # Price players pay
sell_multiplier: float = 0.5 # Price players receive
stock: dict[str, int] = Field(default_factory=dict) # template_id -> quantity (-1 = unlimited)
gold: int = 1000 # Shop's gold reserve
Setting Up the Merchant¶
from maid_engine.core.world import World
from maid_stdlib.components import (
DescriptionComponent,
NPCComponent,
InventoryComponent,
ItemComponent,
)
async def create_shopkeeper(world: World, room_id: UUID) -> None:
"""Create a shopkeeper NPC in the given room."""
merchant = world.create_entity()
merchant.add(DescriptionComponent(
name="Greta the Shopkeeper",
short_desc="a stout woman behind the counter",
long_desc="Greta eyes you shrewdly from behind a wooden counter "
"piled high with goods.",
keywords=["greta", "shopkeeper", "merchant"],
))
merchant.add(NPCComponent(
behavior_type="merchant",
is_merchant=True,
dialogue_id="greta_dialogue",
))
merchant.add(InventoryComponent(capacity=100))
merchant.add(ShopComponent(
shop_name="Greta's Goods",
buy_multiplier=1.2,
sell_multiplier=0.4,
stock={"health_potion": -1, "iron_sword": 3, "leather_armor": 2},
))
merchant.add_tag("npc")
merchant.add_tag("merchant")
world.place_entity_in_room(merchant.id, room_id)
Buy and Sell Commands¶
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_engine.core.ecs import Entity
from maid_stdlib.components import (
DescriptionComponent,
InventoryComponent,
ItemComponent,
)
def _find_merchant(ctx: CommandContext) -> Entity | None:
"""Find a merchant NPC in the player's 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):
if entity.has_tag("merchant"):
return entity
return None
@command(name="buy", category="economy", help_text="Buy an item from a merchant")
@arguments(
ArgumentSpec("item_name", ArgumentType.STRING, description="Item to buy"),
)
async def cmd_buy(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Buy an item from the merchant in the room."""
merchant = _find_merchant(ctx)
if not merchant:
await ctx.session.send("There's no merchant here.\n")
return False
shop = merchant.get(ShopComponent)
item_keyword: str = args["item_name"]
# Find the item template in the shop's stock
matching_template: str | None = None
for template_id in shop.stock:
if item_keyword.lower() in template_id.lower():
matching_template = template_id
break
if not matching_template:
await ctx.session.send(f"The shop doesn't sell '{item_keyword}'.\n")
return False
qty = shop.stock[matching_template]
if qty == 0:
await ctx.session.send("That item is out of stock.\n")
return False
# Check player gold (assumes a GoldComponent or similar)
player = ctx.world.get_entity(ctx.player_id)
if not player:
return False
# Create the purchased item and add to player inventory
item = ctx.world.create_entity()
item.add(DescriptionComponent(
name=matching_template.replace("_", " ").title(),
keywords=[matching_template],
))
item.add(ItemComponent(item_type="misc", weight=1.0))
item.add_tag("item")
player_inv = player.try_get(InventoryComponent)
if not player_inv or not player_inv.add_item(item.id, 1.0):
await ctx.session.send("Your inventory is full.\n")
ctx.world.destroy_entity(item.id)
return False
# Deduct stock
if qty > 0:
shop.stock[matching_template] = qty - 1
shop.notify_mutation()
await ctx.session.send(
f"You buy {matching_template.replace('_', ' ')} from {shop.shop_name}.\n"
)
return True
@command(name="sell", category="economy", help_text="Sell an item to a merchant")
@arguments(
ArgumentSpec("item_name", ArgumentType.STRING, description="Item to sell"),
)
async def cmd_sell(ctx: CommandContext, args: ParsedArguments) -> bool:
"""Sell an item to the merchant in the room."""
merchant = _find_merchant(ctx)
if not merchant:
await ctx.session.send("There's no merchant here.\n")
return False
shop = merchant.get(ShopComponent)
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 nothing to sell.\n")
return False
# Find matching item in player inventory
item_keyword: str = args["item_name"]
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(item_keyword):
item_comp = item.try_get(ItemComponent)
sale_price = int(
(item_comp.value if item_comp else 0) * shop.sell_multiplier
)
player_inv.remove_item(item_id, item_comp.weight if item_comp else 0)
ctx.world.destroy_entity(item_id)
await ctx.session.send(
f"You sell {desc.name} for {sale_price} gold.\n"
)
return True
await ctx.session.send(f"You don't have '{item_keyword}' to sell.\n")
return False
@command(name="list", aliases=["browse"], category="economy",
help_text="Browse a merchant's wares")
async def cmd_list_wares(ctx: CommandContext) -> bool:
"""List items for sale at the merchant."""
merchant = _find_merchant(ctx)
if not merchant:
await ctx.session.send("There's no merchant here.\n")
return False
shop = merchant.get(ShopComponent)
lines: list[str] = [f"\n=== {shop.shop_name} ===\n"]
for template_id, qty in shop.stock.items():
stock_str = "unlimited" if qty == -1 else str(qty)
name = template_id.replace("_", " ").title()
lines.append(f" {name:<25} Stock: {stock_str}\n")
lines.append("")
await ctx.session.send("".join(lines))
return True
Registering Commands¶
In your content pack's register_commands:
from maid_engine.commands import LayeredCommandRegistry
def register_commands(self, registry: LayeredCommandRegistry) -> None:
registry.register("buy", cmd_buy, self.manifest.name, category="economy")
registry.register("sell", cmd_sell, self.manifest.name, category="economy")
registry.register(
"list", cmd_list_wares, self.manifest.name,
aliases=["browse"], category="economy",
)
How It Works¶
- ShopComponent is a pure data component holding stock, prices, and gold
_find_merchant()scans the player's room for entities tagged"merchant"- Buy creates a new item entity, adds it to the player's
InventoryComponent, and decrements stock - Sell finds a matching item in the player's inventory, removes it, and destroys the entity
DescriptionComponent.matches_keyword()handles fuzzy item matching
Variations¶
- Restocking: Add a
RestockSystemthat refillsshop.stockon a timer viaTickEvent - Dynamic pricing: Adjust
buy_multiplierbased on supply/demand or player reputation - Gold tracking: Use
maid_classic_rpg'sGoldComponentto manage player currency - Haggling: Add a
has_skill(haggle, 3)lock expression to unlock better prices - Shop hours: Check
TimeOfDayin the buy/sell handlers to restrict trading to daytime
See Also¶
- Commands Guide — Command registration
- ECS Components — Creating custom components
- Content Packs — Pack structure