Source code for DnD_5e.combatant

import inspect
import logging
from copy import copy, deepcopy
from typing import Optional, Union
from DnD_5e import armor, armory, attack_class, weapons
from DnD_5e.tactics import combatant_tactics, attack_tactics
from DnD_5e.utility_methods_dnd import ability_to_mod, roll_dice, TYPE_ROLL_RESULT, NullLogger


[docs] class Combatant: """ This class represents anything with a stat block that can fight """ def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param name: what *self* is called. A unique name is recommended but not required :type name: str :param logger: for logging :type logger: Logger :param features: names of features *self* has. Mostly used by subclasses. :type features: set of strings :param feature_classes: :py:class:`Feature` s *self* has. :type feature_classes: list, tuple, or set of :py:class:`Feature` s :param vulnerabilities: all the damage types that *self* is vulnerable to :type vulnerabilities: set, list, or tuple of strings (will be converted to set of strings) :param resistances: all the damage types that *self* is resistant to :type resistances: set, list, or tuple of strings (will be converted to set of strings) :param immunities: all the damage types that *self* is immune to :type immunities: set, list, or tuple of strings (will be converted to set of strings) :param strength: strength score. Will be converted to modifier and stored as such. :type strength: integer between 1 and 30 (inclusive) :param strength_mod: dexterity modifier :type strength_mod: int :param dexterity: dexterity score. Will be converted to modifier and stored as such. :type dexterity: integer between 1 and 30 (inclusive) :param dexterity_mod: dexterity modifier :type dexterity_mod: int :param constitution: constitution score. Will be converted to modifier and stored as such. :type constitution: integer between 1 and 30 (inclusive) :param constitution_mod: constitution modifier :type constitution_mod: int :param intelligence: intelligence score. Will be converted to modifier and stored as such. :type intelligence: integer between 1 and 30 (inclusive) :param intelligence_mod: intelligence modifier :type intelligence_mod: int :param wisdom: wisdom score. Will be converted to modifier and stored as such. :type wisdom: integer between 1 and 30 (inclusive) :param wisdom_mod: wisdom modifier :type wisdom_mod: int :param charisma: charisma score. Will be converted to modifier and stored as such. :type charisma: integer between 1 and 30 (inclusive) :param charisma_mod: charisma modifier :type charisma_mod: int :param proficiencies: saving throws, weapons, etc. Everything *self* is proficient in :type proficiencies: set, list, or tuple of strings (will be converted to set of strings) :param proficiency_mod: proficiency bonus :type proficiency_mod: positive integer :param expertise: Everything *self* has expertise (double proficiency) in :type expertise: set, list, or tuple of strings (will be converted to set of strings) :param ac: *self's* armor class :type ac: positive integer :param armor: *self's* armor (overrides ac parameter) :type armor: :py:class:`Armor` :param max_hp: max hit points :type max_hp: positive integer :param temp_hp: temporary hit points :type temp_hp: non-negative integer :param hit_dice: hit dice :type hit_dice: see *utility_methods_dnd.validate_dice* :param conditions: all conditions currently affecting *self* :type conditions: list of strings :param current_hp: current hit points :type current_hp: non-negative integer :param speed: *self's* base speed (i.e., normal walking speed) :type speed: non-negative integer :param vision: what kind of sight *self* has (not affected by the blindness condition) :type vision: one of these strings: "normal", "darkvision", "blindsight", "truesight" :param attacks: list of :py:class:`Attack` s that *self* can use :param weapons: Weapons (see weapons module) that *self* has available to use :type weapons: list of :py:class:`Weapon` s :param main_hand: the weapon held in main hand :type main_hand: :py:class:`Weapon` :param off_hand: the weapon or shield held in the off (i.e., not main) hand :type off_hand: :py:class:`Weapon` or :py:class:`Shield` :param size: size :type size: one of these strings: "tiny", "small", "medium", "large", "huge", "gargantuan" :param team: the team *self* belongs to. None by default. (Input not sanitized) :type team: :py:class:`Team` (or None) :param enemy_tactic: how to select a :py:class:`Combatant` to attack :type enemy_tactic: :py:class:`CombatantTactic` :param heal_tactic: how to select a :py:class:`Combatant` to heal :type heal_tactic: :py:class:`CombatantTactic` :param items: NOT IMPLEMENTED YET :raise: ValueError if input is invalid """ self._name = kwargs.get('name') if not self._name: raise ValueError("Must provide a name") self._logger = kwargs.get("logger", logging.getLogger("DnD_5e.combatant")) self._damage_dealt = 0 self._damage_taken = 0 self._times_gone_unconscious = 0 self._features = kwargs.get('features') if isinstance(self._features, (list, tuple, set)): self._features = set(self._features) elif self._features is None: self._features = set() else: self.get_logger().error("Features must be a set (or a list or tuple to convert to a set)", stack_info=True) raise ValueError("Features must be a set (or a list or tuple to convert to a set)") self._feature_dict = {} self._feature_classes = kwargs.get('feature_classes') # TODO: change this to self._features if isinstance(self._feature_classes, (list, tuple, set)): self._feature_classes = set(self._feature_classes) elif self._feature_classes is None: self._feature_classes = set() else: self.get_logger().error("feature_classes must be a set (or a list or tuple to convert to a set)", stack_info=True) raise ValueError("feature_classes must be a set (or a list or tuple to convert to a set)") for feature in self._feature_classes: try: for method_tup in feature.get_ol_methods(): # check that this feature actually affects a method that we have getattr(self, method_tup[0]) self._feature_dict[method_tup[0]] = method_tup[1] except AttributeError: self.get_logger().warning("%s Tried to include an invalid Feature in feature_classes " "(either it is not a Feature or it uses a method Combatant doesn't have)", self.get_name()) vulnerabilities = kwargs.get("vulnerabilities") if isinstance(vulnerabilities, (list, set)): vulnerabilities = set(vulnerabilities) elif vulnerabilities is None: vulnerabilities = set() else: self.get_logger().error("Vulnerabilities must be a set (or a list to convert to a set)", stack_info=True) raise ValueError("Vulnerabilities must be a set (or a list to convert to a set)") self._vulnerabilities = vulnerabilities resistances = kwargs.get("resistances") if isinstance(resistances, (list, set)): resistances = set(resistances) elif resistances is None: resistances = set() else: self.get_logger().error("Resistances must be a set (or a list to convert to a set)", stack_info=True) raise ValueError("Resistances must be a set (or a list to convert to a set)") if not resistances.isdisjoint(self._vulnerabilities): self.get_logger().error("Cannot have duplicate items across vulnerabilities and resistances", stack_info=True) raise ValueError("Cannot have duplicate items across vulnerabilities and resistances") self._resistances = resistances immunities = kwargs.get("immunities") if isinstance(immunities, (list, set)): immunities = set(immunities) elif immunities is None: immunities = set() else: self.get_logger().error("Immunities must be a set (or a list to convert to a set)", stack_info=True) raise ValueError("Immunities must be a set (or a list to convert to a set)") if not immunities.isdisjoint(self._vulnerabilities): self.get_logger().error("Cannot have duplicate items across vulnerabilities and immunities", stack_info=True) raise ValueError("Cannot have duplicate items across vulnerabilities and immunities") if not immunities.isdisjoint(self._resistances): self.get_logger().error("Cannot have duplicate items across resistances and immunities", stack_info=True) raise ValueError("Cannot have duplicate items across resistances and immunities") self._immunities = immunities strength = kwargs.get('strength') if not strength: strength_mod = kwargs.get("strength_mod") # modifiers also accepted if strength_mod is not None: if isinstance(strength_mod, int): self._strength = strength_mod else: self.get_logger().error("Strength mod must be an integer", stack_info=True) raise ValueError("Strength mod must be an integer") else: self.get_logger().error("Must provide strength score or modifier", stack_info=True) raise ValueError("Must provide strength score or modifier") else: self._strength = ability_to_mod(strength) dexterity = kwargs.get('dexterity') if not dexterity: dexterity_mod = kwargs.get("dexterity_mod") if dexterity_mod is not None: if isinstance(dexterity_mod, int): self._dexterity = dexterity_mod else: self.get_logger().error("Dexterity mod must be an integer", stack_info=True) raise ValueError("Dexterity mod must be an integer") else: self.get_logger().error("Must provide dexterity score or modifier", stack_info=True) raise ValueError("Must provide dexterity score or modifier") else: self._dexterity = ability_to_mod(dexterity) constitution = kwargs.get('constitution') if not constitution: constitution_mod = kwargs.get("constitution_mod") if constitution_mod is not None: if isinstance(constitution_mod, int): self._constitution = constitution_mod else: self.get_logger().error("Constitution mod must be an integer", stack_info=True) raise ValueError("Constitution mod must be an integer") else: self.get_logger().error("Must provide constitution score or modifier", stack_info=True) raise ValueError("Must provide constitution score or modifier") else: self._constitution = ability_to_mod(constitution) intelligence = kwargs.get('intelligence') if not intelligence: intelligence_mod = kwargs.get("intelligence_mod") if intelligence_mod is not None: if isinstance(intelligence_mod, int): self._intelligence = intelligence_mod else: self.get_logger().error("Intelligence mod must be an integer", stack_info=True) raise ValueError("Intelligence mod must be an integer") else: self.get_logger().error("Must provide intelligence score or modifier", stack_info=True) raise ValueError("Must provide intelligence score or modifier") else: self._intelligence = ability_to_mod(intelligence) wisdom = kwargs.get('wisdom') if not wisdom: wisdom_mod = kwargs.get("wisdom_mod") if wisdom_mod is not None: if isinstance(wisdom_mod, int): self._wisdom = wisdom_mod else: self.get_logger().error("Wisdom mod must be an integer", stack_info=True) raise ValueError("Wisdom mod must be an integer") else: self.get_logger().error("Must provide wisdom score or modifier", stack_info=True) raise ValueError("Must provide wisdom score or modifier") else: self._wisdom = ability_to_mod(wisdom) charisma = kwargs.get('charisma') if not charisma: charisma_mod = kwargs.get("charisma_mod") if charisma_mod is not None: if isinstance(charisma_mod, int): self._charisma = charisma_mod else: self.get_logger().error("Charisma mod must be an integer", stack_info=True) raise ValueError("Charisma mod must be an integer") else: self.get_logger().error("Must provide charisma score or modifier", stack_info=True) raise ValueError("Must provide charisma score or modifier") else: self._charisma = ability_to_mod(charisma) self._proficiencies = kwargs.get('proficiencies', []) if isinstance(self._proficiencies, (list, tuple, set)): self._proficiencies = set(self._proficiencies) else: self.get_logger().error("Proficiencies must be provided as a set, list, or tuple", stack_info=True) raise ValueError("Proficiencies must be provided as a set, list, or tuple") self._expertise = kwargs.get('expertise', []) if isinstance(self._expertise, (list, tuple, set)): self._expertise = set(self._expertise) else: self.get_logger().error("Expertise must be provided as a set, list, or tuple", stack_info=True) raise ValueError("Expertise must be provided as a set, list, or tuple") self._proficiency_mod = kwargs.get("proficiency_mod", 0) if not isinstance(self._proficiency_mod, int) or self._proficiency_mod < 0: self.get_logger().error("Proficiency mod must be non-negative", stack_info=True) raise ValueError("Proficiency mod must be non-negative") self._saving_throws = {"strength": self._strength, "dexterity": self._dexterity, "constitution": self._constitution, "intelligence": self._intelligence, "wisdom": self._wisdom, "charisma": self._charisma, "death": 0} # death is for death saving throws for ability in self._saving_throws: if ability in self._proficiencies: self._saving_throws[ability] += self._proficiency_mod self._ac = kwargs.get('ac') self._armor = kwargs.get('armor') if self._armor: try: self._ac = self._armor.get_total_ac(self) except AttributeError: self.get_logger().error("armor must be of class Armor", stack_info=True) raise ValueError("armor must be of class Armor") if self._ac is None: self._ac = self.get_unarmored_ac() elif not isinstance(self._ac, int) or self._ac < 1: self.get_logger().error("Must provide ac as a positive integer, provide armor, " "or provide no ac/armor (unarmored)", stack_info=True) raise ValueError("Must provide ac as a positive integer, provide armor, or provide no ac/armor (unarmored)") self._max_hp = kwargs.get('max_hp') if not self._max_hp or not isinstance(self._max_hp, int) or self._max_hp <= 0: self.get_logger().error("Must provide positive max hp", stack_info=True) raise ValueError("Must provide positive max hp") self._temp_hp = kwargs.get('temp_hp', 0) if not isinstance(self._temp_hp, int) or self._temp_hp < 0: self.get_logger().error("Temp hp must be a non-negative integer", stack_info=True) raise ValueError("Temp hp must be a non-negative integer") self._conditions = kwargs.get('conditions', []) # set this first in case current hp makes character unconscious if not isinstance(self._conditions, list): self.get_logger().error("If conditions provided, must be a list", stack_info=True) raise ValueError("If conditions provided, must be a list") self._current_hp = kwargs.get('current_hp', self._max_hp) # by default, start with max hp if not isinstance(self._current_hp, int): self.get_logger().error("Must provide non-negative integer for current hp", stack_info=True) raise ValueError("Must provide non-negative integer for current hp") if self._current_hp <= 0: self.get_logger().warning("Combatant created with 0 or less hp. Going unconscious (and setting hp to 0).", stack_info=True) self.become_unconscious() if self._current_hp > self._max_hp: self.get_logger().error("Current hp cannot be greater than max hp. Use temp hp if needed.", stack_info=True) raise ValueError("Current hp cannot be greater than max hp. Use temp hp if needed.") self._speed = kwargs.get('speed', 25) if not isinstance(self._speed, int) or self._speed < 0: self.get_logger().error("Speed must be a non-negative integer", stack_info=True) raise ValueError("Speed must be a non-negative integer") self._climb_speed = kwargs.get("climb_speed", 0) if not isinstance(self._climb_speed, int) or self._speed < 0: self.get_logger().error("Climb speed must be a non-negative integer", stack_info=True) raise ValueError("Climb speed must be a non-negative integer") self._fly_speed = kwargs.get("fly_speed", 0) if not isinstance(self._fly_speed, int) or self._speed < 0: self.get_logger().error("Fly speed must be a non-negative integer", stack_info=True) raise ValueError("Fly speed must be a non-negative integer") self._swim_speed = kwargs.get("swim_speed", 0) if not isinstance(self._swim_speed, int) or self._speed < 0: self.get_logger().error("Swim speed must be a non-negative integer", stack_info=True) raise ValueError("Swim speed must be a non-negative integer") self._vision = kwargs.get('vision', 'normal') if self._vision not in ['normal', 'darkvision', 'blindsight', 'truesight']: self.get_logger().warning("%s not recognized as a valid vision. " "Setting vision to normal.", self._vision, stack_info=True) self._adv_to_be_hit = 0 self._attacks = [] for attack in kwargs.get("attacks", []): self.add_attack(attack) self._weapons = [] the_weapons = kwargs.get('weapons', []) if not isinstance(the_weapons, (list, tuple)): self.get_logger().error("weapons must be a list or tuple of Weapons", stack_info=True) raise ValueError("weapons must be a list or tuple of Weapons") for weapon in the_weapons: self.add_weapon(weapon) # TODO: extract main hand and off hand into "equip" functions? self._main_hand = kwargs.get("main_hand") if self._main_hand is not None and not isinstance(self._main_hand, weapons.Weapon): self.get_logger().error("main_hand must be None or a Weapon", stack_info=True) raise ValueError("main_hand must be None or a Weapon") # TODO: don't allow dual wielding when main hand has a two-handed weapon self._off_hand = kwargs.get("off_hand") if isinstance(self._off_hand, weapons.Weapon): if self._off_hand.has_prop("two_handed"): self.get_logger().error("off_hand must be a one-handed or versatile weapon, not two-handed", stack_info=True) raise ValueError("off_hand must be a one-handed or versatile weapon, not two-handed") if self._main_hand: if not isinstance(self._main_hand, armory.MeleeWeapon) or not isinstance(self._off_hand, armory.MeleeWeapon): self.get_logger().error("Both weapons for dual wielding must be melee", stack_info=True) raise ValueError("Both weapons for dual wielding must be melee") if not self._main_hand.has_prop("light") or not self._off_hand.has_prop("light"): self.get_logger().error("Both weapons for dual wielding must have the light property", stack_info=True) raise ValueError("Both weapons for dual wielding must have the light property") elif inspect.isclass(self._off_hand) and issubclass(self._off_hand, armor.Shield): self.set_ac(self.get_ac() + self._off_hand.get_ac()) elif self._off_hand is not None: self.get_logger().error("off_hand must be None or a Weapon or a Shield", stack_info=True) raise ValueError("off_hand must be None or a Weapon or a Shield") self._size = kwargs.get("size", "medium") if self._size not in ["tiny", "small", "medium", "large", "huge", "gargantuan"]: self.get_logger().error("Size should be tiny, small, medium, large, huge, or gargantuan", stack_info=True) raise ValueError("Size should be tiny, small, medium, large, huge, or gargantuan") self._team = kwargs.get("team") self._enemy_tactic = kwargs.get("enemy_tactic", combatant_tactics.IsConsciousTactic()) self._heal_tactic = kwargs.get("heal_tactic", combatant_tactics.IsUnconsciousTactic()) self._attack_tactic = kwargs.get("attack_tactic", attack_tactics.DprMaxTactic()) self._items = kwargs.get('items', []) def __eq__(self, other) -> bool: """ Compare *self* and *other* to determine if they are equal based on the following characteristics: :py:attr:`hit_dice`, :py:attr:`max_hp`, ability scores, :py:attr:`proficiencies`, :py:attr:`features`, :py:attr:`fighting_styles` :param other: the Combatant to be compared :type other: :py:class:`Combatant` :return: True if *self* equals *other*, False otherwise :rtype: bool """ if other is self: return True if type(other) != type(self): # pylint: disable=unidiomatic-typecheck return False return self.get_ac() == other.get_ac() \ and self.get_max_hp() == other.get_max_hp() \ and self.get_strength() == other.get_strength() \ and self.get_dexterity() == other.get_dexterity() \ and self.get_constitution() == other.get_constitution() \ and self.get_intelligence() == other.get_intelligence() \ and self.get_wisdom() == other.get_wisdom() \ and self.get_charisma() == other.get_charisma() \ and self.get_proficiencies() == other.get_proficiencies() \ and self.get_expertise() == other.get_expertise() \ and self.get_features() == other.get_features() \ and self.get_feature_classes() == other.get_feature_classes() \ and self.get_fighting_styles() == other.get_fighting_styles()
[docs] def current_eq(self, other) -> bool: """ Check to see if *self* is identical to *other* by looking at everything in *equals* as well as the following attributes: :py:attr:`armor`, :py:attr:`current_hp`, :py:attr:`temp_hp`, :py:attr:`speed`, :py:attr:`conditions`, :py:attr:`vision`, :py:attr:`vulnerabilities`, :py:attr:`resistances`, :py:attr:`immunities`, :py:attr:`adv_to_be_hit`, py:attr:`weapons` (don't have to be identical, just equal), :py:attr:`attacks`, :py:attr:`items`, :py:attr:`size`, and :py:attr:`team` :param other: the Combatant to be compared :type other: :py:class:`Combatant` :return: True if *self* is identical to *other*, False otherwise :rtype: bool """ return self == other \ and self.get_armor() == other.get_armor() \ and self.get_current_hp() == other.get_current_hp() \ and self.get_temp_hp() == other.get_temp_hp() \ and self.get_speed() == other.get_speed() \ and self.get_climb_speed() == other.get_climb_speed() \ and self.get_fly_speed() == other.get_fly_speed() \ and self.get_swim_speed() == other.get_swim_speed() \ and self.get_conditions() == other.get_conditions() \ and self.get_vision() == other.get_vision() \ and self.get_vulnerabilities() == other.get_vulnerabilities() \ and self.get_resistances() == other.get_resistances() \ and self.get_immunities() == other.get_immunities() \ and self.get_adv_to_be_hit() == other.get_adv_to_be_hit() \ and weapons.weapon_list_equals(self.get_weapons(), other.get_weapons()) \ and self.get_attacks() == other.get_attacks() \ and self.get_items() == other.get_items() \ and self.get_size() == other.get_size()
def __str__(self): """ String representation of a Combatant is just the name :return: name :rtype: str """ return self.get_name()
[docs] def get_ac(self) -> int: """ :return: armor class :rtype: positive integer """ ac = self._ac if self.has_feature_method("get_ac"): ac += self.get_feature_dict()["get_ac"](self) return ac
[docs] def get_unarmored_ac(self) -> int: """ Get unarmored AC. Can be overridden with a Feature. :return: ac when not wearing armor :rtype: positive integer """ if "get_unarmored_ac" in self.get_feature_dict(): # call the overloaded function return self.get_feature_dict()["get_unarmored_ac"](self) return 10 + self.get_dexterity()
[docs] def get_armor(self) -> Optional[armor.Armor]: """ :return: armor :rtype: :py:class:`Armor` """ return self._armor
[docs] def get_max_hp(self) -> int: """ :return: max hit points :rtype: positive integer """ return self._max_hp
[docs] def get_temp_hp(self) -> int: """ :return: temporary hit points :rtype: non-negative integer """ return self._temp_hp
[docs] def get_current_hp(self) -> int: """ :return: current hit points :rtype: non-negative integer """ return self._current_hp
[docs] def is_bloodied(self) -> bool: """ Tell whether *self* is bloodied (current hit points at or below half of maximum) :return: True if *self* is bloodied, False otherwise :rtype: bool """ return self.get_current_hp() <= self.get_max_hp() // 2
[docs] def is_hp_max(self) -> bool: """ :return: True if current hp equals max hp, False otherwise :rtype: bool """ return self.get_current_hp() == self.get_max_hp()
[docs] def get_hp_to_max(self) -> int: """ Get the number of hit points needed to bring :py:attr:`current_hp` to :py:attr:`max_hp` :return: difference between max hp and current hp :rtype: non-negative integer """ return self.get_max_hp() - self.get_current_hp()
[docs] def get_speed(self) -> int: """ :return: speed :rtype: non-negative integer """ if "get_speed" in self.get_feature_dict(): # call the overloaded function return self.get_feature_dict()["get_speed"](self) return self._speed
[docs] def get_climb_speed(self) -> int: """ :return: speed :rtype: non-negative integer """ if "get_climb_speed" in self.get_feature_dict(): # call the overloaded function return self.get_feature_dict()["get_climb_speed"](self) return self._climb_speed
[docs] def get_fly_speed(self) -> int: """ :return: speed :rtype: non-negative integer """ if "get_fly_speed" in self.get_feature_dict(): # call the overloaded function return self.get_feature_dict()["get_fly_speed"](self) return self._fly_speed
[docs] def get_swim_speed(self) -> int: """ :return: speed :rtype: non-negative integer """ if "get_swim_speed" in self.get_feature_dict(): # call the overloaded function return self.get_feature_dict()["get_swim_speed"](self) return self._swim_speed
[docs] def get_conditions(self) -> set: """ :return: conditions :rtype: set of strings """ return self._conditions
[docs] def has_condition(self, condition: str) -> bool: """ Check *self._conditions* to see if *self* has the given condition :param condition: a condition to look for :type condition: str :return: True if *self* has *condition*, False otherwise :rtype: bool """ return condition in self._conditions
[docs] def is_conscious(self) -> bool: """ :return: True if *self* is not unconscious and not dead """ return not (self.has_condition("unconscious") or self.has_condition("dead"))
[docs] def get_vision(self) -> str: """ :return: vision :rtype: one of these strings: "normal", "darkvision", "blindsight", "truesight" """ return self._vision
[docs] def can_see(self, light_src: str) -> bool: # pylint: disable=inconsistent-return-statements """ Determine whether *self* can see a given light source :param light_src: a kind of light :type light_src: one of these strings: "normal", "dark", "magic" :return: True if *self* can see *light_src*, False otherwise :rtype: bool :raise: ValueError if *light_src* is not valid input """ if light_src not in ["normal", "dark", "magic"]: self.get_logger().error("Light source must be normal, dark, or magic", stack_info=True) raise ValueError("Light source must be normal, dark, or magic") if self.has_condition("blinded"): return self.get_vision() == "blindsight" and light_src != "magic" if light_src == "normal": return True if self._vision == "normal": # normal vision can't see anything better than normal light return False if light_src == "dark": # darkvision, blindsight, and truesight can all see in the dark return True if light_src == "magic": return self._vision == "truesight"
[docs] def get_ability(self, ability: str) -> int: """ Get the ability score modifier indicated by *ability* :param ability: the name of an ability score :type ability: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma" :return: the ability score modifier :rtype: int :raise: ValueError if *ability* is not valid """ if ability == "strength": return self.get_strength() if ability == "dexterity": return self.get_dexterity() if ability == "constitution": return self.get_constitution() if ability == "intelligence": return self.get_intelligence() if ability == "wisdom": return self.get_wisdom() if ability == "charisma": return self.get_charisma() self.get_logger().error( "Ability score must be strength, dexterity, constitution, intelligence, wisdom, or charisma", stack_info=True) raise ValueError("Ability score must be strength, dexterity, constitution, intelligence, wisdom, or charisma")
[docs] def get_strength(self) -> int: """ :return: strength modifier :rtype: int """ return self._strength
[docs] def get_dexterity(self) -> int: """ :return: dexterity modifier :rtype: int """ return self._dexterity
[docs] def get_constitution(self) -> int: """ :return: constitution modifier :rtype: int """ return self._constitution
[docs] def get_intelligence(self) -> int: """ :return: intelligence modifier :rtype: int """ return self._intelligence
[docs] def get_wisdom(self) -> int: """ :return: wisdom modifier :rtype: int """ return self._wisdom
[docs] def get_charisma(self) -> int: """ :return: charisma modifier :rtype: int """ return self._charisma
[docs] def get_proficiencies(self) -> set: """ :return: proficiencies :rtype: set of strings """ return self._proficiencies
[docs] def has_proficiency(self, item: str) -> bool: """ Check to see if *self* has a proficiency called *item* :param item: a weapon name/type, ability score (for saving throws), etc. :type item: str :return: True if *self* has a proficiency called *item*, False otherwise :rtype: bool """ return item in self.get_proficiencies()
[docs] def get_proficiency_mod(self) -> int: """ :return: proficiency bonus :rtype: positive integer """ return self._proficiency_mod
[docs] def get_expertise(self) -> set: """ :return: expertise :rtype: set of strings """ return self._expertise
[docs] def has_expertise(self, item: str) -> bool: """ Check to see if *self* has a proficiency called *item* :param item: a weapon name/type, ability score, etc. :type item: str :return: True if self has a proficiency called *item*, False otherwise """ return item in self.get_expertise()
[docs] def has_weapon_proficiency(self, weapon: weapons.Weapon) -> bool: """ Check to see whether *self* has proficiency with the given weapon :param weapon: the Weapon object to check for proficiency :type weapon: Weapon :return: True if *self* has proficiency with *weapon*, False otherwise :rtype: bool :raise: ValueError if *weapon* is not a Weapon """ if not isinstance(weapon, weapons.Weapon): self.get_logger().error("Must provide a Weapon to calculate weapon proficiency", stack_info=True) raise ValueError("Must provide a Weapon to calculate weapon proficiency") if isinstance(weapon, armory.SimpleWeapon) and self.has_proficiency("simple weapons"): return True if isinstance(weapon, armory.MartialWeapon) and self.has_proficiency("martial weapons"): return True if self.has_proficiency("monk weapons") and armory.is_monk_weapon(weapon): return True return self.has_proficiency(type(weapon).__name__.lower())
[docs] def has_armor_proficiency(self, arm) -> bool: """ Check to see whether *self* has proficiency with the given armor. Note: checks armor name based on class name (e.g., ChainMailArmor) :param arm: the armor in question :type arm: :py:class:`Armor` :return: True if *self* has proficiency with *arm*, False otherwise :rtype: bool :raise: ValueError if *arm* is not a Armor """ if not inspect.isclass(arm) or not issubclass(arm, armor.Armor): self.get_logger().error("Must provide a Armor to calculate armor proficiency", stack_info=True) raise ValueError("Must provide a Armor to calculate armor proficiency") if issubclass(arm, armor.LightArmor) and self.has_proficiency("light armor"): return True if issubclass(arm, armor.MediumArmor) and self.has_proficiency("medium armor"): return True if issubclass(arm, armor.HeavyArmor) and self.has_proficiency("heavy armor"): return True return self.has_proficiency(arm.__name__)
[docs] def get_vulnerabilities(self) -> set: """ :return: vulnerabilities :rtype: set of strings """ return self._vulnerabilities
[docs] def is_vulnerable(self, thing) -> bool: """ Check to see if *self* is vulnerable to a given thing (usually a string for a damage type) :param thing: what we're checking for vulnerability :return: True if *self* is vulnerable to *thing*, False otherwise :rtype: bool """ return thing in self.get_vulnerabilities()
[docs] def get_resistances(self) -> set: """ :return: resistances :rtype: set of strings """ return self._resistances
[docs] def is_resistant(self, thing) -> bool: """ Check to see if *self* is resistant to a given thing (usually a string for a damage type) :param thing: what we're checking for resistance :return: True if *self* is resistant to *thing*, False otherwise :rtype: bool """ return thing in self.get_resistances()
[docs] def get_immunities(self) -> set: """ :return: immunities :rtype: set of strings """ return self._immunities
[docs] def is_immune(self, thing) -> bool: """ Check to see if *self* is resistant to a given thing (usually a string for a damage type) :param thing: what we're checking for immunity :return: True if *self* is immune to *thing*, False otherwise :rtype: bool """ return thing in self.get_immunities()
[docs] def get_saving_throw(self, ability: str) -> int: """ Get the modifier for an *ability* saving throw. .. Warning:: This does not roll the saving throw, it just returns the modifier to use for the throw :param ability: an ability score name :type ability: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma' :return: the modifier for an *ability* saving throw :rtype: int :raise: ValueError if *ability* is not valid """ try: return self._saving_throws[ability] except KeyError: self.get_logger().error("Asked for a saving throw that is not an ability score", stack_info=True) raise ValueError("Asked for a saving throw that is not an ability score")
[docs] def get_adv_to_be_hit(self) -> int: """ The sum of advantage (+1) and disadvantage (-1) circumstances affecting *self* is stored in :py:attr:`_adv_to_be_hit`. Look at this number and return an integer indicating whether an attack against *self* has advantage, disadvantage, or neither :return: advantage to be hit (positive means advantage, negative means disadvantage, 0 means neither) :rtype: int """ return self._adv_to_be_hit
[docs] def get_features(self) -> set: """ :return: features :rtype: set of strings """ return self._features
[docs] def has_feature(self, feature: str) -> bool: """ Check to see if *self* has the feature *feature* :param feature: the name of a feature :type feature: str :return: True if *self* has the feature *feature*, False otherwise :rtype: bool """ return feature in self.get_features()
[docs] def get_feature_dict(self): """ Get the dictionary that maps *self's* methods to the overloaded methods of any :py:class:`Feature` s *self* has. Internal use only! :return: feature_dict :rtype: dict """ return self._feature_dict
[docs] def get_feature_classes(self): """ :return: all the Features affecting *self* :rtype: set of :py:class:`Feature` s """ return self._feature_classes
[docs] def has_feature_class(self, item) -> bool: """ Check whether *self* has the given :py:class:`Feature` :param item: the feature to look for :type item: py:class:`Feature` :return: True if *self* has the feature, False otherwise :rtype: bool """ return item in self.get_feature_classes()
[docs] def get_feature_methods(self): """ :return: all the methods affected by the Features *self* has :rtype: set of methods """ return set(self._feature_dict.keys())
[docs] def has_feature_method(self, item: str) -> bool: """ Check whether *self* has a Feature affecting the given method :param item: the method to look for :type item: str :return: True if *self* has a Feature affecting item, False otherwise :rtype: bool """ return item in self.get_feature_methods()
[docs] def get_fighting_styles(self) -> set: """ If *self* has the "fighting style" feature, return :py:attr:`fighting_styles` (create it if it doesn't already exist). Otherwise, return None :return: fighting styles, or None :rtype: set of strings, or None """ if not self.has_feature("fighting style"): return None try: return self._fighting_styles except AttributeError: self._fighting_styles = set() # pylint: disable=attribute-defined-outside-init return self._fighting_styles
[docs] def has_fighting_style(self, fighting_style: str) -> bool: """ Check to see if *self* has the fighting style *fighting_style* :param fighting_style: a fighting style :type fighting_style: str :return: True if *self* has *fighting_style*, False otherwise :rtype: bool """ if not self.has_feature("fighting style"): return False return fighting_style in self.get_fighting_styles()
[docs] def get_weapons(self) -> list: """ :return: the weapons *self* owns :rtype: list of Weapons """ return self._weapons
[docs] def get_main_hand(self) -> Optional[weapons.Weapon]: """ :return: what *self* is carrying in main hand :rtype: :py:class:`Weapon` """ return self._main_hand
[docs] def get_off_hand(self) -> Optional[Union[weapons.Weapon, armor.Shield]]: """ :return: what *self* is carrying in off hand :rtype: :py:class:`Weapon` or :py:class:`Shield` """ return self._off_hand
[docs] def get_size(self) -> str: """ :return: size :rtype: str """ return self._size
[docs] def get_team(self): """ :return: team :rtype: :py:class:`Team` """ return self._team
[docs] def is_on_my_team(self, other) -> bool: """ :param other: the other Combatant to check :type other: :py:class:`Combatant` :return: True if *other* is on the same team as *self*, False otherwise :rtype: bool :raise: ValueError if *other* is not a Combatant """ try: return other.get_team() == self.get_team() except NameError: self.get_logger().error("other must be a Combatant", stack_info=True) raise ValueError("other must be a Combatant")
[docs] def get_items(self): """ :return: items :rtype: list """ return self._items
[docs] def get_attacks(self) -> list: """ :return: attacks :rtype: list of Attacks """ return self._attacks
[docs] def get_attack_by_name(self, name: str): """ Find an attack by name and return it if it exists :param name: the name of the :py:class:`Attack` we are looking for :type name: str :return: the Attack, or None if *self* does not have an Attack with the given name """ for attack in self.get_attacks(): if attack.get_name() == name: return attack return None
[docs] def get_weapon_attacks(self, weapon) -> list: """ Get all attacks based on a given Weapon :param weapon: the Weapon to look for :type weapon: Weapon :return: a list of all attacks based on *weapon* """ return [attack for attack in self.get_attacks() if attack.get_weapon() is weapon]
[docs] def get_enemy_tactic(self) -> combatant_tactics.CombatantTactic: """ :return: enemy_tactic (tactic for selecting who to attack) :rtype: :py:Class:`CombatantTactic` """ return self._enemy_tactic
[docs] def get_heal_tactic(self) -> combatant_tactics.CombatantTactic: """ :return: heal_tactic (tactic for selecting who to heal) :rtype: :py:Class:`CombatantTactic` """ return self._heal_tactic
[docs] def get_attack_tactic(self) -> attack_tactics.AttackTactic: """ :return: attack_tactic (tactic for selecting who to attack) :rtype: :py:class:`AttackTactic` """ return self._attack_tactic
[docs] def get_damage_dealt(self): """ :return: damage dealt """ return self._damage_dealt
[docs] def get_damage_taken(self): """ :return: damage taken """ return self._damage_taken
[docs] def get_times_unconscious(self): """ :return: number of times gone unconscious """ return self._times_gone_unconscious
[docs] def get_name(self) -> str: """ :return: name :rtype: str """ return self._name
[docs] def set_name(self, name: str): self._name = name
[docs] def get_logger(self) -> logging.Logger: """ :return: logger :rtype: logging.Logger """ return self._logger
[docs] def set_logger(self, logger: str): """ Set *self._logger* to the logger with the name specified in *logger* :param logger: the name of the Logger to use, or the logging.Logger object itself :return: None """ try: self._logger = logging.getLogger(logger) except TypeError: if isinstance(logger, (logging.Logger, NullLogger)): self._logger = logger else: self.get_logger().error("Tried to set logger to an invalid value", stack_info=True) raise ValueError("logger must be a string (the name of a logger)")
[docs] def set_ac(self, ac: int): """ Set the armor class :param ac: the new ac :type ac: positive integer :return: None :raise: ValueError if *ac* is invalid """ if not isinstance(ac, int) or ac < 1: self.get_logger().error("Ac must be a positive integer", stack_info=True) raise ValueError("Ac must be a positive integer") self._ac = ac self.get_logger().info("%s sets ac to %d", self.get_name(), ac)
[docs] def set_armor(self, arm: Optional[armor.Armor]): """ Set the armor (and set the AC if appropriate). Assume *arm* is Armor or None. :param arm: the Armor to wear :type arm: :py:class:`Armor` :return: """ try: self._armor = arm self.set_ac(arm.get_total_ac(self)) self.get_logger().info("%s sets armor to %s", self.get_name(), type(arm)) except AttributeError: if self._armor is not None: self.get_logger().error("Tried to put on invalid armor. Must be Armor or None.", stack_info=True) raise ValueError("Tried to put on invalid armor. Must be Armor or None.") self.set_ac(self.get_unarmored_ac()) self.get_logger().info("%s removes armor and is now using unarmored ac", self.get_name())
[docs] def set_temp_hp(self, hp: int): """ Set temporary hp :param hp: the new temporary hit points :type hp: non-negative integer :return: None """ if not isinstance(hp, int) or hp < 0: self.get_logger().error("temp hp must be a non-negative integer", stack_info=True) raise ValueError("temp hp must be a non-negative integer") self._temp_hp = hp self.get_logger().info("%s sets temp hp to %d", self.get_name(), hp)
[docs] def add_vulnerability(self, item): """ Add the given vulnerability :param item: the vulnerability to add :return: None """ self._vulnerabilities.add(item) self.get_logger().info("%s is now vulnerable to %s", self.get_name(), str(item))
[docs] def remove_vulnerability(self, item): """ Remove the given vulnerability :param item: the vulnerability to remove :return: None """ if item in self._vulnerabilities: self._vulnerabilities.remove(item) self.get_logger().info("%s is no longer vulnerable to %s", self.get_name(), str(item)) else: self.get_logger().warning("tried to remove a vulnerability you don't have", stack_info=True)
[docs] def add_resistance(self, item): """ Add the given resistance :param item: the resistance to add :return: None """ self._resistances.add(item) self.get_logger().info("%s is now resistant to %s", self.get_name(), str(item))
[docs] def remove_resistance(self, item): """ Remove the given resistance :param item: the resistance to remove :return: None """ if item in self._resistances: self._resistances.remove(item) self.get_logger().info("%s is no longer resistant to %s", self.get_name(), str(item)) else: self.get_logger().warning("Tried to remove a resistance you don't have", stack_info=True)
[docs] def add_immunity(self, item): """ Add the given immunity :param item: the immunity to add :return: None """ self._immunities.add(item) self.get_logger().info("%s is now immune to %s", self.get_name(), str(item))
[docs] def remove_immunity(self, item): """ Remove the given immunity :param item: the immunity to remove :return: None """ if item in self._immunities: self._immunities.remove(item) self.get_logger().info("%s is no longer immune to %s", self.get_name(), str(item)) else: self.get_logger().warning("Tried to remove an immunity you don't have", stack_info=True)
[docs] def modify_adv_to_be_hit(self, adv: int): """ Modify the advantage to be hit :param adv: the number to modify :py:attr:`_adv_to_be_hit` by :type adv: int :return: None :raise: ValueError if *adv* is invalid """ try: self._adv_to_be_hit += adv self.get_logger().info("%s modifies adv_to_be_hit, now it is %d", self.get_name(), self.get_adv_to_be_hit()) except TypeError: self.get_logger().error("adv must be an integer", stack_info=True) raise ValueError("adv must be an integer")
[docs] def add_feature(self, feature: str): """ Add the given feature :param feature: the feature to add :type feature: str :return: None """ self._features.add(feature) self.get_logger().info("%s adds feature %s", self.get_name(), feature)
[docs] def add_feature_class(self, feature): """ Add the given Feature :param feature: the Feature to add :type feature: Feature :return: None """ try: for method_tup in feature.get_ol_methods(): # check that this feature actually affects a method that we have getattr(self, method_tup[0]) self._feature_dict[method_tup[0]] = method_tup[1] self._feature_classes.add(feature) self.get_logger().info("%s adds Feature %s", self.get_name(), type(feature)) except AttributeError: self.get_logger().warning("Tried to include an invalid Feature in add_feature_class" "(either it is not a Feature or it uses a method Combatant doesn't have)", stack_info=True)
[docs] def add_fighting_style(self, fighting_style: str): """ Add the given fighting style :param fighting_style: the fighting style to add :return: None :raise: ValueError if *fighting_style* is invalid or if *self* already has *fighting_stlye* """ if not self.has_feature("fighting style"): self.get_logger().error("Cannot add a fighting style because you do not have this feature", stack_info=True) raise ValueError("Cannot add a fighting style because you do not have this feature") if fighting_style not in ["archery", "defense", "dueling", "great weapon fighting", "protection", "two-weapon fighting"]: self.get_logger().error('Fighting style must be in "archery", "defense", "dueling", ' '"great weapon fighting", "protection", "two-weapon fighting"', stack_info=True) raise ValueError( 'Fighting style must be in "archery", "defense", "dueling", ' '"great weapon fighting", "protection", "two-weapon fighting"') if self.has_fighting_style(fighting_style): self.get_logger().error("Cannot add the same fighting style (%s) twice", fighting_style, stack_info=True) raise ValueError(f"Cannot add the same fighting style (${fighting_style}) twice") self.get_fighting_styles().add(fighting_style) self.get_logger().info("%s adds %s fighting style", self.get_name(), fighting_style)
[docs] def add_weapon(self, weapon: weapons.Weapon): """ Add the given weapon :param weapon: the Weapon to add :type weapon: Weapon :return: None :raise: ValueError if *weapon* is invalid """ if not isinstance(weapon, weapons.Weapon): self.get_logger().error("Tried to add a non-Weapon as a weapon", stack_info=True) raise ValueError("Tried to add a non-Weapon as a weapon") owner = weapon.get_owner() if owner: self.get_logger().warning("Weapon %s is owned by %s. Replacing owner.", weapon.get_name(), owner.get_name(), stack_info=True) self._weapons.append(weapon) weapon.set_owner(self) self.add_weapon_attacks(weapon) self.get_logger().info("%s adds %s weapon", self._name, weapon.get_name())
[docs] def remove_weapon(self, weapon: weapons.Weapon): """ Remove the given weapon (based on identity) and all its attacks :param weapon: the Weapon to remove :type weapon: a Weapon :return: None """ weapon_num = len(self.get_weapons()) self._weapons[:] = [w for w in self._weapons if w is not weapon] if len(self.get_weapons()) == weapon_num: self.get_logger().error("Tried to remove a weapon you don't have", stack_info=True) raise ValueError("Tried to remove a weapon you don't have") weapon.set_owner(None) # Dobby is a free weapon! self.remove_weapon_attacks(weapon) self.get_logger().info("%s removes %s weapon", self._name, weapon.get_name())
[docs] def remove_all_weapons(self): """ Remove all of *self's* weapons and their attacks :return: None """ to_remove = self._weapons.copy() # make a copy so I can iterate through the list for weapon in to_remove: self.remove_weapon(weapon) self.get_logger().info("%s removes all weapons", self.get_name())
[docs] def add_weapon_attacks(self, weapon: weapons.Weapon): """ Add all the Attacks that *weapon* can make to *self._attacks* :param weapon: the Weapon to add attacks for :type weapon: Weapon :return: None """ if weapon not in self.get_weapons(): self.get_logger().error("Cannot add attacks for a weapon you don't own", stack_info=True) raise ValueError("Cannot add attacks for a weapon you don't own") attack_mod, damage_mod, range_attack_mod = self.get_weapon_attack_modifiers(weapon) for attack_kwargs in weapon.get_attack_kwargs().values(): if "range" in attack_kwargs: the_attack_mod = range_attack_mod else: the_attack_mod = attack_mod attack = attack_class.Attack(**attack_kwargs, attack_mod=the_attack_mod, damage_mod=damage_mod) self.add_attack(attack)
[docs] def get_weapon_attack_mod(self, weapon): """ Get the base modifier for attacks with a given weapon (based on str/dex, features, etc.) :param weapon: the weapon to look at :type weapon: :py:class:`Weapon` :return: attack modifier :rtype: int """ if weapon.has_prop("finesse"): mod = max(self._strength, self._dexterity) elif isinstance(weapon, armory.RangedWeapon): mod = self._dexterity elif isinstance(weapon, armory.MeleeWeapon): mod = self._strength elif weapon.get_range(): mod = self._dexterity else: mod = self._strength if self.has_weapon_proficiency(weapon): mod += self.get_proficiency_mod() if self.has_feature_method("get_weapon_attack_mod"): mod += self.get_feature_dict()["get_weapon_attack_mod"](self, weapon) return mod
[docs] def get_weapon_damage_mod(self, weapon): """ Get the base modifier for damage attacks with a given weapon (based on str/dex, features, etc.). May seem like duplication of get_weapon_attack_mod, but this is a different method so it's easier to overload with a Feature :param weapon: the weapon to look at :type weapon: :py:class:`Weapon` :return: damage modifier :rtype: int """ if weapon.has_prop("finesse"): mod = max(self._strength, self._dexterity) elif isinstance(weapon, armory.RangedWeapon): mod = self._dexterity elif isinstance(weapon, armory.MeleeWeapon): mod = self._strength elif weapon.get_range(): mod = self._dexterity else: mod = self._strength return mod
[docs] def get_weapon_attack_modifiers(self, weapon): """ Gets the modifiers for attacks with a given weapon (based on str/dex, features, etc.) :param weapon: the weapon to look at :type weapon: Weapon :return: attack_mod, damage_mod, range_attack_mod :rtype: tuple of ints """ if weapon.has_prop("finesse"): mod = max(self._strength, self._dexterity) elif isinstance(weapon, armory.RangedWeapon): mod = self._dexterity elif isinstance(weapon, armory.MeleeWeapon): mod = self._strength elif weapon.get_range(): mod = self._dexterity else: mod = self._strength attack_mod = weapon.get_attack_mod() + mod if self.has_weapon_proficiency(weapon): attack_mod += self._proficiency_mod damage_mod = mod if self.has_fighting_style("archery") and isinstance(weapon, armory.RangedWeapon): range_attack_mod = attack_mod + 2 else: range_attack_mod = attack_mod return attack_mod, damage_mod, range_attack_mod
[docs] def add_attack(self, attack: attack_class.Attack): """ Add the given attack :param attack: the Attack to add :type attack: Attack :return: None """ if attack in self._attacks: pass self._attacks.append(attack) self.get_logger().info("%s adds attack %s", self.get_name(), attack.get_name())
[docs] def remove_attack(self, attack: attack_class.Attack): """ Remove the given :py:class:`Attack`. ..Warning:: this calls list.remove(item), which uses item's built-in :py:meth:`__eq__` method. :py:meth:`__eq__` is overriden for :py:class:`Attack` to do a comparison based on instance variables and not memory address. :param attack: the :py:class:`Attack` to remove :return: None """ self.get_attacks().remove(attack)
[docs] def remove_weapon_attacks(self, weapon: weapons.Weapon): """ Remove all the attacks related to Weapon. .. Warning:: Doesn't check to see whether *self* actually has the given weapon (checking something that is usually False is not that important), but no unexpected behavior happens if this is the case. :param weapon: the Weapon to remove attacks based on :type weapon: Weapon :return: None """ old_attacks = self.get_attacks() self._attacks = [] for attack in old_attacks: if attack.get_weapon() is weapon: # clear out the old modifiers by modifying by the opposite number pass else: self._attacks.append(attack)
[docs] def add_condition(self, condition: str): """ Add the given condition :param condition: the condition to add :type condition: str :return: None """ if not isinstance(condition, str): self.get_logger().error("condition must be a string", stack_info=True) raise ValueError("condition must be a string") if condition not in self._conditions: if self.is_immune(condition): self.get_logger().info("%s is immune to the %s condition", self._name, condition) return self._conditions.append(condition) self.get_logger().info("%s is now %s", self._name, condition)
[docs] def remove_condition(self, condition: str): """ Remove the given condition :param condition: the condition to remove :type condition: str :return: None """ try: self._conditions.remove(condition) self.get_logger().info("%s is no longer %s", self.get_name(), condition) except ValueError: pass
[docs] def remove_all_conditions(self): """ Remove all conditions :return: None """ self._conditions.clear() self.get_logger().info("%s removed all conditions", self.get_name())
[docs] def set_vision(self, vision: str): # in case of special vision changing magic? also useful for testing """ Set vision to the given value :param vision: the vision to change to :type vision: str :return: None :raise: ValueError if *vision* is invalid """ if vision in ["normal", "darkvision", "blindsight", "truesight"]: self._vision = vision self.get_logger().info("%s changes vision to %s", self._name, vision) else: self.get_logger().error("Vision type not recognized: %s", vision, stack_info=True) raise ValueError(f"Vision type not recognized: {vision}")
[docs] def set_size(self, size: str): """ Set size to the given size :param size: the size to change to :type size: str :return: None :raise: ValueError if *size* is invalid """ if size not in ["tiny", "small", "medium", "large", "huge", "gargantuan"]: self.get_logger().error("Size must be tiny, small, medium, large, huge, or gargantuan", stack_info=True) raise ValueError("Size must be tiny, small, medium, large, huge, or gargantuan") self._size = size
[docs] def set_team(self, team): """ Set team to the given Team. Does not check that *team* is valid (this is to avoid circular imports) :param team: the team to change to :return: None """ self._team = team if self._team is None: # don't check that None is a valid team return try: team_name = team.get_name() if team.get_enemy_tactic() not in self.get_enemy_tactic().get_tiebreakers(): self.get_enemy_tactic().append_tiebreaker(team.get_enemy_tactic()) except AttributeError: self.get_logger().error("%s set team to invalid team", self.get_name()) raise ValueError("Combatant.set_team(team) needs a valid Team") self.get_logger().info("%s sets team to %s", self.get_name(), team_name)
[docs] def set_enemy_tactic(self, tact): """ Set rule for who to attack to *tact* :param tact: the :py:class:`Tactic` *self* uses to select an enemy :py:class:`Combatant` :type tact: :py:class:`CombatantTactic` :return: None """ self._enemy_tactic = tact try: self._enemy_tactic.run_tactic([self]) # make sure this is a :py:class:`CombatantTactic` except AttributeError: self.get_logger().error("%s tried to set enemy_tactic to an invalid value", self.get_name()) raise ValueError("enemy_tactic must be a CombatantTactic")
[docs] def set_heal_tactic(self, tact): """ Set rule for who to heal to *tact* :param tact: the :py:class:`Tactic` *self* uses to select a who to heal :py:class:`Combatant` :type tact: :py:class:`CombatantTactic` :return: None """ self._heal_tactic = tact try: self._heal_tactic.run_tactic([self]) # make sure this is a :py:class:`CombatantTactic` except AttributeError: self.get_logger().error("%s tried to set heal_tactic to an invalid value", self.get_name()) raise ValueError("heal_tactic must be a CombatantTactic")
[docs] def set_attack_tactic(self, tact): """ Set rule for which attack to use to *tact* :param tact: the :py:class:`Tactic` *self* uses to select an enemy :py:class:`Attack` :type tact: :py:class:`AttackTactic` :return: None """ self._attack_tactic = tact
# TODO: check that this attack tactic is valid # try: # self._enemy_tactic.run_tactic([attack_class.Attack(name="test")]) # make sure this is a :py:class:`AttackTactic` # except AttributeError: # self.get_logger().error("%s tried to set enemy_tactic to an invalid value", self.get_name()) # raise ValueError("attack_tactic must be a AttackTactic")
[docs] def select_action(self) -> str: """ Choose what action to take. # TODO: support actions other than "Attack" :return: a string representing the action chosen :rtype: str """ return "Attack"
[docs] def select_enemy(self, choices, **kwargs): """ From a list of :py:class:`Combatant` s, select who to attack :param choices: the list of :py:class:`Combatant` s to choose from :type choices: list of :py:class:`Combatant` s :return: the selected :py:class:`Combatant` s :rtype: :py:class:`Combatant` """ return self.get_enemy_tactic().make_choice(choices, **kwargs)
[docs] def select_heal(self, choices, **kwargs): """ From a list of :py:class:`Combatant` s, select who to heal :param choices: the list of :py:class:`Combatant` s :type choices: list of :py:class:`Combatant` s :return: the selected :py:class:`Combatant` :rtype: :py:class:`Combatant` """ return self.get_heal_tactic().make_choice(choices, **kwargs)
[docs] def select_attack(self, **kwargs): """ Choose which of *self*'s attacks to use :param kwargs: keyword arguments :return: the chosen attack :rtype: :py:class:`Attack` """ return self.get_attack_tactic().make_choice(self.get_attacks(), **kwargs)
[docs] def send_attack(self, target, attack: attack_class.Attack, adv=0) -> Optional[int]: """ Attack a given target using a given attack :param target: the Combatant to attack :type target: Combatant :param attack: the Attack being made :type attack: Attack :param adv: indicates whether *self* has advantage for this attack :type adv: int :return: the damage *target* took from *attack*, or None if the attack failed to hit """ try: adv += target.get_adv_to_be_hit() damage = attack.make_attack(self, target, adv=adv) if damage is not None: self._damage_dealt += damage return damage except NameError: self.get_logger().error("Tried to make an attack with something that can't make attacks", stack_info=True) raise ValueError("Tried to make an attack with something that can't make attacks")
[docs] def take_attack(self, attack_result: TYPE_ROLL_RESULT, source=None, attack: attack_class.Attack = None) -> bool: # pylint: disable=unused-argument """ Read in an attack roll and determine whether the attack hits or not :param attack_result: first number in tuple indicates roll value, second number indicates crit value :type attack_result: TYPE_ROLL_RESULT :param source: the thing that is making the attack (usually a Combatant) :param attack: the Attack that is received :type attack: Attack :return: True if the attack hits, False otherwise :rtype: bool """ hit_val, crit_val = attack_result if crit_val == -1: # critical fails auto-miss return False if crit_val == 1: # critical successes auto-hit return True return hit_val >= self.get_ac()
[docs] def take_saving_throw(self, save_type: str, dc: int, attack: attack_class.Attack = None, adv: int = 0) -> bool: # pylint: disable=unused-argument """ Respond to something that asks for a saving throw :param save_type: the kind of saving throw to make :type save_type: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma" :param dc: to succeed, the roll must be greater than or equal to the dc :type dc: int :param attack: the Attack that is asking for a saving throw :type attack: Attack :param adv: indicates advantage, disadvantage, or neither :type adv: int :return: True if *self* makes the saving throw, False otherwise :rtype: bool """ return self.make_saving_throw(save_type, adv) >= dc
[docs] def make_saving_throw(self, save_type: str, adv: int = 0) -> int: """ Roll a saving throw of the given type :param save_type: the kind of saving throw to make :type save_type: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma" :param adv: indicates advantage, disadvantage, or neither :type adv: int :return: the number rolled for the saving throw :rtype: int """ # TODO: fix to use Dice class modifier = self.get_saving_throw(save_type) result = roll_dice(dice_type=20, modifier=modifier, adv=adv)[0] self.get_logger().info("%s rolls a %d for a %s saving throw.", self._name, result, save_type) return result
[docs] def should_die_from_damage(self, damage_taken: int, damage_type: str = None): # pylint: disable=unused-argument """ Return whether taking the specified amount of damage should result in *self* dying :param damage_taken: the total amount of damage taken :param damage_type: the damage type :return: """ return damage_taken >= self.get_current_hp()
# pylint: disable=unused-argument
[docs] def take_damage(self, damage: int, damage_type: str = None, is_critical: bool = False) -> int: """ Take damage, applying vulnerabilities, resistances, and immunities as necessary :param damage: the number of hit points of damage to take :type damage: positive integer :param damage_type: the type of damage :type damage_type: str :param is_critical: whether the damage is from a critical hit :type is_critical: bool :return: the actual damage taken :rtype: int """ if self.is_vulnerable(damage_type): self.get_logger().info("%s is vulnerable to %s!", self._name, damage_type) damage *= 2 elif self.is_resistant(damage_type): self.get_logger().info("%s is resistant to %s.", self._name, damage_type) damage //= 2 elif self.is_immune(damage_type): self.get_logger().info("%s is immune to %s.", self._name, damage_type) return 0 self.get_logger().info("%s takes %d damage", self._name, damage) damage_taken = damage if damage_taken is not None: self._damage_taken += damage_taken if self.get_temp_hp(): if damage <= self.get_temp_hp(): self._temp_hp -= damage return damage_taken damage -= self.get_temp_hp() # empty out temp hp self._temp_hp = 0 if self.should_die_from_damage(damage, damage_type): self.die() else: self._current_hp -= damage if self.get_current_hp() <= 0: self.become_unconscious() return damage_taken
[docs] def take_healing(self, healing: int) -> int: """ Become healed for the given number of hit points :param healing: the number of hit points to gain :type healing: int :return: the number of hit points actually healed :rtype: int """ if self.get_current_hp() + healing > self.get_max_hp(): healing = self.get_max_hp() - self.get_current_hp() self._current_hp += healing self.get_logger().info("%s is healed for %d.", self._name, healing) if self.has_condition("unconscious"): self.become_conscious() return healing
[docs] def become_conscious(self): """ Become conscious (removing unconcsious conditions) :return: None """ self.remove_condition("unconscious")
# TODO: condition from Sleep spell is more complicated than just unconscious
[docs] def heal_to_max(self): """ Heal to max hp :return: None """ self._current_hp = self.get_max_hp() self.get_logger().info("%s is healed to max.", self.get_name())
[docs] def ability_check(self, ability) -> int: try: result = roll_dice(num=1, dice_type=20, modifier=self.get_ability(ability))[0] self.get_logger().info("%s rolls %d on %s check", self.get_name(), result, ability) return result except ValueError: self.get_logger().error("Must provide a valid ability for an ability check", stack_info=True) raise ValueError("Must provide a valid ability for an ability check")
[docs] def roll_initiative(self) -> int: result = self.ability_check("dexterity") self.get_logger().info("%s rolls %d on initiative", self.get_name(), result) return result
[docs] def take_turn(self, teams): """ Take a turn in combat :param teams: the teams in the current combat :type teams: list of :py:class:`Team` s :return: damage taken by the enemy """ if self.has_condition("unconscious"): self.get_logger().info("%s is unconscious and thus will take a turn being unconscious", self.get_name()) return self.take_turn_unconscious() action = self.select_action() if action == "Attack": enemies = [] for member in teams: if member is not self.get_team(): # TODO: add support for allies enemies.extend(member.get_combatants()) target = self.select_enemy(enemies) attack = self.select_attack(target=target) return self.send_attack(target, attack) # TODO: deal with advantage return 0
[docs] def become_unconscious(self): """ Add unconscious condition and set :py:attr:`current_hp` to 0 :return: None """ if self.has_condition("unconscious"): # don't go unconscious again return self.add_condition("unconscious") self._times_gone_unconscious += 1
[docs] def take_turn_unconscious(self): """ Do nothing :return: None (no damage was dealt) """ return None # no damage dealt
[docs] def die(self): """ Die - set condition to "dead" and :py:attr:`current_hp` and :py:attr:`temp_hp` to 0 :return: None """ self._conditions.clear() self.add_condition("dead") self._current_hp = 0 self._temp_hp = 0 self.get_logger().info("%s is dead", self.get_name())
[docs] def reset(self): """ Reset attributes. Used to prepare to run an Encounter again, if the Encounter is simple enough. :return: None """ # required resets self._adv_to_be_hit = 0 self.remove_all_conditions() self._current_hp = self.get_max_hp() self._damage_taken = 0 self._damage_dealt = 0 self._times_gone_unconscious = 0