import warnings
from typing import Optional, List
from copy import copy, deepcopy
from DnD_5e.utility_methods_dnd import TYPE_DICE_TUPLE, TYPE_ROLL_RESULT
from DnD_5e import weapons, dice
[docs]
class Attack:
"""
This class represents attacks
"""
__hash__ = None # Tell the interpreter that instances of this class cannot be hashed
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 range_val: the range of the ranged attack
:type range_val: usually an integer, but other types are allowed currently (a warning is issued)
:param melee_range: the range of the melee attack
:param adv: indicates whether the attack rolls should be made normally, with advantage, or with disadvanatage
:type adv: one of these integers: -1, 0, 1
:param weapon: the Weapon this attack comes from, if any
:type weapon: Weapon or None
:param attack_mod: the number to be added to attack rolls (ignored if attack_dice is passed in as a Dice)
:type attack_mod: int
:param damage_mod: the number to be added to damage rolls (ignored if damage_dice is passed in as a DamageDice)
:type damage_mod: int
:param attack_dice: the dice for making attack rolls
:type attack_dice: dice.Dice
:param damage_dice: the dice to determine damage points. If provided as a tuple or string,
keyword arguments will be passed on to the DamageDice constructor, including damage_type
:type damage_dice: dice.DamageDice or tuple or string
:raise: ValueError if input is invalid
"""
name = kwargs.get("name")
self._name = name
if not isinstance(name, str):
raise ValueError("Name should be a string")
range_val = kwargs.get("range", 0)
if isinstance(range_val, int):
self._range = range_val
else:
warnings.warn("Non-integer value for range provided")
self._range = range_val
melee_range = kwargs.get("melee_range", 0)
if isinstance(melee_range, int):
self._melee_range = melee_range
else:
self._melee_range = 0
if not self._range and not self._melee_range: # assume this is a melee attack if not otherwise specified
self._melee_range = 5
adv = kwargs.get("adv", 0)
if not isinstance(adv, int):
raise ValueError("Advantage must be an int")
weapon = kwargs.get("weapon")
if isinstance(weapon, weapons.Weapon):
self._weapon = weapon
elif weapon:
raise ValueError("Weapon should be a Weapon object")
else:
self._weapon = None
attack_dice = kwargs.get("attack_dice")
if isinstance(attack_dice, dice.Dice):
self._attack_dice = attack_dice
else:
if isinstance(attack_dice, tuple):
kwargs.update(dice_tuple=attack_dice)
elif isinstance(attack_dice, str):
kwargs.update(str_val=attack_dice)
else:
kwargs.update(dice_tuple=(1, 20))
kwargs.update(modifier=kwargs.get("attack_mod", 0))
kwargs.update(critable=True)
kwargs.update(adv=0) # don't add advantage twice
self._attack_dice = dice.Dice(**kwargs)
self._attack_dice.shift_adv(adv)
damage_dice = kwargs.get("damage_dice")
if not damage_dice:
if not self._weapon:
raise ValueError("Must provide damage dice or a weapon to get damage dice from")
self._damage_dice = copy(self._weapon.get_damage_dice())
# adjusting damage and attack mods based on keyword arguments
if kwargs.get("damage_mod"):
self.shift_damage_mod(kwargs.get("damage_mod"))
elif isinstance(damage_dice, dice.DamageDice):
self._damage_dice = copy(damage_dice)
# adjusting damage and attack mods based on keyword arguments
if kwargs.get("damage_mod"):
self.shift_damage_mod(kwargs.get("damage_mod"))
else:
if isinstance(damage_dice, tuple):
kwargs.update(dice_tuple=damage_dice)
elif isinstance(damage_dice, str):
kwargs.update(str_val=damage_dice)
kwargs.update(modifier=kwargs.get("damage_mod", 0))
kwargs.update(critable=False)
kwargs.update(adv=0) # can't have advantage or disadvantage on damage
self._damage_dice = dice.DamageDice(**kwargs)
[docs]
def set_name(self, name: str):
if not isinstance(name, str):
raise ValueError("name must be a string")
self._name = name
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on the following characteristics:
damage dice, range, melee range, adv, weapon
Override the default __eq__ because it's useful to be able to tell if one Combatant's Attacks are the same as another's
:param other: the Attack to be compared
:type other: Attack
:return: True if *self* __eq__ *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_attack_dice() == other.get_attack_dice() \
and self.get_damage_dice() == other.get_damage_dice() \
and self.get_range() == other.get_range() \
and self.get_melee_range() == other.get_melee_range() \
and self.get_weapon() == other.get_weapon()
[docs]
def get_attack_dice(self) -> dice.Dice:
"""
:return: attack dice
:rtype: dice.Dice
"""
return self._attack_dice
[docs]
def get_damage_dice(self) -> dice.DamageDice:
"""
:return: damage dice
:rtype: dice.DamageDice
"""
return self._damage_dice
[docs]
def get_attack_mod(self) -> int:
"""
:return: attack mod
:rtype: int
"""
return self.get_attack_dice().get_modifier()
[docs]
def get_damage_mod(self) -> int:
"""
:return: damage mod
:rtype: int
"""
try:
return self.get_damage_dice().get_modifier()
except NotImplementedError: # DamageDiceBag
warnings.warn("Tried to get damage mod on a DamageDiceBag. Returning 0 instead")
return 0
[docs]
def get_damage_type(self) -> Optional[str]:
"""
:return: damage type
:rtype: str
"""
return self.get_damage_dice().get_damage_type()
[docs]
def has_damage_type(self, damage_type: str) -> bool:
"""
:param damage_type: the type of damage to look for
:type damage_type: str
:return: True if *self* has the specified damage type, False otherwise
:rtype: bool
"""
return self.get_damage_dice().has_damage_type(damage_type)
[docs]
def get_range(self) -> int:
"""
:return: range for ranged attacks
:rtype: usually a positive integer, but currently other types are also supported
"""
return self._range
[docs]
def get_melee_range(self) -> int:
"""
:return: range for melee attacks
:rtype: positive integer
"""
return self._melee_range
[docs]
def get_adv(self) -> int:
"""
:return: adv
:rtype: int
"""
try:
result = self.get_attack_dice().get_adv()
except NotImplementedError:
warnings.warn("Tried to get adv of a DiceBag, which is not supported. Returning 0.")
result = 0
return result
[docs]
def get_name(self) -> str:
"""
:return: name
:rtype: str
"""
return self._name
[docs]
def get_weapon(self) -> Optional[weapons.Weapon]:
"""
:return: weapon, if any
:rtype: Weapon or None
"""
return self._weapon
[docs]
def get_max_hit(self) -> int:
"""
:return: the maximum number that could result from an attack roll
:rtype: int
"""
return self.get_attack_dice().get_max_value()
[docs]
def get_min_hit(self) -> int:
"""
:return: the minimum number that could result from an attack roll
:rtype: int
"""
return self.get_attack_dice().get_min_value()
[docs]
def get_average_hit(self) -> float:
"""
:return: the average number that would result from an attack roll
:rtype: float
"""
return self.get_attack_dice().get_average_value()
[docs]
def get_max_damage(self) -> int:
"""
:return: the maximum number that could result from a damage roll (not counting extra damage from a critical hit)
:rtype: int
"""
return self.get_damage_dice().get_max_value()
[docs]
def get_min_damage(self) -> int:
"""
:return: the minimum number that could result from a damage roll (not counting extra damage from a critical hit)
:rtype: int
"""
return self.get_damage_dice().get_min_value()
[docs]
def get_average_damage(self) -> float:
"""
:return: the average number that would result from a damage roll
:rtype: float
"""
return self.get_damage_dice().get_average_value()
[docs]
def get_prob_hit(self, **kwargs) -> float:
"""
:param ac: the ac you're trying to hit
:return: the probability of rolling greater than or equal to ac
"""
ac = kwargs.get("ac")
return self.get_attack_dice().get_prob(ac, kind="ge")
[docs]
def get_dpr(self, **kwargs) -> float:
"""
:param ac: the armor class you need to hit
:type ac: int
:return: the average damage per round (dpr)
"""
ac = kwargs.get("ac")
if ac is None:
ac = kwargs.get('target').get_ac()
return self.get_prob_hit(ac=ac) * self.get_average_damage()
[docs]
def set_attack_mod(self, attack_mod: int):
"""
Set *self._attack_mod*
:param attack_mod: the new attack mod
:type attack_mod: int
:return: None
:raise: ValueError if *attack_mod* is not an integer
"""
self.get_attack_dice().set_modifier(attack_mod)
[docs]
def shift_attack_mod(self, attack_mod: int):
"""
Modify *self._attack_mod* by the given value
:param attack_mod: the damage mod to shift by
:type attack_mod: int
:return: None
:raise: ValueError if *attack_mod* is not an integer
"""
self.get_attack_dice().shift_modifier(attack_mod)
[docs]
def set_damage_mod(self, damage_mod: int):
"""
Set *self._damage_mod* to the given value
:param damage_mod: the new damage mod
:type damage_mod: int
:return: None
:raise: ValueError if *damage_mod* is not an integer
"""
self.get_damage_dice().set_modifier(damage_mod)
[docs]
def shift_damage_mod(self, damage_mod: int):
"""
Modify *self._damage_mod* by the given value
:param damage_mod: the damage mod to shift by
:type damage_mod: int
:return: None
:raise: ValueError if *damage_mod* is not an integer
"""
self.get_damage_dice().shift_modifier(damage_mod)
[docs]
def roll_attack(self, adv: int = 0) -> TYPE_ROLL_RESULT: # adv is the additional advantage afforded by circumstance
"""
Roll a d20 (with advantage/disadvantage as computed with *adv* and :py:attr:`_adv`), adding :py:attr:`_attack_mod`
:param adv: advantage, disadvantage, or neither on the attack roll
:type adv: int
:return: the roll and an indication of whether it was a critical hit, critical miss, or neither
:rtype: TYPE_ROLL_RESULT
"""
result = self.get_attack_dice().roll_dice(adv=adv)
return result
[docs]
def roll_damage(self, crit: int = 0, crit_multiplier: int = 2):
"""
Roll :py:attr:`_damage_dice` (if crit == 1, multiply the number of dice to roll by *crit_multiplier*),
then add :py:attr:`_damage_mod`
:param crit: indicates whether the hit was a crit
:type crit: one of these integers: -1, 0, 1
:param crit_multiplier: the multiplier for dice num if the hit is a crit
:type crit_multiplier: positive integer
:return: the damage rolled
:rtype: namedtuple
:raise: ValueError if *crit_multiplier* is invalid
"""
return self.get_damage_dice().roll_dice(crit=crit, crit_multiplier=crit_multiplier)
[docs]
def make_attack(self, source, target, adv: int = 0) -> Optional[int]:
"""
Roll attack using :py:meth:`roll_attack` and store the result in variable *result*.
Call *target*. :py:meth:`take_attack` to see if the attack hits.
If the attack hits, call :py:meth:`send_damage`.
:param source: the Combatant that is making the attack
:type source: Combatant
:param target: the Combatant that *source* is attacking
:type target: Combatant
:param adv: advantage, disadvantage, or neither on the attack roll
:type adv: one of these integers: -1, 0, 1
:return: the damage taken (if the attack misses, damage taken is None)
:rtype: non-negative integer or None
:raise: ValueError if *source* can't send attacks or *target* can't take attacks
"""
num, crit = self.roll_attack(adv=adv)
if target.has_condition("unconscious") and crit != -1: # TODO: and source is within 5 feet of target
crit = 1 # any attack that hits the target is a critical hit if the attacker is within 5 feet of the target
try:
source.get_logger().info(f"{source.get_name()} attacks ${target.get_name()} "
f"with ${self.get_name()} and rolls a ${num}.")
if target.take_attack((num, crit), source=source, attack=self): # take_attack returns True if attack hits
if crit == 1:
source.get_logger().info("Critical hit!")
else:
source.get_logger().info("Hit!")
return self.on_hit(source, target, adv, crit)
# if the attack failed to hit
if crit == -1:
source.get_logger().info("Critical miss.")
else:
source.get_logger().info("Miss.")
return self.on_miss(source, target, adv, crit) # by default on_miss returns None so it's clear from the return value whether the attack hit
except NameError:
raise ValueError("Tried to attack with a source that can't send attacks or a target that can't take attacks")
[docs]
def on_hit(self, source, target, adv=0, crit=0) -> int: # pylint: disable=unused-argument
"""
You hit the target, now do whatever happens on a hit
:param source: the Combatant that is making the attack
:type source: Combatant
:param target: the Combatant that *source* is attacking
:type target: Combatant
:param adv: advantage, disadvantage, or neither on the attack roll
:type adv: one of these integers: -1, 0, 1
:type crit: whether or not the hit was a crit
:return: the damage taken
"""
if source.has_feature("brutal critical"):
crit_multiplier = 3
else:
crit_multiplier = 2
damage_taken = self.send_damage(target, crit=crit, crit_multiplier=crit_multiplier)
return damage_taken
[docs]
def on_miss(self, source, target, adv=0, crit=0):
"""
You missed the target, now do whatever happens on a miss
:param source: the Combatant that is making the attack
:type source: Combatant
:param target: the Combatant that *source* is attacking
:type target: Combatant
:param adv: advantage, disadvantage, or neither on the attack roll
:type adv: one of these integers: -1, 0, 1
:return: None
"""
pass # pylint: disable=unnecessary-pass
[docs]
def send_damage(self, target, crit: int = 0, crit_multiplier: int = 2) -> int:
"""
Roll damage using :py:meth:`roll_damage` and store the result in variable *damage*.
Call *target*. :py:meth:`take_damage` , passing in *damage* and :py:attr:`damage_type`.
:param target: the Combatant that is taking the damage
:type target: Combatant
:param crit: indicates whether the hit was a crit
:type crit: one of these integers: -1, 0, 1
:param crit_multiplier: the number to multiply dice num by if the hit is a crit
:type crit_multiplier: positive integer
:return: the damage taken, as returned by *target.take_damage*
:rtype: non-negative integer or None
"""
roll_result = self.roll_damage(crit=crit, crit_multiplier=crit_multiplier)
damage_taken = 0
try:
damage_taken = target.take_damage(roll_result.roll_number, roll_result.damage_type, crit == 1)
except AttributeError: # DamageDiceBag was rolled, so roll_list is list of namedtuples
roll_list = roll_result[1]
for result in roll_list:
damage_taken += target.take_damage(result.roll_number, result.damage_type, crit == 1)
return damage_taken
[docs]
class MultiAttack(Attack):
"""
Container for multiple Attacks
"""
def __init__(self, **kwargs): # pylint: disable=super-init-not-called
"""
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 attack_list: list of Attacks, in the order in which they will be executed
:type attack_list: list of Attacks
:raise: ValueError if input is invalid
"""
self._name = kwargs.get("name", "multiattack")
if not self._name or not isinstance(self._name, str):
self._name = "multiattack"
attack_list = kwargs.get("attacks")
if not isinstance(attack_list, (list, tuple)):
raise ValueError("Attacks must be a list or tuple")
self._attack_list = []
for attack in attack_list:
self.add_attack(attack)
if len(self._attack_list) < 2:
raise ValueError("MultiAttack must contain 2 or more Attacks")
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on the following characteristics:
attack list
:param other: the MultiAttack to be compared
:type other: MultiAttack
:return: True if *self* __eq__ *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_attacks() == other.get_attacks()
[docs]
def current_eq(self, other) -> bool:
"""
For this class, it is the same as __eq__.
:param other: the MultiAttack to be compared
:type other: MultiAttack
:return: True if *self* is identical to *other*, False otherwise
:rtype: bool
"""
return self == other
[docs]
def get_attacks(self) -> List[Attack]:
"""
:return: attacks
:rtype: list of Attacks
"""
return self._attack_list
[docs]
def get_attack_by_name(self, name) -> Optional[Attack]:
"""
Get an Attack from :py:attr:`_attack_list` with the given name
:param name: the name of the Attack
:type name: str
:return: the first Attack with the given name (or None, if no match is found)
:raise: ValueError if *name* is invalid
"""
if not isinstance(name, str):
raise ValueError("Name must be a string")
for attack in self.get_attacks():
if attack.get_name() == name:
return attack
return None
[docs]
def get_max_damage(self) -> int:
"""
:return: The maximum damage combined across all attacks
:rtype: non-negative integer
"""
max_damage = 0
for attack in self.get_attacks():
max_damage += attack.get_max_damage()
return max_damage
[docs]
def get_min_damage(self) -> int:
"""
:return: The minimum damage combined across all attacks
:rtype: non-negative integer
"""
min_damage = 0
for attack in self.get_attacks():
min_damage += attack.get_min_damage()
return min_damage
[docs]
def get_average_damage(self) -> int:
"""
:return: The average damage combined across all attacks
:rtype: non-negative integer
"""
average_damage = 0
for attack in self.get_attacks():
average_damage += attack.get_average_damage()
return average_damage
[docs]
def add_attack(self, attack: Attack):
"""
Add *attack* to the end of *self._attack_list*
:param attack: the Attack to add
:type attack: Attack
:return: None
:raise: ValueError if *attack* is not an Attack
"""
if not isinstance(attack, Attack):
raise ValueError("Each attack must be an Attack object")
self._attack_list.append(attack)
[docs]
def remove_attack(self, attack):
"""
Remove *attack* from :py:attr:`_attack_list`
:param attack: the Attack to remove (or the name of the Attack to remove)
:type attack: Attack or str
:return: None
"""
if isinstance(attack, str):
attack = self.get_attack_by_name(attack)
try:
self._attack_list.remove(attack)
except ValueError as error:
warnings.warn(str(error))
[docs]
def make_attack(self, source, target, adv: int = 0) -> Optional[int]:
"""
Convert *target* and *adv* to lists so that each attack has one target and one adv.
If *target* or *adv* is too short of a sequence, the last value in the provided list will be copied as needed.
For each attack in :py:meth:`get_attacks`, call the attack the appropriate target with the appropriate adv.
:param source: the Combatant that is making the attack
:type source: Combatant
:param target: the Combatant(s) that *source* is attacking
:type target: Combatant or list of Combatants
:param adv: one or more integers that indicates advantage on the attack roll
:return: the total damage taken
:rtype: non-negative integer
:raise: ValueError if *source* can't send attacks
"""
source.get_logger().info(f"{source.get_name()} attacks using {self.get_name()}")
if not isinstance(target, (list, tuple)):
targets = [target for attack in self.get_attacks()]
elif len(target) < len(self.get_attacks()):
targets = target
for i in range(len(target), len(self.get_attacks())):
targets.append(target[-1])
else:
targets = target
if not isinstance(adv, (list, tuple)):
advs = [adv for _ in range(len(self.get_attacks()))]
elif len(adv) < len(self.get_attacks()):
advs = adv
for i in range(len(adv), len(self.get_attacks())):
advs.append(adv[-1])
else:
advs = adv
total_damage = 0
for i in range(len(self.get_attacks())):
current_damage = self.get_attacks()[i].make_attack(source, target=targets[i], adv=advs[i])
if current_damage: # done to avoid errors from adding None
total_damage += current_damage
return total_damage
[docs]
def get_damage_dice(self) -> TYPE_DICE_TUPLE:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_damage_dice on one of the attacks in this multiattack")
[docs]
def get_attack_mod(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_attack_mod on one of the attacks in this multiattack")
[docs]
def get_damage_mod(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_damage_mod on one of the attacks in this multiattack")
[docs]
def get_damage_type(self) -> Optional[str]:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_damage_type on one of the attacks in this multiattack")
[docs]
def get_range(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_range on one of the attacks in this multiattack")
[docs]
def get_melee_range(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_melee_range on one of the attacks in this multiattack")
[docs]
def get_adv(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_adv on one of the attacks in this multiattack")
[docs]
def get_weapon(self) -> Optional[weapons.Weapon]:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_weapon on one of the attacks in this multiattack")
[docs]
def get_max_hit(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_max_hit on one of the attacks in this multiattack")
[docs]
def get_average_hit(self) -> int:
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call get_average_hit on one of the attacks in this multiattack")
[docs]
def set_attack_mod(self, attack_mod: int):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call set_attack_mod on one of the attacks in this multiattack")
[docs]
def set_damage_mod(self, damage_mod: int):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call set_damage_mod on one of the attacks in this multiattack")
[docs]
def shift_damage_mod(self, damage_mod: int):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call shift_damage_mod on one of the attacks in this multiattack")
[docs]
def roll_attack(self, adv=0):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call roll_attack on one of the attacks in this multiattack")
[docs]
def roll_damage(self, crit=0, crit_multiplier=2):
"""
:raise: NotImplementedError
"""
raise NotImplementedError("Call roll_damage on one of the attacks in this multiattack")