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