From 2848e31f4b78e5e69d3ba098a85c10fa65904146 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 4 Aug 2022 20:22:42 +0200 Subject: [PATCH] Add all needed evadventure commands --- evennia/commands/default/general.py | 7 +- .../tutorials/evadventure/characters.py | 26 +- .../tutorials/evadventure/combat_turnbased.py | 2 +- .../contrib/tutorials/evadventure/commands.py | 361 +++++++++++++++++- .../tutorials/evadventure/equipment.py | 106 +++-- evennia/contrib/tutorials/evadventure/npcs.py | 2 +- .../contrib/tutorials/evadventure/utils.py | 44 +++ evennia/utils/evmenu.py | 3 +- 8 files changed, 490 insertions(+), 61 deletions(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 98ad671e7a..7c5b05de1b 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -2,9 +2,10 @@ General Character commands usually available to all characters """ import re + from django.conf import settings -from evennia.utils import utils from evennia.typeclasses.attributes import NickTemplateInvalid +from evennia.utils import utils COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -501,7 +502,7 @@ class CmdGive(COMMAND_DEFAULT_CLASS): Usage: give - Gives an items from your inventory to another character, + Gives an item from your inventory to another person, placing it in their inventory. """ @@ -538,7 +539,7 @@ class CmdGive(COMMAND_DEFAULT_CLASS): return # give object - success = to_give.move_to(target, quiet=True, move_type="get") + success = to_give.move_to(target, quiet=True, move_type="give") if not success: caller.msg("This could not be given.") else: diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index 9d866fa328..50cce06d09 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -5,11 +5,14 @@ Character class. from evennia.objects.objects import DefaultCharacter from evennia.typeclasses.attributes import AttributeProperty +from evennia.utils.evmenu import ask_yes_no +from evennia.utils.logger import log_trace from evennia.utils.utils import lazy_property from . import rules -from .equipment import EquipmentHandler +from .equipment import EquipmentError, EquipmentHandler from .quests import EvAdventureQuestHandler +from .utils import get_obj_stats class LivingMixin: @@ -79,7 +82,7 @@ class LivingMixin: """ pass - def at_loot(self, looted): + def at_do_loot(self, looted): """ Called when looting another entity. @@ -87,9 +90,9 @@ class LivingMixin: looted: The thing to loot. """ - looted.get_loot() + looted.at_looted() - def get_loot(self, looter): + def at_looted(self, looter): """ Called when being looted (after defeat). @@ -186,12 +189,14 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): Args: moved_object (Object): Object to move into this one (that is, into inventory). source_location (Object): Source location moved from. - **kwargs: Passed from move operation; unused here. + **kwargs: Passed from move operation; the `move_type` is useful; if someone is giving + us something (`move_type=='give'`) we want to ask first. Returns: bool: If move should be allowed or not. """ + # this will raise EquipmentError if inventory is full return self.equipment.validate_slot_usage(moved_object) def at_object_receive(self, moved_object, source_location, **kwargs): @@ -205,15 +210,18 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): **kwargs: Passed from move operation; unused here. """ - self.equipment.add(moved_object) + try: + self.equipment.add(moved_object) + except EquipmentError as err: + log_trace(f"at_object_receive error: {err}") def at_pre_object_leave(self, leaving_object, destination, **kwargs): """ Hook called when dropping an item. We don't allow to drop weilded/worn items - (need to unwield/remove them first). + (need to unwield/remove them first). Return False to """ - return self.equipment.can_remove(leaving_object) + return True def at_object_leave(self, moved_object, destination, **kwargs): """ @@ -264,7 +272,7 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter): # don't allow looting in pvp return not self.location.allow_pvp - def get_loot(self, looter): + def at_looted(self, looter): """ Called when being looted. diff --git a/evennia/contrib/tutorials/evadventure/combat_turnbased.py b/evennia/contrib/tutorials/evadventure/combat_turnbased.py index 25dbc0cf86..a84df2c577 100644 --- a/evennia/contrib/tutorials/evadventure/combat_turnbased.py +++ b/evennia/contrib/tutorials/evadventure/combat_turnbased.py @@ -765,7 +765,7 @@ class EvAdventureCombatHandler(DefaultScript): for enemy in defeated_enemies: try: if ally.pre_loot(enemy): - enemy.get_loot(ally) + enemy.at_looted(ally) ally.post_loot(enemy) except Exception: logger.log_trace() diff --git a/evennia/contrib/tutorials/evadventure/commands.py b/evennia/contrib/tutorials/evadventure/commands.py index 70d8dc4476..c4fcc82157 100644 --- a/evennia/contrib/tutorials/evadventure/commands.py +++ b/evennia/contrib/tutorials/evadventure/commands.py @@ -9,13 +9,35 @@ New commands: inventory wield/wear unwield/remove - give to + give to + talk +To install, add the `EvAdventureCmdSet` from this module to the default character cmdset: + +```python + # in mygame/commands/default_cmds.py + + from evennia.contrib.tutorials.evadventure.commands import EvAdventureCmdSet # <--- + + # ... + + class CharacterCmdSet(CmdSet): + def at_cmdset_creation(self): + # ... + self.add(EvAdventureCmdSet) # <----- + +``` """ -from evennia import Command, default_cmds +from evennia import CmdSet, Command, InterruptCommand +from evennia.utils.evmenu import EvMenu +from evennia.utils.utils import inherits_from from .combat_turnbased import CombatFailure, join_combat +from .enums import WieldLocation +from .equipment import EquipmentError +from .npcs import EvAdventureTalkativeNPC +from .utils import get_obj_stats class EvAdventureCommand(Command): @@ -93,12 +115,343 @@ class CmdInventory(EvAdventureCommand): self.caller.msg(self.caller.equipment.display_loadout()) -class CmdWield(EvAdventureCommand): +class CmdWieldOrWear(EvAdventureCommand): """ - Wield a weapon/shield or wear armor. + Wield a weapon/shield, or wear a piece of armor or a helmet. Usage: wield wear + The item will automatically end up in the suitable spot, replacing whatever + was there previously. + """ + + key = "wield" + aliases = ("wear",) + + out_txts = { + WieldLocation.BACKPACK: "You shuffle the position of {key} around in your backpack.", + WieldLocation.TWO_HANDS: "You hold {key} with both hands.", + WieldLocation.WEAPON_HAND: "You hold {key} in your strongest hand, ready for action.", + WieldLocation.SHIELD_HAND: "You hold {key} in your off hand, ready to protect you.", + WieldLocation.BODY: "You strap {key} on yourself.", + WieldLocation.HEAD: "You put {key} on your head.", + } + + def func(self): + # find the item among those in equipment + item = self.caller.search(self.args, candidates=self.caller.equipment.all(only_objs=True)) + if not item: + # An 'item not found' error will already have been reported; we add another line + # here for clarity. + self.caller.msg("You must carry the item you want to wield or wear.") + return + + use_slot = getattr(item, "inventory_use_slot", WieldLocation.BACKPACK) + + # check what is currently in this slot + current = self.caller.equipment.slots[use_slot] + + if current == item: + self.caller.msg(f"You are already using {item.key} here.") + return + + # move it to the right slot based on the type of object + self.caller.equipment.use(item) + + # inform the user of the change (and potential swap) + if current: + self.caller.msg(f"Returning {current.key} to the backpack.") + self.caller.msg(self.out_txts[use_slot].format(key=item.key)) + + +class CmdRemove(EvAdventureCommand): + """ + Remove a remove a weapon/shield, armor or helmet. + + Usage: + remove + unwield + unwear + + To remove an item from the backpack, use |wdrop|n instead. + + """ + + key = "remove" + aliases = ("unwield", "unwear") + + def func(self): + caller = self.caller + + # find the item among those in equipment + item = caller.search(self.args, candidates=caller.equipment.all(only_objs=True)) + if not item: + # An 'item not found' error will already have been reported + return + + current_slot = caller.equipment.get_current_slot(item) + + if current_slot is WieldLocation.BACKPACK: + # we don't allow dropping this way since it may be unexepected by users who forgot just + # where their item currently is. + caller.msg( + f"You already stashed away {item.key} in your backpack. Use 'drop' if " + "you want to get rid of it." + ) + return + + caller.equipment.remove(item) + caller.equipment.add(item) + caller.msg(f"You stash {item.key} in your backpack.") + + +# give / accept menu + + +def _rescind_gift(caller, raw_string, **kwargs): + """ + Called when giver rescinds their gift in `node_give` below. + It means they entered 'cancel' on the gift screen. + + """ + # kill the gift menu for the receiver immediately + receiver = kwargs["receiver"] + receiver.ndb._evmenu.close_menu() + receiver.msg("The offer was rescinded.") + return "node_end" + + +def node_give(caller, raw_string, **kwargs): + """ + This will show to the giver until receiver accepts/declines. It allows them + to rescind their offer. + + The `caller` here is the one giving the item. We also make sure to feed + the 'item' and 'receiver' into the Evmenu. + + """ + item = kwargs["item"] + receiver = kwargs["receiver"] + text = f""" +You are offering {item.key} to {receiver.get_display_name(looker=caller)}. +|wWaiting for them to accept or reject the offer ...|n +""".strip() + + options = { + "key": ("cancel", "abort"), + "desc": "Rescind your offer.", + "goto": (_rescind_gift, kwargs), + } + return text, options + + +def _accept_or_reject_gift(caller, raw_string, **kwargs): + """ + Called when receiver enters yes/no in `node_receive` below. We first need to + figure out which. + + """ + item = kwargs["item"] + giver = kwargs["giver"] + if raw_string.lower() in ("yes", "y"): + # they accepted - move the item! + item = giver.equipment.remove(item) + if item: + try: + # this will also add them to the equipment backpack, if possible + item.move_to(caller, quiet=True, move_type="give") + except EquipmentError: + caller.location.msg_contents( + f"$You({giver.key.key}) $conj(try) to give " + f"{item.key} to $You({caller.key}), but they can't accept it since their " + "inventory is full.", + mapping={giver.key: giver, caller.key: caller}, + ) + else: + caller.location.msg_contents( + f"$You({giver.key}) $conj(give) {item.key} to $You({caller.key}), " + "and they accepted the offer.", + mapping={giver.key: giver, caller.key: caller}, + ) + giver.ndb._evmenu.close_menu() + return "node_end" + + +def node_receive(caller, raw_string, **kwargs): + """ + Will show to the receiver and allow them to accept/decline the offer for + as long as the giver didn't rescind it. + + The `caller` here is the one receiving the item. We also make sure to feed + the 'item' and 'giver' into the EvMenu. + + """ + item = kwargs["item"] + giver = kwargs["giver"] + text = f""" +{giver.get_display_name()} is offering you {item.key}: + +{get_obj_stats(item)} + +[Your inventory usage: {caller.equipment.get_slot_usage_string()}] +|wDo you want to accept the given item? Y/[N] + """ + options = ({"key": "_default", "goto": (_accept_or_reject_gift, kwargs)},) + return text, options + + +def node_end(caller, raw_string, **kwargs): + return "", None + + +class CmdGive(EvAdventureCommand): + """ + Give item or money to another person. Items need to be accepted before + they change hands. Money changes hands immediately with no wait. + + Usage: + give to + give [coins] to receiver + + If item name includes ' to ', surround it in quotes. + + Examples: + give apple to ranger + give "road to happiness" to sad ranger + give 10 coins to ranger + give 12 to ranger + + """ + + key = "give" + + def parse(self): + """ + Parsing is a little more complex for this command. + + """ + super().parse() + args = self.args + if " to " not in args: + self.caller.msg( + "Usage: give to . Specify e.g. '10 coins' to pay money. " + "Use quotes around the item name it if includes the substring ' to '. " + ) + raise InterruptCommand + + self.item_name = "" + self.coins = 0 + + # make sure we can use '...' to include items with ' to ' in the name + if args.startswith('"') and args.count('"') > 1: + end_ind = args[1:].index('"') + 1 + item_name = args[:end_ind] + _, receiver_name = args.split(" to ", 1) + elif args.startswith("'") and args.count("'") > 1: + end_ind = args[1:].index("'") + 1 + item_name = args[:end_ind] + _, receiver_name = args.split(" to ", 1) + else: + item_name, receiver_name = args.split(" to ", 1) + + # a coin count rather than a normal name + if " coins" in item_name: + item_name = item_name[:-6] + if item_name.isnumeric(): + self.coins = max(0, int(item_name)) + + self.item_name = item_name + self.receiver_name = receiver_name + + def func(self): + caller = self.caller + + receiver = caller.search(self.receiver_name) + if not receiver: + return + + # giving of coins is always accepted + + if self.coins: + current_coins = caller.coins + if caller.coins < current_coins: + caller.msg("You only have |y{current_coins}|n to give.") + return + # do transaction + caller.coins -= self.coins + receiver.coins += self.coins + caller.location.msg_contents( + f"$You() $conj(give) $You(receiver.key) {self.coins} coins." + ) + return + + # giving of items require acceptance before it happens + + item = caller.search(self.item_name, candidates=caller.equipment.all(only_objs=True)) + if not item: + return + + # testing hook + if not item.at_pre_give(caller, receiver): + return + + # before we start menus, we must check so either part is not already in a menu, + # that would be annoying otherwise + if receiver.ndb._evmenu: + caller.msg( + f"{receiver.get_display_name(looker=caller)} seems busy talking to someone else." + ) + return + if caller.ndb._evmenu: + caller.msg("Close the current menu first.") + return + + # this starts evmenus for both parties + EvMenu( + receiver, {"node_receive": node_receive, "node_end": node_end}, item=item, giver=caller + ) + EvMenu(caller, {"node_give": node_give, "node_end": node_end}, item=item, receiver=receiver) + + +class CmdTalk(EvAdventureCommand): + """ + Start a conversations with shop keepers and other NPCs in the world. + + Args: + talk + + """ + + key = "talk" + + def func(self): + target = self.search(self.args) + if not target: + return + + if not inherits_from(target, EvAdventureTalkativeNPC): + self.caller.msg( + f"{target.get_display_name(looker=self.caller)} does not seem very talkative." + ) + return + target.at_talk(self.caller) + + +class EvAdventureCmdSet(CmdSet): + """ + Groups all commands in one cmdset which can be added in one go to the DefaultCharacter cmdset. + + """ + + key = "evadventure" + + def at_cmdset_creation(self): + self.add(CmdAttackTurnBased()) + self.add(CmdInventory()) + self.add(CmdWieldOrWear()) + self.add(CmdRemove()) + self.add(CmdGive()) + self.add(CmdTalk()) diff --git a/evennia/contrib/tutorials/evadventure/equipment.py b/evennia/contrib/tutorials/evadventure/equipment.py index fe045ea4da..04dec2c36a 100644 --- a/evennia/contrib/tutorials/evadventure/equipment.py +++ b/evennia/contrib/tutorials/evadventure/equipment.py @@ -3,8 +3,10 @@ Knave has a system of Slots for its inventory. """ +from evennia.utils.utils import inherits_from + from .enums import Ability, WieldLocation -from .objects import WeaponEmptyHand +from .objects import EvAdventureObject, WeaponEmptyHand class EquipmentError(TypeError): @@ -81,6 +83,16 @@ class EquipmentHandler: """ return getattr(self.obj, Ability.CON.value, 1) + 10 + def get_slot_usage_string(self): + """ + Get a slot usage/max string for display. + + Returns: + str: The usage string. + + """ + return f"|b{self.count_slots()}/{self.max_slots}|n" + def validate_slot_usage(self, obj): """ Check if obj can fit in equipment, based on its size. @@ -92,7 +104,10 @@ class EquipmentHandler: EquipmentError: If there's not enough room. """ - size = getattr(obj, "size", 0) + if not inherits_from(obj, EvAdventureObject): + raise EquipmentError(f"{obj.key} is not something that can be equipped.") + + size = obj.size max_slots = self.max_slots current_slot_usage = self.count_slots() if current_slot_usage + size > max_slots: @@ -104,25 +119,21 @@ class EquipmentHandler: ) return True - def all(self): + def get_current_slot(self, obj): """ - Get all objects in inventory, regardless of location. + Check which slot-type the given object is in. + + Args: + obj (EvAdventureObject): The object to check. Returns: - list: A flat list of item tuples `[(item, WieldLocation),...]` - starting with the wielded ones, backpack content last. + WieldLocation: A location the object is in. None if the object + is not in the inventory at all. """ - 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]] + for equipment_item, slot in self.all(): + if obj == equipment_item: + return slot @property def armor(self): @@ -205,10 +216,10 @@ class EquipmentHandler: return f"{weapon_str}{shield_str}\n{armor_str}{helmet_str}" - def use(self, obj): + def move(self, obj): """ - Make use of item - this makes use of the object's wield slot to decide where - it goes. If it doesn't have any, it goes into backpack. + Moves item to the place it things it should be in - this makes use of the object's wield + slot to decide where it goes. If it doesn't have any, it goes into backpack. Args: obj (EvAdventureObject): Thing to use. @@ -238,7 +249,7 @@ class EquipmentHandler: slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None slots[use_slot] = obj elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND): - # can't keep a two-handed weapon if adding a one-handede weapon or shield + # can't keep a two-handed weapon if adding a one-handed weapon or shield slots[WieldLocation.TWO_HANDS] = None slots[use_slot] = obj elif use_slot is WieldLocation.BACKPACK: @@ -255,19 +266,19 @@ class EquipmentHandler: """ Put something in the backpack specifically (even if it could be wield/worn). + Args: + obj (EvAdventureObject): The object to add. + + Notes: + This will not change the object's `.location`, this must be done + by the calling code. + """ # check if we have room self.validate_slot_usage(obj) self.slots[WieldLocation.BACKPACK].append(obj) self._save() - def can_remove(self, leaving_object): - """ - Called to check if the object can be removed. - - """ - return True # TODO - some things may not be so easy, like mud - def remove(self, obj_or_slot): """ Remove specific object or objects from a slot. @@ -279,6 +290,10 @@ class EquipmentHandler: Returns: list: A list of 0, 1 or more objects emptied from the inventory. + Notes: + This will not change the object's `.location`, this must be done separately + by the calling code. + """ slots = self.slots ret = [] @@ -354,21 +369,28 @@ 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): + def all(self, only_objs=False): """ - Get a string of stats about the object. + Get all objects in inventory, regardless of location. + + Keyword Args: + only_objs (bool): Only return a flat list of objects, not tuples. + + Returns: + list: A list of item tuples `[(item, WieldLocation),...]` + starting with the wielded ones, backpack content last. If `only_objs` is set, + this will just be a flat list of objects. """ - 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}""" + 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 + if only_objs: + return [tup[0] for tup in lst if tup[0]] + return [tup for tup in lst if tup[0]] diff --git a/evennia/contrib/tutorials/evadventure/npcs.py b/evennia/contrib/tutorials/evadventure/npcs.py index c7313e63c2..02f777a316 100644 --- a/evennia/contrib/tutorials/evadventure/npcs.py +++ b/evennia/contrib/tutorials/evadventure/npcs.py @@ -280,7 +280,7 @@ class EvAdventureMob(EvAdventureNPC): """ self.at_death() - def at_loot(self, looted): + def at_do_loot(self, looted): """ Called when mob gets to loot a PC. diff --git a/evennia/contrib/tutorials/evadventure/utils.py b/evennia/contrib/tutorials/evadventure/utils.py index 070f252ad5..1e9b6d9127 100644 --- a/evennia/contrib/tutorials/evadventure/utils.py +++ b/evennia/contrib/tutorials/evadventure/utils.py @@ -2,3 +2,47 @@ Various utilities. """ + +_OBJ_STATS = """ +|c{key}|n Value: approx. |y{value}|n coins {carried} + +{desc} + +Slots: |w{size}|n Used from: |w{use_slot_name}|n +Quality: |w{quality}|n Uses: |wuses|n +Attacks using: |w{attack_type_name}|n against |w{defense_type_value}|n +Damage roll: |w{damage_roll}""".strip() + + +def get_obj_stats(obj, owner=None): + """ + Get a string of stats about the object. + + Args: + obj (EvAdventureObject): The object to get stats for. + owner (EvAdventureCharacter, optional): If given, it allows us to + also get information about if the item is currently worn/wielded. + + Returns: + str: A stat string to show about the object. + + """ + carried = "" + if owner: + objmap = dict(owner.equipment.all()) + carried = objmap.get(obj) + carried = f"Worn: [{carried.value}]" if carried else "" + + return _OBJ_STATS.format( + key=obj.key, + value=obj.value, + carried=carried, + desc=obj.db.desc, + size=obj.size, + use_slot=obj.use_slot.value, + quality=obj.quality, + uses=obj.uses, + attack_type=obj.attack_type.value, + defense_type=obj.defense_type.value, + damage_roll=obj.damage_roll, + ) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 41df976a66..1111f8faea 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1744,10 +1744,11 @@ def ask_yes_no( **kwargs, ): """ - A helper question for asking a simple yes/no question. This will cause + A helper function for asking a simple yes/no question. This will cause the system to pause and wait for input from the player. Args: + caller (Object): The entity being asked. prompt (str): The yes/no question to ask. This takes an optional formatting marker `{options}` which will be filled with 'Y/N', '[Y]/N' or 'Y/[N]' depending on the setting of `default`. If `allow_abort` is set,