"""
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
[docs]@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
[docs] @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,
)
[docs] @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]"
[docs] 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}"""
[docs] 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
[docs]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
[docs]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}