Note

You are reading an old version of the Evennia documentation. The latest version is here

.

# Source code for evennia.contrib.tutorials.evadventure.shops

    """
    EvAdventure Shop system.

    A shop is run by an NPC. It can provide one or more of several possible services:

    - Buy from a pre-set list of (possibly randomized) items. Cost is based on the item's value,
      adjusted by how stingy the shopkeeper is. When bought this way, the item is
      generated on the fly and passed to the player character's inventory. Inventory files are
      a list of prototypes, normally from a prototype-file. A random selection of items from each
      inventory file is available.
    - Sell items to the shop for a certain percent of their value. One could imagine being able
      to buy back items again, but we will instead _destroy_ sold items, so as to remove them
      from circulation. In-game we can say it's because the merchants collect the best stuff
      to sell to collectors in the big city later. Each merchant buys a certain subset of items
      based on their tags.
    - Buy a service. For a cost, a certain action is performed for the character; this applies
      immediately when bought. The most notable services are healing and converting coin to XP.
    - Buy rumors - this is echoed to the player for a price. Different merchants could have
      different rumors (or randomized ones).
    - Quest - gain or hand in a quest for a merchant.

    All shops are menu-driven. One starts talking to the npc and will then end up in their shop
    interface.

    This is a series of menu nodes meant to be added as a mapping via
    `EvAdventureShopKeeper.create(menudata={},...)`.

    To make this pluggable, the shopkeeper start page will analyze the available nodes
    and auto-add options to all nodes in the three named `node_start_*`. The last part of the
    node name will be the name of the option capitalized, with underscores replaced by spaces, so
    `node_start_sell_items` will become a top-level option `Sell items`.

    """

    from dataclasses import dataclass

    from evennia.prototypes.prototypes import search_prototype
    from evennia.prototypes.spawner import flatten_prototype, spawn
    from evennia.utils.evmenu import list_node
    from evennia.utils.logger import log_err, log_trace

    from .enums import ObjType, WieldLocation
    from .equipment import EquipmentError

    # ------------------------------------ Buying from an NPC

    @dataclass
    class BuyItem:
        """
        Storage container for storing generic info about an item for sale. This means it can be used
        both for real objects and for prototypes without constantly having to track which is which.

        """

        # skipping typehints here since we are not using them anywhere else

        # available for all buyable items
        key = ""
        desc = ""
        obj_type = ObjType.GEAR
        size = 1
        value = 0
        use_slot = WieldLocation.BACKPACK

        uses = None
        quality = None
        attack_type = None
        defense_type = None
        damage_roll = None

        # references the original (always only one of the two)
        obj = None
        prototype = None

        @staticmethod
        def create_from_obj(obj, shopkeeper):
            """
            Build a new BuyItem container from a real db obj.

            Args:
                obj (EvAdventureObject): An object to analyze.
                shopkeeper (EvAdventureShopKeeper): The shopkeeper.

            Returns:
                BuyItem: A general representation of the original data.

            """
            try:
                # mandatory
                key = obj.key
                desc = obj.db.desc
                obj_type = obj.obj_type
                size = obj.size
                use_slot = obj.use_slot
                value = obj.value * shopkeeper.upsell_factor
            except AttributeError:
                # not a buyable item
                log_trace("Not a buyable item")
                return None

            # getting optional properties

            return BuyItem(
                key=key,
                desc=desc,
                obj_type=obj_type,
                size=size,
                use_slot=use_slot,
                value=value,
                # optional fields
                uses=getattr(obj, "uses", None),
                quality=getattr(obj, "quality", None),
                attack_type=getattr(obj, "attack_type", None),
                defense_type=getattr(obj, "defense_type", None),
                damage_roll=getattr(obj, "damage_roll", None),
                # back-reference (don't set prototype)
                obj=obj,
            )

        @staticmethod
        def create_from_prototype(self, prototype_or_key, shopkeeper):
            """
            Build a new BuyItem container from a prototype.

            Args:
                prototype (dict or key): An Evennia prototype dict or the key of one
                    registered with the system. This is assumed to be a full prototype,
                    including having parsed and included parentage.

            Returns:
                BuyItem: A general representation of the original data.

            """

            def _get_attr_value(key, prot, optional=True):
                """
                We want the attribute's value, which is always in the `attrs` field of
                the prototype.

                """
                attr = [tup for tup in prot.get("attrs", ()) if tup[0] == key]
                try:
                    return attr[0][1]
                except IndexError:
                    if optional:
                        return None
                    raise

            if isinstance(prototype_or_key, dict):
                prototype = prototype_or_key
            else:
                # make sure to generate a 'full' prototype with all inheritance applied ('flattened'),
                # otherwise we will not get inherited data when we analyze it.
                prototype = flatten_prototype(search_prototype(key=prototype_or_key))

            if not prototype:
                log_err(f"No valid prototype '{prototype_or_key}' found")
                return None

            try:
                # at this point we should have a full, flattened prototype ready to spawn. It must
                # contain all fields needed for buying
                key = prototype["key"]
                desc = _get_attr_value("desc", prototype, optional=False)
                obj_type = _get_attr_value("obj_type", prototype, optional=False)
                size = _get_attr_value("size", prototype, optional=False)
                use_slot = _get_attr_value("use_slot", prototype, optional=False)
                value = int(
                    _get_attr_value("value", prototype, optional=False) * shopkeeper.upsell_factor
                )
            except (KeyError, IndexError):
                # not a buyable item
                log_trace("Not a buyable item")
                return None

            return BuyItem(
                key=key,
                desc=desc,
                obj_type=obj_type,
                size=size,
                use_slot=use_slot,
                value=value,
                # optional fields
                uses=_get_attr_value("uses", prototype),
                quality=_get_attr_value("quality", prototype),
                attack_type=_get_attr_value("attack_type", prototype),
                defense_type=_get_attr_value("defense_type", prototype),
                damage_roll=_get_attr_value("damage_roll", prototype),
                # back-reference (don't set obj)
                prototype=prototype,
            )

        def __str__(self):
            """
            Get the short description to show in buy list.

            """
            return f"{self.key} [|y{self.value}|n coins]"

        def get_detail(self):
            """
            Get more info when looking at the item.

            """
            return f"""
    |c{self.key}|n  Cost: |y{self.value}|n coins

    {self.desc}

    Slots: |w{self.size}|n Used from: |w{self.use_slot.value}|n
    Quality: |w{self.quality}|n Uses: |wself.uses|n
    Attacks using: |w{self.attack_type.value}|n against |w{self.defense_type.value}|n
    Damage roll: |w{self.damage_roll}"""

        def to_obj(self):
            """
            Convert this into an actual database object that we can trade. This either means
            using the stored `.prototype` to spawn a new instance of the object, or to
            use the `.obj` reference to get the already existing object.

            """
            if self.obj:
                return self.obj
            return spawn(self.prototype)

    def _get_or_create_buymap(caller, shopkeep):
        """
        Helper that fetches or creates the mapping of `{"short description": BuyItem, ...}`
        we need for the buy menu. We cache it on the `_evmenu` object on the caller.

        """
        if not caller.ndb._evmenu.buymap:
            # buymap not in cache - build it and store in memory on _evmenu object - this way
            # it will be removed automatically when the menu closes. We will need to reset this
            # when the shopkeep buys new things.
            # items carried by the shopkeep are sellable (these are items already created, such as
            # things sold to the shopkeep earlier). We
            obj_wares = [BuyItem.create_from_obj(obj) for obj in list(shopkeep.contents)]
            prototype_wares = [
                BuyItem.create_from_prototype(prototype)
                for prototype in shopkeep.common_ware_prototypes
            ]
            wares = obj_wares + prototype_wares
            caller.ndb._evmenu.buymap = {str(ware): ware for ware in wares if ware}

        return caller.ndb._evmenu.buymap

    # Helper functions for building the shop listings and select a ware to buy
    def _get_all_wares_to_buy(caller, raw_string, **kwargs):
        """
        This helper is used by `EvMenu.list_node` to build the list of items to buy.

        We rely on `**kwargs` being forwarded from `node_start_buy`, which in turns contains
        the `npc` kwarg pointing to the shopkeeper (`caller` is the one doing the buying).

        """
        shopkeep = kwargs["npc"]
        buymap = _get_or_create_buymap(caller, shopkeep)
        return [ware_desc for ware_desc in buymap]

    def _select_ware_to_buy(caller, selected_ware_desc, **kwargs):
        """
        This helper is used by `EvMenu.list_node` to operate on what the user selected.
        We return `item` in the kwargs to the `node_select_buy` node.

        """
        shopkeep = kwargs["npc"]
        buymap = _get_or_create_buymap(caller, shopkeep)
        kwargs["item"] = buymap[selected_ware_desc]

        return "node_confirm_buy", kwargs

    def _back_to_previous_node(caller, raw_string, **kwargs):
        """
        Back to previous node is achieved by returning a node of None.

        """
        return None, kwargs

    def _buy_ware(caller, raw_string, **kwargs):
        """
        Complete the purchase of a ware. At this point the money is deducted
        and the item is either spawned from a prototype or simply moved from
        the sellers inventory to that of the buyer.

        We will have kwargs `item` and `npc` passed along to refer to the BuyItem we bought
        and the shopkeep selling it.

        """
        item = kwargs["item"]  # a BuyItem instance
        shopkeep = kwargs["npc"]

        # exchange money
        caller.coins -= item.value
        shopkeep += item.value

        # get the item - if not enough room, dump it on the ground
        obj = item.to_obj()
        try:
            caller.equipment.add(obj)
        except EquipmentError as err:
            obj.location = caller.location
            caller.msg(err)
            caller.msg(f"|w{obj.key} ends up on the ground.|n")

        caller.msg("|gYou bought |w{obj.key}|g for |y{item.value}|g coins.|n")

    @list_node(_get_all_wares_to_buy, select=_select_ware_to_buy, pagesize=40)
    def node_start_buy(caller, raw_string, **kwargs):
        """
        Menu node for the caller to buy items from the shopkeep. This assumes `**kwargs` contains
        a kwarg `npc` referencing the npc/shopkeep being talked to.

        Items available to sell are a combination of items in the shopkeep's inventory and
        the list of `prototypes` stored in the Shopkeep's "common_ware_prototypes` Attribute. In the
        latter case, the properties will be extracted from the prototype when inspecting it (object will
        only spawn when bought).

        """
        coins = caller.coins
        used_slots = caller.equipment.count_slots()
        max_slots = caller.equipment.max_slots

        text = (
            f'"Seeing something you like?" [you have |y{coins}|n coins, '
            f"using |b{used_slots}/{max_slots}|n slots]"
        )
        # this will be in addition to the options generated by the list-node
        extra_options = [{"key": ("[c]ancel", "b", "c", "cancel"), "goto": "node_start"}]

        return text, extra_options

    def node_confirm_buy(caller, raw_string, **kwargs):
        """
        Menu node reached when a user selects an item in the buy menu. The `item` passed
        along in `**kwargs` is the selected item (see `_select_ware_to_buy`, where this is injected).

        """
        # this was injected in _select_ware_to_buy. This is an BuyItem instance.
        item = kwargs["item"]

        coins = caller.coins
        used_slots = caller.equipment.count_slots()
        max_slots = caller.equipment.max_slots

        text = item.get_detail()
        text += f"\n\n[You have |y{coins}|n coins] and are using |b{used_slots}/{max_slots}|n slots]"

        options = []

        if caller.coins >= item.value and item.size <= (max_slots - used_slots):
            options.append({"desc": f"Buy [{item.value} coins]", "goto": (_buy_ware, kwargs)})
        options.append({"desc": "Cancel", "goto": (_back_to_previous_node, kwargs)})

        return text, options

    # node tree to inject for buying things
    node_tree_buy = {"node_start_buy": node_start_buy, "node_confirm_buy": node_confirm_buy}

    # ------------------------------------------------- Selling to an NPC

    def _get_or_create_sellmap(self, caller, shopkeep):
        if not caller.ndb._evmenu.sellmap:
            # no sellmap, build one anew

            sellmap = {}
            for obj, wieldlocation in caller.equipment.all():
                key = obj.key
                value = int(obj.value * shopkeep.miser_factor)
                if value > 0 and obj.obj_type is not ObjType.QUEST:
                    sellmap[f"|w{key}|n [{wieldlocation.value}] - sell price |y{value}|n coins"] = (
                        obj,
                        value,
                    )
            caller.ndb._evmenu.sellmap = sellmap

        sellmap = caller.ndb._evmenu.sellmap

        return sellmap

    def _get_all_wares_to_sell(caller, raw_string, **kwargs):
        """
        Get all wares available to sell from caller's inventory. We need to build a
        mapping between the descriptors and the items.

        """
        shopkeep = kwargs["npc"]
        sellmap = _get_or_create_sellmap(caller, shopkeep)
        return [ware_desc for ware_desc in sellmap]

    def _sell_ware(caller, raw_string, **kwargs):
        """
        Complete the sale of a ware. This is were money is gained and the item is removed.

        We will have kwargs `item`, `value` and `npc` passed along to refer to the inventory item we
        sold, its (adjusted) sales cost and the shopkeep buying it.

        """
        item = kwargs["item"]
        value = kwargs["value"]
        shopkeep = kwargs["npc"]

        # move item to shopkeep
        obj = caller.equipment.remove(item)
        obj.location = shopkeep

        # exchange money - shopkeep always have money to pay, so we don't deduct from them
        caller.coins += value

        caller.msg("|gYou sold |w{obj.key}|g for |y{value}|g coins.|n")

    def _select_ware_to_sell(caller, selected_ware_desc, **kwargs):
        """
        Selected one ware to sell. Figure out which one it is using the sellmap.
        Store the result as "item" kwarg.

        """
        shopkeep = kwargs["npc"]
        sellmap = _get_or_create_sellmap(caller, shopkeep)
        kwargs["item"], kwargs["value"] = sellmap[selected_ware_desc]

        return "node_examine_sell", kwargs

    @list_node(_get_all_wares_to_sell, select=_select_ware_to_sell, pagesize=20)
    def node_start_sell(caller, raw_string, **kwargs):
        """
        The start-level node for selling items from the user's inventory. This assumes
        `**kwargs` contains a kwarg `npc` referencing the npc/shopkeep being talked to.

        Items available to sell are all items in the player's equipment handler, including
        things in their hands.

        """
        coins = caller.coins
        used_slots = caller.equipment.count_slots()
        max_slots = caller.equipment.max_slots

        text = (
            f'"Anything you want to sell?" [you have |y{coins}|n coins, '
            f"using |b{used_slots}/{max_slots}|n slots]"
        )
        # this will be in addition to the options generated by the list-node
        extra_options = [{"key": ("[c]ancel", "b", "c", "cancel"), "goto": "node_start"}]

        return text, extra_options

    def node_confirm_sell(caller, raw_string, **kwargs):
        """
        In this node we confirm the sell by first investigating the item we are about to sell.

        We have `item` and `value` available in kwargs here, added by `_select_ware_to_sell` earler.

        """
        item = kwargs["item"]
        value = kwargs["value"]

        coins = caller.coins
        used_slots = caller.equipment.count_slots()
        max_slots = caller.equipment.max_slots

        text = caller.equipment.get_obj_stats(item)
        text += f"\n\n[You have |y{coins}|n coins] and are using |b{used_slots}/{max_slots}|n slots]"

        options = (
            {"desc": f"Sell [{value} coins]", "goto": (_sell_ware, kwargs)},
            {"desc": "Cancel", "goto": (_back_to_previous_node, kwargs)},
        )

        return text, options

    # node tree to inject for selling things
    node_tree_sell = {"node_start_sell": node_start_sell, "node_confirm_sell": node_confirm_sell}

    # Full shopkeep node tree - inject into ShopKeep NPC menu to add buy/sell submenus
    node_tree_shopkeep = {**node_tree_buy, **node_tree_sell}

Note

You are reading an old version of the Evennia documentation. The latest version is here

.