Skip to content

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

  1. ShopComponent is a pure data component holding stock, prices, and gold
  2. _find_merchant() scans the player's room for entities tagged "merchant"
  3. Buy creates a new item entity, adds it to the player's InventoryComponent, and decrements stock
  4. Sell finds a matching item in the player's inventory, removes it, and destroys the entity
  5. DescriptionComponent.matches_keyword() handles fuzzy item matching

Variations

  • Restocking: Add a RestockSystem that refills shop.stock on a timer via TickEvent
  • Dynamic pricing: Adjust buy_multiplier based on supply/demand or player reputation
  • Gold tracking: Use maid_classic_rpg's GoldComponent to manage player currency
  • Haggling: Add a has_skill(haggle, 3) lock expression to unlock better prices
  • Shop hours: Check TimeOfDay in the buy/sell handlers to restrict trading to daytime

See Also