From 4b856b84f75451d349e49d39a2301f2d078b529c Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 3 Aug 2022 01:04:07 +0200 Subject: [PATCH] First design of shop menu nodes for plugging in --- .../contrib/tutorials/evadventure/commands.py | 39 ++- .../tutorials/evadventure/equipment.py | 43 ++- evennia/contrib/tutorials/evadventure/npcs.py | 6 +- .../contrib/tutorials/evadventure/shops.py | 291 ++++++++++++++++-- evennia/utils/evmenu.py | 21 +- 5 files changed, 361 insertions(+), 39 deletions(-) diff --git a/evennia/contrib/tutorials/evadventure/commands.py b/evennia/contrib/tutorials/evadventure/commands.py index 8d0cdb4f04..70d8dc4476 100644 --- a/evennia/contrib/tutorials/evadventure/commands.py +++ b/evennia/contrib/tutorials/evadventure/commands.py @@ -1,5 +1,15 @@ """ -nextEvAdventure commands and cmdsets. +EvAdventure commands and cmdsets. We don't need that many stand-alone new +commands since a lot of functionality is managed in menus. These commands +are in additional to normal Evennia commands and should be added +to the CharacterCmdSet + +New commands: + attack/hit [,...] + inventory + wield/wear + unwield/remove + give to """ @@ -65,3 +75,30 @@ class CmdAttackTurnBased(EvAdventureCommand): self.caller.msg(f"|r{err}|n") else: self.caller.msg("|rFound noone to attack.|n") + + +class CmdInventory(EvAdventureCommand): + """ + View your inventory + + Usage: + inventory + + """ + + key = "inventory" + aliases = ("i", "inv") + + def func(self): + self.caller.msg(self.caller.equipment.display_loadout()) + + +class CmdWield(EvAdventureCommand): + """ + Wield a weapon/shield or wear armor. + + Usage: + wield + wear + + """ diff --git a/evennia/contrib/tutorials/evadventure/equipment.py b/evennia/contrib/tutorials/evadventure/equipment.py index 57438864fc..fe045ea4da 100644 --- a/evennia/contrib/tutorials/evadventure/equipment.py +++ b/evennia/contrib/tutorials/evadventure/equipment.py @@ -55,7 +55,7 @@ class EquipmentHandler: """ self.obj.attributes.add(self.save_attribute, self.slots, category="inventory") - def _count_slots(self): + def count_slots(self): """ Count slot usage. This is fetched from the .size Attribute of the object. The size can also be partial slots. @@ -94,7 +94,7 @@ class EquipmentHandler: """ size = getattr(obj, "size", 0) max_slots = self.max_slots - current_slot_usage = self._count_slots() + current_slot_usage = self.count_slots() if current_slot_usage + size > max_slots: slots_left = max_slots - current_slot_usage raise EquipmentError( @@ -104,6 +104,26 @@ class EquipmentHandler: ) return True + def all(self): + """ + Get all objects in inventory, regardless of location. + + Returns: + list: A flat list of item tuples `[(item, WieldLocation),...]` + starting with the wielded ones, backpack content last. + + """ + slots = self.slots + lst = [ + (slots[WieldLocation.WEAPON_HAND], WieldLocation.WEAPON_HAND), + (slots[WieldLocation.SHIELD_HAND], WieldLocation.SHIELD_HAND), + (slots[WieldLocation.TWO_HANDS], WieldLocation.TWO_HANDS), + (slots[WieldLocation.BODY], WieldLocation.BODY), + (slots[WieldLocation.HEAD], WieldLocation.HEAD), + ] + [(item, WieldLocation.BACKPACK) for item in slots[WieldLocation.BACKPACK]] + # remove any None-results from empty slots + return [tup for tup in lst if item[0]] + @property def armor(self): """ @@ -333,3 +353,22 @@ class EquipmentHandler: """ character = self.obj return [obj for obj in self.slots[WieldLocation.BACKPACK] if obj.at_pre_use(character)] + + def get_obj_stats(self, obj): + """ + Get a string of stats about the object. + + """ + objmap = dict(self.all()) + carried = objmap.get(obj) + carried = f"Worn: [{carried.value}]" if carried else "" + + return f""" +|c{self.key}|n Value: |y{self.value}|n coins {carried} + +{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}""" diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index d02f0ea580..c7313e63c2 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -217,9 +217,9 @@ class EvAdventureShopKeeper(EvAdventureTalkativeNPC): """ # how much extra the shopkeeper adds on top of the item cost - upsell_factor = AttributePropert(1.0, autocreate=False) + upsell_factor = AttributeProperty(1.0, autocreate=False) # how much of the raw cost the shopkeep is willing to pay when buying from character - miser_factor = Attribute(0.5, autocreate=False) + miser_factor = AttributeProperty(0.5, autocreate=False) # prototypes of common wares common_ware_prototypes = AttributeProperty([], autocreate=False) @@ -241,7 +241,7 @@ class EvAdventureMob(EvAdventureNPC): """ # chance (%) that this enemy will loot you when defeating you - loot_chance = AttributeProperty(75) + loot_chance = AttributeProperty(75, autocreate=False) def ai_combat_next_action(self, combathandler): """ diff --git a/evennia/contrib/tutorials/evadventure/shops.py b/evennia/contrib/tutorials/evadventure/shops.py index 0d8c7a7e69..dd02e42d64 100644 --- a/evennia/contrib/tutorials/evadventure/shops.py +++ b/evennia/contrib/tutorials/evadventure/shops.py @@ -37,16 +37,16 @@ node name will be the name of the option capitalized, with underscores replaced """ from dataclasses import dataclass -from random import choice from evennia.prototypes.prototypes import search_prototype -from evennia.prototypes.spawner import flatten_prototype -from evennia.utils.evmenu import EvMenu, list_node +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 evennia.utils.utils import make_iter -from .enums import Ability, ObjType, WieldLocation -from .npcs import EvAdventureShopKeeper +from .enums import ObjType, WieldLocation +from .equipment import EquipmentError + +# ------------------------------------ Buying from an NPC @dataclass @@ -170,8 +170,9 @@ class BuyItem: 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) + 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") @@ -194,12 +195,12 @@ class BuyItem: prototype=prototype, ) - def get_sdesc(self): + def __str__(self): """ Get the short description to show in buy list. """ - return self.key + return f"{self.key} [|y{self.value}|n coins]" def get_detail(self): """ @@ -207,11 +208,48 @@ class BuyItem: """ return f""" - |c{self.key}|n - {self.desc} +|c{self.key}|n Cost: |y{self.value}|n coins - Slots: {self.size} Used from: {self.use_slot.value} +{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 @@ -224,19 +262,61 @@ def _get_all_wares_to_buy(caller, raw_string, **kwargs): """ shopkeep = kwargs["npc"] - # items carried by the shopkeep are sellable (these are items already created, such as - # things sold to the shopkeep earlier). We - wares = [BuyItem.create_from_obj(obj) for obj in list(shopkeep.contents)] + [ - BuyItem.create_from_prototype(prototype) for prototype in shopkeep.common_ware_prototypes - ] - # clean out any ByItems that failed to create for some reason - wares = [ware for ware in wares if ware] + buymap = _get_or_create_buymap(caller, shopkeep) + return [ware_desc for ware_desc in buymap] -# shop menu nodes to use for building a Shopkeeper npc +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 -@list_node(_get_all_wares_to_buy, select=_select_ware_to_buy, pagesize=10) +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 @@ -248,3 +328,170 @@ def node_start_buy(caller, raw_string, **kwargs): 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} diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 060d47c140..41df976a66 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -269,24 +269,23 @@ they will be tested in sequence. """ -import re import inspect - +import re from ast import literal_eval from fnmatch import fnmatch +from inspect import getargspec, isfunction from math import ceil -from inspect import isfunction, getargspec from django.conf import settings -from evennia import Command, CmdSet -from evennia.utils import logger -from evennia.utils.evtable import EvTable, EvColumn -from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop -from evennia.commands import cmdhandler # i18n from django.utils.translation import gettext as _ +from evennia import CmdSet, Command +from evennia.commands import cmdhandler +from evennia.utils import logger +from evennia.utils.ansi import strip_ansi +from evennia.utils.evtable import EvColumn, EvTable +from evennia.utils.utils import crop, dedent, is_iter, m_len, make_iter, mod_import, pad, to_str # read from protocol NAWS later? _MAX_TEXT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -1394,9 +1393,9 @@ def list_node(option_generator, select=None, pagesize=10): # we assume a string was given, we inject the result into the kwargs # to pass on to the next node kwargs["selection"] = selection - return str(select) + return str(select), kwargs # this means the previous node will be re-run with these same kwargs - return None + return None, kwargs def _list_node(caller, raw_string, **kwargs):