diff --git a/evennia/contrib/tutorials/evadventure/characters.py b/evennia/contrib/tutorials/evadventure/characters.py index c139abf30f..cf3cc8a5f1 100644 --- a/evennia/contrib/tutorials/evadventure/characters.py +++ b/evennia/contrib/tutorials/evadventure/characters.py @@ -3,12 +3,12 @@ Base Character and NPCs. """ - from evennia.objects.objects import DefaultCharacter, DefaultObject from evennia.typeclasses.attributes import AttributeProperty from evennia.utils.utils import lazy_property, int2str from .objects import EvAdventureObject from . import rules +from .enums import Ability, WieldLocation class EquipmentError(TypeError): @@ -17,316 +17,226 @@ class EquipmentError(TypeError): class EquipmentHandler: """ - _Knave_ puts a lot of emphasis on the inventory. You have 20 inventory slots, - Some things, like torches can fit multiple in one slot, other (like + _Knave_ puts a lot of emphasis on the inventory. You have CON_DEFENSE inventory + slots. Some things, like torches can fit multiple in one slot, other (like big weapons) use more than one slot. The items carried and wielded has a big impact on character customization - even magic requires carrying a runestone per spell. The inventory also doubles as a measure of negative effects. Getting soaked in mud or slime could gunk up some of your inventory slots and make the items there unusuable - until you cleaned them. - + until you clean them. """ - # these are the equipment slots available - total_slots = 20 - wield_slots = ["shield", "weapon"] - wear_slots = ["helmet", "armor"] + save_attribute = "inventory_slots" def __init__(self, obj): self.obj = obj - self._slots_used = None - self._wielded = None - self._worn = None - self._armor = None + self._load() - def _wield_or_wear(self, item, action="wear"): + def _load(self): """ - Wield or wear a previously carried item in one of the supported wield/wear slots. Items need - to have the wieldable/wearable tag and will get a wielded/worn tag. The slot to occupy is - retrieved from the item itself. - - Args: - item (Object): The object to wield. This will replace any existing - wieldable item in that spot. - action (str): One of 'wield' or 'wear'. - Returns: - tuple: (slot, old_item - the slot-name this item was - assigned to (like 'helmet') and any old item that was replaced in that location,. - (else `old_item` is `None`). This is useful for returning info messages - to the user. - Raises: - EquipmentError: If there is a problem wielding the item. - - Notes: - Since the action of wielding is so similar to wearing, we use the same code for both, - just exchanging which slot to use and the wield/wear and wielded/worn texts. + Load or create a new slot storage. """ - adjective = 'wearable' if action == 'wear' else 'wieldable' - verb = "worn" if action == 'wear' else 'wielded' + self.slots = self.obj.attributes.get( + self.save_attribute, + category="inventory", + default={ + WieldLocation.WEAPON_HAND: None, + WieldLocation.SHIELD_HAND: None, + WieldLocation.TWO_HANDS: None, + WieldLocation.BODY: None, + WieldLocation.HEAD: None, + WieldLocation.BACKPACK: [] + } + ) - if item not in self.obj.contents: - raise EquipmentError(f"You need to pick it up before you can use it.") - if item in self.wielded: - raise EquipmentError(f"Already using {item.key}") - if not item.tags.has(adjective, category="inventory"): - # must have wieldable/wearable tag - raise EquipmentError(f"Cannot {action} {item.key}") + def _count_slots(self): + """ + Count slot usage. This is fetched from the .size Attribute of the + object. The size can also be partial slots. - # see if an existing item already sits in the relevant slot - if action == 'wear': - slot = item.wear_slot - old_item = self.worn.get(slot) - self.worn[slot] = item - else: - slot = item.wield_slot - old_item = self.wielded.get(slot) - self.wielded[item] + """ + slots = self.slots + wield_usage = sum( + getattr(slotobj, "size", 0) or 0 + for slot, slotobj in slots.items() + if slot is not WieldLocation.BACKPACK + ) + backpack_usage = sum( + getattr(slotobj, "size", 0) or 0 + for slotobj in slots[WieldLocation.BACKPACK] + ) + return wield_usage + backpack_usage - # untag old, tag the new and store it in .wielded dict for easy access - if old_item: - old_item.tags.remove(verb, category="inventory") - item.tags.add(verb, category="inventory") + def _save(self): + """ + Save slot to storage. - return slot, old_item + """ + self.obj.attributes.add(self.save_attribute, category="inventory") @property - def slots_used(self): + def max_slots(self): """ - Return how many slots are used up (out of .total_slots). Certain, big items may use more - than one slot. Also caches the results. + The max amount of equipment slots ('carrying capacity') is based on + the constitution defense. """ - slots_used = self._slots_used - if slots_used is None: - slots_used = self._slots_used = sum( - item.inventory_slot_usage for item in self.contents - ) - return slots_used + return getattr(self.obj, Ability.CON_DEFENSE.value, 11) - @property - def all(self): + def validate_slot_usage(self, obj): """ - Get all carried items. Used by an 'inventory' command. - - """ - return self.obj.contents - - @property - def worn(self): - """ - Get (and cache) all worn items. - - """ - worn = self._worn - if worn is None: - worn = self._worn = list( - DefaultObject.objects - .get_by_tag(["wearable", "worn"], category="inventory") - .filter(db_location=self.obj) - ) - return worn - - @property - def wielded(self): - wielded = self._wielded - if wielded is None: - wielded = self._wielded = list( - DefaultObject.objects - .get_by_tag(["wieldable", "wielded"], category="inventory") - .filter(db_location=self.obj) - ) - return wielded - - @property - def carried(self): - wielded_or_worn = self.wielded + self.worn - return [item for item in self.contents if item not in wielded_or_worn] - - @property - def armor_defense(self): - """ - Figure out the total armor defense of the character. This is a combination - of armor from worn items (helmets, armor) and wielded ones (shields). - - """ - armor = self._armor - if armor is None: - # recalculate and refresh cache. Default for unarmored enemy is armor defense of 11. - armor = self._armor = sum(item.armor for item in self.worn + self.wielded) or 11 - return armor - - def has_space(self, item): - """ - Check if there's room in equipment for this item. + Check if obj can fit in equipment, based on its size. Args: - item (Object): An entity that takes up space. + obj (EvAdventureObject): The object to add. - Returns: - bool: If there's room or not. - - Notes: - Also informs the user of the failure. + Raise: + EquipmentError: If there's not enough room. """ - needed_slots = getattr(item, "inventory_slot_usage", 1) - free = self.slots_used - needed_slots - if free - needed_slots < 0: - self.obj.msg(f"No space in inventory - {item} takes up {needed_slots}, " - f"but $int2str({free}) $pluralize(is, {free}, are) available.") - return False - return True - - def can_drop(self, item): - """ - Check if the item can be dropped - this is blocked by being worn or wielded. - - Args: - item (Object): The item to drop. - - Returns: - bool: If the object can be dropped. - - Notes: - Informs the user of a failure. - - """ - if item in self.wielded: - self.msg("You are currently wielding {item.key}. Unwield it first.") - return False - if item in self.worn: - self.msg("You are currently wearing {item.key}. Remove it first.") - return False - return True - - def add(self, item): - """ - Add an item to the inventory. This will be called when picking something up. An item - must be carried before it can be worn or wielded. - - There is a max number of carry-slots. - - Args: - item (EvAdventureObject): The item to add (pick up). - Raises: - EquipmentError: If the item can't be added (usually because of lack of space). - - """ - slots_needed = item.inventory_slot_usage - slots_already_used = self.slots_used - - slots_free = self.total_slots - slots_already_used - - if slot_needed > slots_free: - raise EquipmentError( - f"This requires {slots_needed} equipment slots - you have " - f"$int2str({slots_free}) $pluralize(slot, {slots_free}) available.") - # move to inventory - item.location = self.obj - self.slots_used += slots_needed - - def remove(self, item): - """ - Remove (drop) an item from inventory. This will also un-wear or un-wield it. - - Args: - item (EvAdventureObject): The item to drop. - Raises: - EquipmentError: If the item can't be dropped (usually because we don't have it). - - """ - if item not in self.obj.contents: - raise EquipmentError("You are not carrying this item.") - self.slots_used -= item.inventory_slot_usage - - def wear(self, item): - """ - Wear a previously carried item. The item itelf knows which slot it belongs in (like 'helmet' - or 'armor'). - - Args: - item (EvAdventureObject): The item to wear. Must already be carried. - Returns: - tuple: (slot, old_item - the slot-name this item was - assigned to (like 'helmet') and any old item that was replaced in that location - (else `old_item` is `None`). This is useful for returning info messages - to the user. - Raises: - EquipmentError: If there is a problem wearing the item. - - """ - return self._wield_or_wear(item, action="wield") - - def wield(self, item): - """ - Wield a previously carried item. The item itelf knows which wield-slot it belongs in (like - 'helmet' or 'armor'). - - Args: - item (EvAdventureObject): The item to wield. Must already be carried. - - Returns: - tuple: (slot, old_item - the wield-slot-name this item was - assigned to (like 'shield') and any old item that was replaced in that location - (else `old_item` is `None`). This is useful for returning info messages - to the user. - Raises: - EquipmentError: If there is a problem wielding the item. - - """ - return self._wield_or_wear(item, action="wear") - - -class EvAdventureCharacter(DefaultCharacter): - """ - A Character for use with EvAdventure. This also works fine for - monsters and NPCS. - - """ - - # these are the ability bonuses. Defense is always 10 higher - strength = AttributeProperty(default=1) - dexterity = AttributeProperty(default=1) - constitution = AttributeProperty(default=1) - intelligence = AttributeProperty(default=1) - wisdom = AttributeProperty(default=1) - charisma = AttributeProperty(default=1) - - armor = AttributeProperty(default=1) - - exploration_speed = AttributeProperty(default=120) - combat_speed = AttributeProperty(default=40) - - hp = AttributeProperty(default=4) - hp_max = AttributeProperty(default=4) - level = AttributeProperty(default=1) - xp = AttributeProperty(default=0) - - morale = AttributeProperty(default=9) # only used for NPC/monster morale checks - - @lazy_property - def equipment(self): - """Allows to access equipment like char.equipment.worn""" - return EquipmentHandler(self) - - @property - def weapon(self): - """ - Quick access to the character's currently wielded weapon. - Will return the "Unarmed" weapon if none other are found. - - """ - # TODO + size = getattr(obj, "size", 0) + max_slots = self.max_slots + current_slot_usage = self._count_slots() + if current_slot_usage + size > max_slots: + slots_left = max_slots - current_slot_usage + raise EquipmentError(f"Equipment full ({int2str(slots_left)} slots " + f"remaining, {obj.key} needs {int2str(size)} " + f"$pluralize(slot, {size})).") @property def armor(self): """ - Quick access to the character's current armor. - Will return the "Unarmored" armor if none other are found. + Armor provided by actually worn equipment/shield. For body armor + this is a base value, like 12, for shield/helmet, it's a bonus, like +1. + We treat values and bonuses equal and just add them up. This value + can thus be 0, the 'unarmored' default should be handled by the calling + method. + + Returns: + int: Armor from equipment. """ - # TODO + slots = self.slots + return sum(( + getattr(slots[WieldLocation.BODY], "armor", 0), + getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0), + getattr(slots[WieldLocation.HEAD], "armor", 0), + )) + + @property + def weapon(self): + """ + Conveniently get the currently active weapon. + + Returns: + obj or None: The weapon. None if unarmored. + + """ + # first checks two-handed wield, then one-handed; the two + # should never appear simultaneously anyhow (checked in `use` method). + slots = self.slots + weapon = slots[WieldLocation.TWO_HANDS] + if not weapon: + weapon = slots[WieldLocation.WEAPON_HAND] + return weapon + + def use(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. + + Args: + obj (EvAdventureObject): Thing to use. + + Raises: + EquipmentError: If there's no room in inventory. It will contains the details + of the error, suitable to echo to user. + + Notes: + If using an item already in the backpack, it should first be `removed` from the + backpack, before applying here - otherwise, it will be added a second time! + + this will cleanly move any 'colliding' items to the backpack to + make the use possible (such as moving sword + shield to backpack when wielding + a two-handed weapon). If wanting to warn the user about this, it needs to happen + before this call. + + """ + # first check if we have room for this + self.validate_slot_usage(obj) + + slots = self.slots + use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK) + + if use_slot is WieldLocation.TWO_HANDS: + # two-handed weapons can't co-exist with weapon/shield-hand used items + 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 + slots[WieldLocation.TWO_HANDS] = None + slots[use_slot] = obj + elif use_slot is WieldLocation.BACKPACK: + # backpack has multiple slots. + slots[use_slot].append(obj) + else: + # for others (body, head), just replace whatever's there + slots[use_slot] = obj + + # store new state + self._save() + + def store(self, obj): + """ + Put something in the backpack specifically (even if it could be wield/worn). + + """ + # check if we have room + self.validate_slot_usage(obj) + self.slots[WieldLocation.BACKPACK].append(obj) + self._save() + + def remove(self, obj_or_slot): + """ + Remove specific object or objects from a slot. + + Args: + obj_or_slot (EvAdventureObject or WieldLocation): The specific object or + location to empty. If this is WieldLocation.BACKPACK, all items + in the backpack will be emptied and returned! + Returns: + list: A list of 0, 1 or more objects emptied from the inventory. + + """ + slots = self.slots + ret = [] + if isinstance(obj_or_slot, WieldLocation): + ret = slots[obj_or_slot] + slots[obj_or_slot] = [] if obj_or_slot is WieldLocation.BACKPACK else None + elif obj_or_slot in self.obj.contents: + # object is in inventory, find out which slot and empty it + for slot, objslot in slots: + if slot is WieldLocation.BACKPACK: + try: + ret = objslot.remove(obj_or_slot) + break + except ValueError: + pass + elif objslot is obj_or_slot: + ret = objslot + slots[slot] = None + break + if ret: + self._save() + return ret + + +class LivingMixin: + """ + Helpers shared between all living things. + + """ @property def hurt_level(self): @@ -353,7 +263,7 @@ class EvAdventureCharacter(DefaultCharacter): def heal(self, hp, healer=None): """ - Heal the character by a certain amount of HP. + Heal by a certain amount of HP. """ damage = self.hp_max - self.hp @@ -365,6 +275,54 @@ class EvAdventureCharacter(DefaultCharacter): else: self.msg(f"|g{healer.key} heals you for {healed} health.|n") + +class EvAdventureCharacter(LivingMixin, DefaultCharacter): + """ + A Character for use with EvAdventure. This also works fine for + monsters and NPCS. + + """ + + # these are the ability bonuses. Defense is always 10 higher + strength = AttributeProperty(default=1) + dexterity = AttributeProperty(default=1) + constitution = AttributeProperty(default=1) + intelligence = AttributeProperty(default=1) + wisdom = AttributeProperty(default=1) + charisma = AttributeProperty(default=1) + + exploration_speed = AttributeProperty(default=120) + combat_speed = AttributeProperty(default=40) + + hp = AttributeProperty(default=4) + hp_max = AttributeProperty(default=4) + level = AttributeProperty(default=1) + xp = AttributeProperty(default=0) + + morale = AttributeProperty(default=9) # only used for NPC/monster morale checks + + @lazy_property + def equipment(self): + """Allows to access equipment like char.equipment.worn""" + return EquipmentHandler(self) + + @property + def weapon(self): + """ + Quick access to the character's currently wielded weapon. + + """ + self.equipment.weapon + + @property + def armor(self): + """ + Quick access to the character's current armor. + Will return the "Unarmored" armor level (11) if none other are found. + + """ + self.equipment.armor or 11 + def at_pre_object_receive(self, moved_object, source_location, **kwargs): """ Hook called by Evennia before moving an object here. Return False to abort move. @@ -414,7 +372,6 @@ class EvAdventureCharacter(DefaultCharacter): """ self.equipment.remove(moved_object) - def at_damage(self, dmg, attacker=None): """ Called when receiving damage for whatever reason. This @@ -440,7 +397,7 @@ class EvAdventureCharacter(DefaultCharacter): """ -class EvAdventureNPC(DefaultCharacter): +class EvAdventureNPC(LivingMixin, DefaultCharacter): """ This is the base class for all non-player entities, including monsters. These generally don't advance in level but uses a simplified, abstract measure of how @@ -452,19 +409,15 @@ class EvAdventureNPC(DefaultCharacter): Morale is set explicitly per-NPC, usually between 7 and 9. - Monsters don't use equipment in the way PCs do, instead their weapons and equipment - are baked into their HD (and/or dropped as loot when they go down). If you want monsters - or NPCs that can level and work the same as PCs, base them off the EvAdventureCharacter - class instead. + Monsters don't use equipment in the way PCs do, instead they have a fixed armor + value, and their Abilities are dynamically generated from the HD (hit_dice). - Unlike for a Character, we generate all the abilities dynamically based on HD. + If wanting monsters or NPCs that can level and work the same as PCs, base them off the + EvAdventureCharacter class instead. """ hit_dice = AttributeProperty(default=1) - # note: this is the armor bonus, 10 lower than the armor defence (what is usually - # referred to as ascending AC for many older D&D versions). So if AC is 14, this value - # should be 4. - armor = AttributeProperty(default=1) + armor = AttributeProperty(default=11) morale = AttributeProperty(default=9) hp = AttributeProperty(default=8) @@ -502,4 +455,3 @@ class EvAdventureNPC(DefaultCharacter): """ self.hp = self.hp_max - diff --git a/evennia/contrib/tutorials/evadventure/enums.py b/evennia/contrib/tutorials/evadventure/enums.py new file mode 100644 index 0000000000..1fbbf353b2 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/enums.py @@ -0,0 +1,60 @@ +""" +Enums are constants representing different things in EvAdventure. The advantage +of using an Enum over, say, a string is that if you make a typo using an unknown +enum, Python will give you an error while a typo in a string may go through silently. + +It's used as a direct reference: + + from enums import Ability + + if abi is Ability.STR: + # ... + +To get the `value` of an enum (must always be hashable, useful for Attribute lookups), use +`Ability.STR.value` (which would return 'strength' in our case). + +""" +from enum import Enum + +class Ability(Enum): + """ + The six base abilities (defense is always bonus + 10) + + """ + STR = "strength" + DEX = "dexterity" + CON = "constitution" + INT = "intelligence" + WIS = "wisdom" + CHA = "charisma" + + STR_DEFENSE = "strength_defense" + DEX_DEFENSE = "dexterity_defense" + CON_DEFENSE = "constitution_defense" + INT_DEFENSE = "intelligence_defense" + WIS_DEFENSE = "wisdom_defense" + CHA_DEFENSE = "charisma_defense" + + ARMOR = "armor" + HP = "hp" + EXPLORATION_SPEED = "exploration_speed" + COMBAT_SPEED = "combat_speed" + LEVEL = "level" + XP = "xp" + +class WieldLocation(Enum): + """ + Wield (or wear) locations. + + """ + # wield/wear location + BACKPACK = "backpack" + WEAPON_HAND = "weapon_hand" + SHIELD_HAND = "shield_hand" + TWO_HANDS = "two_handed_weapons" + BODY = "body" # armor + HEAD = "head" # helmets + + # combat-related + OPTIMAL_DISTANCE = "optimal_distance" + SUBOPTIMAL_DISTANCE = "suboptimal_distance" diff --git a/evennia/contrib/tutorials/evadventure/objects.py b/evennia/contrib/tutorials/evadventure/objects.py index fd9e8abcc0..499571d9a6 100644 --- a/evennia/contrib/tutorials/evadventure/objects.py +++ b/evennia/contrib/tutorials/evadventure/objects.py @@ -3,11 +3,16 @@ All items in the game inherit from a base object. The properties (what you can d with an object, such as wear, wield, eat, drink, kill etc) are all controlled by Tags. + + """ from evennia.objects.objects import DefaultObject from evennia.typeclasses.attributes import AttributeProperty +from .enums import WieldLocation, Ability + + class EvAdventureObject(DefaultObject): """ @@ -15,12 +20,13 @@ class EvAdventureObject(DefaultObject): """ # inventory management - wield_slot = AttributeProperty(default=None) - wear_slot = AttributeProperty(default=None) - inventory_slot_usage = AttributeProperty(default=1) + inventory_use_slot = AttributeProperty(default=WieldLocation.BACKPACK) + # how many inventory slots it uses (can be a fraction) + size = AttributeProperty(default=1) armor = AttributeProperty(default=0) # when 0, item is destroyed and is unusable quality = AttributeProperty(default=1) + value = AttributeProperty(default=0) class EvAdventureObjectFiller(EvAdventureObject): @@ -43,10 +49,10 @@ class EvAdventureWeapon(EvAdventureObject): Base weapon class for all EvAdventure weapons. """ - wield_slot = AttributeProperty(default="weapon") + inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND) - attack_type = AttributeProperty(default="strength") - defense_type = AttributeProperty(default="armor") + attack_type = AttributeProperty(default=Ability.STR) + defense_type = AttributeProperty(default=Ability.ARMOR) damage_roll = AttributeProperty(default="1d6") # at which ranges this weapon can be used. If not listed, unable to use diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index de2e0cb695..ef283e4551 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -2713,7 +2713,7 @@ def run_in_main_thread(function_or_method, *args, **kwargs): return threads.blockingCallFromThread(reactor, function_or_method, *args, **kwargs) -_INT2STR_MAP_NOUN = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", +_INT2STR_MAP_NOUN = {0: "no", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven", 12: "twelve"} _INT2STR_MAP_ADJ = {1: "1st", 2: "2nd", 3: "3rd"} # rest is Xth.