From 104f47860f2e57908010d226bf0b05fd8865eee2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 19 Jul 2022 00:58:31 +0200 Subject: [PATCH] Start adding dungeon logic --- .../contrib/tutorials/evadventure/dungeon.py | 262 ++++++++++++++ .../tutorials/evadventure/equipment.py | 335 ++++++++++++++++++ .../contrib/tutorials/evadventure/rules.py | 2 +- .../tutorials/evadventure/tests/test_rules.py | 50 +-- 4 files changed, 623 insertions(+), 26 deletions(-) create mode 100644 evennia/contrib/tutorials/evadventure/dungeon.py create mode 100644 evennia/contrib/tutorials/evadventure/equipment.py diff --git a/evennia/contrib/tutorials/evadventure/dungeon.py b/evennia/contrib/tutorials/evadventure/dungeon.py new file mode 100644 index 0000000000..d2926e6206 --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/dungeon.py @@ -0,0 +1,262 @@ +""" +Dungeon system + +This creates a procedurally generated dungeon. + +The dungone originates in an entrance room with exits that spawn a new dungeon connection every X +minutes. As long as characters go through the same exit within that time, they will all end up in +the same dungeon 'branch', otherwise they will go into separate, un-connected dungeon 'branches'. +They can always go back to the start room, but this will become a one-way exit back. + +When moving through the dungeon, a new room is not generated until characters +decided to go in that direction. Each room is tagged with the specific 'instance' +id of that particular branch of dungon. When no characters remain in the branch, +the branch is deleted. + +""" + +from datetime import datetime +from math import sqrt +from random import randint, random, shuffle + +from evennia import AttributeProperty, DefaultExit, DefaultScript +from evennia.utils import create +from evennia.utils.utils import inherits_from + +from .rooms import EvAdventureDungeonRoom + +# aliases for cardinal directions +_EXIT_ALIASES = { + "north": ("n",), + "east": ("w",), + "south": ("s",), + "west": ("w",), + "northeast": ("ne",), + "southeast": ("se",), + "southwest": ("sw",), + "northwest": ("nw",), +} +# finding the reverse cardinal direction +_EXIT_REVERSE_MAPPING = { + "north": "south", + "east": "west", + "south": "north", + "west": "east", + "northeast": "southwest", + "southeast": "northwest", + "southwest": "northeast", + "northwest": "southeast", +} + +# how xy coordinate shifts by going in direction +_EXIT_GRID_SHIFT = { + "north": (0, 1), + "east": (1, 0), + "south": (0, -1), + "west": (-1, 0), + "northeast": (1, 1), + "southeast": (1, -1), + "southwest": (-1, -1), + "northwest": (-1, 1), +} + + +# -------------------------------------------------- +# Dungeon orchestrator and rooms +# -------------------------------------------------- + + +class EvAdventureDungeonExit(DefaultExit): + """ + Dungeon exit. This will not create the target room until it's traversed. + It must be created referencing the dungeon_orchestrator it belongs to. + + """ + + dungeon_orchestrator = AttributeProperty(None, autocreate=False) + + def at_traverse(self, traversing_object, target_location, **kwargs): + """ + Called when traversing. `target_location` will be None if the + target was not yet created. + + """ + if not target_location: + self.destination = target_location = self.dungeon_orchestrator.new_room(self) + super().at_traverse(traversing_object, target_location, **kwargs) + + +class EvAdventureDungeonOrchestrator(DefaultScript): + """ + One script is created per dungeon 'branch' created. The orchestrator is + responsible for determining what is created next when a character enters an + exit within the dungeon. + + """ + + # this determines how branching the dungeon will be + max_unexplored_exits = 5 + max_new_exits_per_room = 3 + + rooms = AttributeProperty(list()) + n_unvisited_exits = AttributeProperty(list()) + highest_depth = AttributeProperty(0) + + # (x,y): room + xy_grid = AttributeProperty(dict()) + + def register_exit_traversed(self, exit): + """ + Tell the system the given exit was traversed. This allows us to track how many unvisited + paths we have so as to not have it grow exponentially. + + """ + if exit.id in self.unvisited_exits: + self.unvisited_exits.remove(exit.id) + + def create_out_exit(self, location, exit_direction="north"): + """ + Create outgoing exit from a room. The target room is not yet created. + + """ + out_exit, _ = EvAdventureDungeonExit.create( + key=exit_direction, location=location, aliases=_EXIT_ALIASES[exit_direction] + ) + self.unvisited_exits.append(out_exit.id) + + def _generate_room(self, depth, coords): + # TODO - determine what type of room to create here based on location and depth + room_typeclass = EvAdventureDungeonRoom + new_room = create.create_object( + room_typeclass, + key="Dungeon room", + tags=((self.key,),), + attributes=(("xy_coord", coords, "dungeon_xygrid"),), + ) + return new_room + + def new_room(self, from_exit): + """ + Create a new Dungeon room leading from the provided exit. + + """ + # figure out coordinate of old room and figure out what coord the + # new one would get + source_location = from_exit.location + x, y = source_location.get("xy_coord", category="dungeon_xygrid", default=(0, 0)) + dx, dy = _EXIT_GRID_SHIFT.get(from_exit.key, (1, 0)) + new_x, new_y = (x + dx, y + dy) + + # the dungeon's depth acts as a measure of the current difficulty level. This is the radial + # distance from the (0, 0) (the entrance). The Orchestrator also tracks the highest + # depth achieved. + depth = int(sqrt(new_x**2 + new_y**2)) + + new_room = self._generate_room(depth, (new_x, new_y)) + + self.xy_grid[(new_x, new_y)] = new_room + + # always make a return exit back to where we came from + back_exit_key = (_EXIT_REVERSE_MAPPING.get(from_exit.key, "back"),) + EvAdventureDungeonExit( + key=back_exit_key, + aliases=_EXIT_ALIASES.get(back_exit_key, ()), + location=new_room, + destination=from_exit.location, + attributes=(("desc", "A dark passage."),), + ) + + # figure out what other exits should be here, if any + n_unexplored = len(self.unvisited_exits) + if n_unexplored >= self.max_unexplored_exits: + # no more exits to open - this is a dead end. + return + else: + n_exits = randint(1, min(self.max_new_exits_per_room, n_unexplored)) + back_exit = from_exit.key + available_directions = [ + direction for direction in _EXIT_ALIASES if direction != back_exit + ] + # randomize order of exits + shuffle(available_directions) + for _ in range(n_exits): + while available_directions: + # get a random direction and check so there isn't a room already + # created in that direction + direction = available_directions.pop(0) + dx, dy = _EXIT_GRID_SHIFT(direction) + target_coord = (new_x + dx, new_y + dy) + if target_coord not in self.xy_grid: + # no room there - make an exit to it + self.create_out_exit(new_room, direction) + break + + self.highest_depth = max(self.highest_depth, depth) + + +# -------------------------------------------------- +# Start room +# -------------------------------------------------- + + +class EvAdventureStartRoomExit(DefaultExit): + """ + Traversing this exit will either lead to an existing dungeon branch or create + a new one. + + """ + + dungeon_orchestrator = AttributeProperty(None, autocreate=False) + + def reset_exit(self): + """ + Flush the exit, so next traversal creates a new dungeon branch. + + """ + self.dungeon_orchestrator = self.destination = None + + def at_traverse(self, traversing_object, target_location, **kwargs): + """ + When traversing create a new orchestrator if one is not already assigned. + + """ + if target_location is None or self.dungeon_orchestrator is None: + self.dungeon_orchestrator, _ = EvAdventureDungeonOrchestrator.create( + f"dungeon_orchestrator_{datetime.utcnow()}", + ) + target_location = self.destination = self.dungeon_orchestrator.new_room(self) + + super().at_traverse(traversing_object, target_location, **kwargs) + + +class EvAdventureStartRoomResetter(DefaultScript): + """ + Simple ticker-script. Introduces a chance of the room's exits cycling every interval. + + """ + + def at_repeat(self): + """ + Called every time the script repeats. + + """ + room = self.obj + for exi in room.exits: + if inherits_from(exi, EvAdventureStartRoomExit) and random() < 0.5: + exi.reset_exit() + + +class EvAdventureDungeonRoomStart(EvAdventureDungeonRoom): + """ + Exits leading out of the start room, (except one leading outside) will lead to a different + dungeon-branch, and after a certain time, the given exit will instead spawn a new branch. This + room is responsible for cycling these exits regularly. + + The actual exits should be created in the build script. + + """ + + recycle_time = 5 * 60 # seconds + + def at_object_creation(self): + self.scripts.add(EvAdventureStartRoomResetter, interval=self.recycle_time, autostart=True) diff --git a/evennia/contrib/tutorials/evadventure/equipment.py b/evennia/contrib/tutorials/evadventure/equipment.py new file mode 100644 index 0000000000..57438864fc --- /dev/null +++ b/evennia/contrib/tutorials/evadventure/equipment.py @@ -0,0 +1,335 @@ +""" +Knave has a system of Slots for its inventory. + +""" + +from .enums import Ability, WieldLocation +from .objects import WeaponEmptyHand + + +class EquipmentError(TypeError): + pass + + +class EquipmentHandler: + """ + _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 and armor) 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 clean them. + + """ + + save_attribute = "inventory_slots" + + def __init__(self, obj): + self.obj = obj + self._load() + + def _load(self): + """ + Load or create a new slot storage. + + """ + 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: [], + }, + ) + + def _save(self): + """ + Save slot to storage. + + """ + self.obj.attributes.add(self.save_attribute, self.slots, category="inventory") + + def _count_slots(self): + """ + Count slot usage. This is fetched from the .size Attribute of the + object. The size can also be partial slots. + + """ + 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 + + @property + def max_slots(self): + """ + The max amount of equipment slots ('carrying capacity') is based on + the constitution defense. + + """ + return getattr(self.obj, Ability.CON.value, 1) + 10 + + def validate_slot_usage(self, obj): + """ + Check if obj can fit in equipment, based on its size. + + Args: + obj (EvAdventureObject): The object to add. + + Raise: + EquipmentError: If there's not enough room. + + """ + 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}))." + ) + return True + + @property + def armor(self): + """ + 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. Note that this is the +bonus of Armor, not the + 'defense' (to get that one adds 10). + + """ + slots = self.slots + return sum( + ( + # armor is listed using its defense, so we remove 10 from it + # (11 is base no-armor value in Knave) + getattr(slots[WieldLocation.BODY], "armor", 11) - 10, + # shields and helmets are listed by their bonus to armor + getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0), + getattr(slots[WieldLocation.HEAD], "armor", 0), + ) + ) + + @property + def weapon(self): + """ + Conveniently get the currently active weapon or rune stone. + + 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] + if not weapon: + weapon = WeaponEmptyHand() + return weapon + + def display_loadout(self): + """ + Get a visual representation of your current loadout. + + Returns: + str: The current loadout. + + """ + slots = self.slots + weapon_str = "You are fighting with your bare fists" + shield_str = " and have no shield." + armor_str = "You wear no armor" + helmet_str = " and no helmet." + + two_hands = slots[WieldLocation.TWO_HANDS] + if two_hands: + weapon_str = f"You wield {two_hands} with both hands" + shield_str = " (you can't hold a shield at the same time)." + else: + one_hands = slots[WieldLocation.WEAPON_HAND] + if one_hands: + weapon_str = f"You are wielding {one_hands} in one hand." + shield = slots[WieldLocation.SHIELD_HAND] + if shield: + shield_str = f"You have {shield} in your off hand." + + armor = slots[WieldLocation.BODY] + if armor: + armor_str = f"You are wearing {armor}" + + helmet = slots[WieldLocation.BODY] + if helmet: + helmet_str = f" and {helmet} on your head." + + return f"{weapon_str}{shield_str}\n{armor_str}{helmet_str}" + + 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 add(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 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. + + 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): + if obj_or_slot is WieldLocation.BACKPACK: + # empty entire backpack + ret.extend(slots[obj_or_slot]) + slots[obj_or_slot] = [] + else: + ret.append(slots[obj_or_slot]) + slots[obj_or_slot] = None + elif obj_or_slot in self.slots.values(): + # obj in use/wear slot + for slot, objslot in slots.items(): + if objslot is obj_or_slot: + slots[slot] = None + ret.append(objslot) + elif obj_or_slot in slots[WieldLocation.BACKPACK]: + # obj in backpack slot + try: + slots[WieldLocation.BACKPACK].remove(obj_or_slot) + ret.append(obj_or_slot) + except ValueError: + pass + if ret: + self._save() + return ret + + def get_wieldable_objects_from_backpack(self): + """ + Get all wieldable weapons (or spell runes) from backpack. This is useful in order to + have a list to select from when swapping your wielded loadout. + + Returns: + list: A list of objects with a suitable `inventory_use_slot`. We don't check + quality, so this may include broken items (we may want to visually show them + in the list after all). + + """ + return [ + obj + for obj in self.slots[WieldLocation.BACKPACK] + if obj.inventory_use_slot + in (WieldLocation.WEAPON_HAND, WieldLocation.TWO_HANDS, WieldLocation.SHIELD_HAND) + ] + + def get_wearable_objects_from_backpack(self): + """ + Get all wearable items (armor or helmets) from backpack. This is useful in order to + have a list to select from when swapping your worn loadout. + + Returns: + list: A list of objects with a suitable `inventory_use_slot`. We don't check + quality, so this may include broken items (we may want to visually show them + in the list after all). + + """ + return [ + obj + for obj in self.slots[WieldLocation.BACKPACK] + if obj.inventory_use_slot in (WieldLocation.BODY, WieldLocation.HEAD) + ] + + def get_usable_objects_from_backpack(self): + """ + Get all 'usable' items (like potions) from backpack. This is useful for getting a + list to select from. + + Returns: + list: A list of objects that are usable. + + """ + character = self.obj + return [obj for obj in self.slots[WieldLocation.BACKPACK] if obj.at_pre_use(character)] diff --git a/evennia/contrib/tutorials/evadventure/rules.py b/evennia/contrib/tutorials/evadventure/rules.py index 5a83ce359e..391bc1e939 100644 --- a/evennia/contrib/tutorials/evadventure/rules.py +++ b/evennia/contrib/tutorials/evadventure/rules.py @@ -532,7 +532,7 @@ class EvAdventureCharacterGeneration: for item in self.backpack: # TODO create here - character.equipment.store(item) + character.equipment.add(item) # character improvement diff --git a/evennia/contrib/tutorials/evadventure/tests/test_rules.py b/evennia/contrib/tutorials/evadventure/tests/test_rules.py index 6db9781444..c7f5fcaf12 100644 --- a/evennia/contrib/tutorials/evadventure/tests/test_rules.py +++ b/evennia/contrib/tutorials/evadventure/tests/test_rules.py @@ -3,15 +3,14 @@ Test the rules and chargen. """ -from unittest.mock import patch, MagicMock, call -from parameterized import parameterized -from evennia.utils.test_resources import BaseEvenniaTest +from unittest.mock import MagicMock, call, patch +from anything import Something +from evennia.utils.test_resources import BaseEvenniaTest +from parameterized import parameterized + +from .. import characters, enums, equipment, random_tables, rules from .mixins import EvAdventureMixin -from .. import rules -from .. import enums -from .. import random_tables -from .. import characters class EvAdventureRollEngineTest(BaseEvenniaTest): @@ -86,23 +85,24 @@ class EvAdventureRollEngineTest(BaseEvenniaTest): character.dexterity = 1 self.assertEqual( - self.roll_engine.saving_throw(character, bonus_type=enums.Ability.STR), (False, None) + self.roll_engine.saving_throw(character, bonus_type=enums.Ability.STR), + (False, None, Something), ) self.assertEqual( self.roll_engine.saving_throw(character, bonus_type=enums.Ability.DEX, modifier=1), - (False, None), + (False, None, Something), ) self.assertEqual( self.roll_engine.saving_throw( character, advantage=True, bonus_type=enums.Ability.DEX, modifier=6 ), - (False, None), + (False, None, Something), ) self.assertEqual( self.roll_engine.saving_throw( character, disadvantage=True, bonus_type=enums.Ability.DEX, modifier=7 ), - (True, None), + (True, None, Something), ) mock_randint.return_value = 1 @@ -110,7 +110,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest): self.roll_engine.saving_throw( character, disadvantage=True, bonus_type=enums.Ability.STR, modifier=2 ), - (False, enums.Ability.CRITICAL_FAILURE), + (False, enums.Ability.CRITICAL_FAILURE, Something), ) mock_randint.return_value = 20 @@ -118,7 +118,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest): self.roll_engine.saving_throw( character, disadvantage=True, bonus_type=enums.Ability.STR, modifier=2 ), - (True, enums.Ability.CRITICAL_SUCCESS), + (True, enums.Ability.CRITICAL_SUCCESS, Something), ) @patch("evennia.contrib.tutorials.evadventure.rules.randint") @@ -133,7 +133,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest): self.roll_engine.opposed_saving_throw( attacker, defender, attack_type=enums.Ability.STR, defense_type=enums.Ability.ARMOR ), - (False, None), + (False, None, Something), ) self.assertEqual( self.roll_engine.opposed_saving_throw( @@ -143,7 +143,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest): defense_type=enums.Ability.ARMOR, modifier=2, ), - (True, None), + (True, None, Something), ) @patch("evennia.contrib.tutorials.evadventure.rules.randint") @@ -224,7 +224,7 @@ class EvAdventureRollEngineTest(BaseEvenniaTest): # death mock_randint.return_value = 1 self.roll_engine.roll_death(character) - character.handle_death.assert_called() + character.at_death.assert_called() # strength loss mock_randint.return_value = 3 self.roll_engine.roll_death(character) @@ -310,7 +310,7 @@ class EvAdventureCharacterGenerationTest(BaseEvenniaTest): self.assertTrue(character.db.desc.startswith("Herbalist")) self.assertEqual(character.armor, "gambeson") - character.equipment.store.assert_called() + character.equipment.add.assert_called() class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): @@ -350,7 +350,7 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): if is_ok: self.assertTrue(self.character.equipment.validate_slot_usage(obj)) else: - with self.assertRaises(characters.EquipmentError): + with self.assertRaises(equipment.EquipmentError): self.character.equipment.validate_slot_usage(obj) @parameterized.expand( @@ -375,8 +375,8 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): else: self.assertEqual(self.character.equipment.slots[where], obj) - def test_store(self): - self.character.equipment.store(self.weapon) + def test_add(self): + self.character.equipment.add(self.weapon) self.assertEqual(self.character.equipment.slots[enums.WieldLocation.WEAPON_HAND], None) self.assertTrue(self.weapon in self.character.equipment.slots[enums.WieldLocation.BACKPACK]) @@ -408,7 +408,7 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): def test_remove__with_obj(self): self.character.equipment.use(self.shield) self.character.equipment.use(self.item) - self.character.equipment.store(self.weapon) + self.character.equipment.add(self.weapon) self.assertEqual( self.character.equipment.slots[enums.WieldLocation.SHIELD_HAND], self.shield @@ -428,7 +428,7 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): def test_remove__with_slot(self): self.character.equipment.use(self.shield) self.character.equipment.use(self.item) - self.character.equipment.store(self.helmet) + self.character.equipment.add(self.helmet) self.assertEqual( self.character.equipment.slots[enums.WieldLocation.SHIELD_HAND], self.shield @@ -449,11 +449,11 @@ class EvAdventureEquipmentTest(EvAdventureMixin, BaseEvenniaTest): def test_properties(self): self.character.equipment.use(self.armor) - self.assertEqual(self.character.equipment.armor, 11) + self.assertEqual(self.character.equipment.armor, 1) self.character.equipment.use(self.shield) - self.assertEqual(self.character.equipment.armor, 12) + self.assertEqual(self.character.equipment.armor, 2) self.character.equipment.use(self.helmet) - self.assertEqual(self.character.equipment.armor, 13) + self.assertEqual(self.character.equipment.armor, 3) self.character.equipment.use(self.weapon) self.assertEqual(self.character.equipment.weapon, self.weapon)