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)
prices: dict[str, int] = Field(default_factory=dict) # template_id -> base price (before buy_multiplier)
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,
PositionComponent,
)
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},
prices={"health_potion": 25, "iron_sword": 150, "leather_armor": 80},
))
# Give the NPC a PositionComponent so its location is durable. Without one,
# place_entity_in_room() only adds it to the (rebuildable) room index; the
# persisted location is derived from PositionComponent, so the merchant
# would lose its room on reload.
merchant.add(PositionComponent(room_id=room_id))
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,
GoldComponent,
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 (with atomic gold transfer)."""
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
base_price = shop.prices.get(matching_template)
if base_price is None:
await ctx.session.send("That item isn't for sale right now.\n")
return False
price = max(1, int(base_price * shop.buy_multiplier))
# Player and gold checks (buying requires a GoldComponent)
player = ctx.world.get_entity(ctx.player_id)
if not player:
return False
player_gold = player.try_get(GoldComponent)
if not player_gold or player_gold.gold < price:
have = player_gold.gold if player_gold else 0
await ctx.session.send(
f"You can't afford that ({price} gold; you have {have}).\n"
)
return False
player_inv = player.try_get(InventoryComponent)
if not player_inv:
await ctx.session.send("You have no inventory.\n")
return False
# Create the purchased item and try to add it to the player's 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, value=base_price))
item.add_tag("item")
if 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
# Transfer gold. If the deduction fails, roll back the inventory add so we
# never hand out a free item.
if not player_gold.remove_gold(price):
player_inv.remove_item(item.id, 1.0)
ctx.world.destroy_entity(item.id)
await ctx.session.send("The transaction failed.\n")
return False
shop.gold += price
# Deduct stock (unlimited stock is -1)
if qty > 0:
shop.stock[matching_template] = qty - 1
shop.notify_mutation()
await ctx.session.send(
f"You buy {matching_template.replace('_', ' ')} for {price} gold "
f"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 (with atomic gold transfer)."""
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
player_gold = player.try_get(GoldComponent)
if not player_gold:
await ctx.session.send("You have no coin purse to hold the gold.\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 = max(
0, int((item_comp.value if item_comp else 0) * shop.sell_multiplier)
)
if shop.gold < sale_price:
await ctx.session.send(
f"{shop.shop_name} can't afford to buy that right now.\n"
)
return False
# Remove the item, then transfer gold (shop -> player)
player_inv.remove_item(item_id, item_comp.weight if item_comp else 0)
ctx.world.destroy_entity(item_id)
player_gold.add_gold(sale_price)
shop.gold -= sale_price
shop.notify_mutation()
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 and the Component¶
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",
)
ShopComponent is a custom Component, so register it for persistence in
register_component_types — otherwise a merchant's stock, prices, and gold
reserve won't survive a restart:
from maid_engine.persistence.registry import ComponentRegistry
def register_component_types(self, registry: ComponentRegistry) -> None:
registry.register(ShopComponent, pack_name="my-pack")
How It Works¶
- ShopComponent is a pure data component holding stock, per-template
prices, multipliers, and the shop's gold reserve _find_merchant()scans the player's room for entities tagged"merchant"- Buy computes
price = base_price * buy_multiplier, verifies the player'sGoldComponentcan afford it, creates the item, adds it to the player'sInventoryComponent, then atomically deducts the player's gold (rolling back the inventory add if the deduction fails) and credits the shop - Sell computes
sale_price = item.value * sell_multiplier, checks the shop can afford it, removes the item, then credits the player'sGoldComponentand debits the shop DescriptionComponent.matches_keyword()handles fuzzy item matchingGoldComponent.remove_gold()returnsFalsewhen funds are insufficient, giving an atomic check-and-deduct
Variations¶
- Restocking: Add a
RestockSystemthat refillsshop.stockon a timer viaTickEvent - Dynamic pricing: Adjust
buy_multiplierbased on supply/demand or player reputation - Banked gold: Use
GoldComponent.deposit()/withdraw()to let players pay from the bank - 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