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

  1. ShopComponent is a pure data component holding stock, per-template prices, multipliers, and the shop's gold reserve
  2. _find_merchant() scans the player's room for entities tagged "merchant"
  3. Buy computes price = base_price * buy_multiplier, verifies the player's GoldComponent can afford it, creates the item, adds it to the player's InventoryComponent, then atomically deducts the player's gold (rolling back the inventory add if the deduction fails) and credits the shop
  4. Sell computes sale_price = item.value * sell_multiplier, checks the shop can afford it, removes the item, then credits the player's GoldComponent and debits the shop
  5. DescriptionComponent.matches_keyword() handles fuzzy item matching
  6. GoldComponent.remove_gold() returns False when funds are insufficient, giving an atomic check-and-deduct

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
  • 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 TimeOfDay in the buy/sell handlers to restrict trading to daytime

See Also