from typing import Optional
from DnD_5e import dice
from DnD_5e.attack_class import Attack
[docs]
class SavingThrowMixin:
def __init__(self, **kwargs):
dc = kwargs.get("dc")
if not dc or not isinstance(dc, int) or dc < 0:
raise ValueError("DC must be a non-negative integer")
self._dc = dc
save_type = kwargs.get("save_type")
if save_type in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]:
self._save_type = save_type
else:
raise ValueError("Save type must be strength, dexterity, constitution, intelligence, wisdom, or charisma")
super().__init__(**kwargs)
def __eq__(self, other):
if other is self:
return True
if type(other) != type(self): # pylint: disable=unidiomatic-typecheck
return False
return super().__eq__(other) and self.get_dc() == other.get_dc() and self.get_save_type() == other.get_save_type()
[docs]
def get_dc(self) -> int:
"""
:return: DC
:rtype: non-negative integer
"""
return self._dc
[docs]
def get_save_type(self) -> str:
"""
:return: ability score for saving throws
:rtype: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"
"""
return self._save_type
[docs]
def get_prob_fail_save(self, **kwargs):
"""
:param target: the target you are forcing to make the saving throw
:type target: :py:class:`Combatant`
:param mod: the modifier for the target's saving throw. Use this or *target*
:type mod: int
:return: the probability that *target* (or somebody with a saving throw modifier of *mod*) fails the saving throw
"""
target = kwargs.get("target")
if target:
mod = target.get_saving_throw(self.get_save_type())
else:
mod = kwargs.get("mod")
return dice.Dice(dice_num=1, dice_type=20, modifier=mod).get_prob(self.get_dc(), kind="lt")
[docs]
def do_saving_thow(self, source, target) -> bool: # pylint: disable=unused-argument
return target.take_saving_throw(self.get_save_type(), self.get_dc(), self)
[docs]
class SavingThrowAttack(SavingThrowMixin, Attack):
"""
This class represents Attacks that require a saving throw from the target instead of a roll to hit
"""
def __init__(self, **kwargs):
"""
Validate the input and set the instance variables
:param kwargs: keyword arguments. Valid arguments are as follows:
:param dc: the difficulty class for the saving throw
:type dc: non-negative integer
:param save_type: the ability score to be used for the saving throw
:type save_type: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"
:param damage_on_success: if this evaluates to True, the target takes half damage on a successful save
:param name: what *self* is called. A unique name is recommended but not required
:type name: str
:param damage_dice: the dice to determine damage points
:type damage_dice: TYPE_DICE_TUPLE or dice.DamageDice
:param attack_mod: the number to be added to attack rolls
:type attack_mod: int
:param damage_mod: the number to be added to damage rolls
:type damage_mod: int
: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
:raise: ValueError if input is invalid
"""
attack_dice = kwargs.get("attack_dice")
if not attack_dice:
kwargs.update(attack_dice=dice.NullDice)
super().__init__(**kwargs) # irrelevant keyword arguments will be ignored
damage_on_success = kwargs.get("damage_on_success")
self._damage_on_success = bool(damage_on_success) # half damage on successful save
def __eq__(self, other):
"""
Compare *self* and *other* to determine if they are equal based on
what is checked in the superclass method as well as dc, save_type, and damage_on_success
:param other: the SavingThrowAttack to be compared
:type other: SavingThrowAttack
:return: True if *self* __eq__ *other*, False otherwise
:rtype: bool
"""
return super().__eq__(other) and self.get_damage_on_success() == other.get_damage_on_success()
[docs]
def get_damage_on_success(self) -> bool:
"""
:return: damage on success
:rtype: bool
"""
return self._damage_on_success
[docs]
def get_prob_hit(self, **kwargs) -> float:
"""
:param target: the target who is making the saving throw
:param mod: the target's modifier to the saving throw
:return: the probability of target failing their saving throw
"""
return self.get_prob_fail_save(**kwargs)
[docs]
def get_dpr(self, **kwargs) -> float:
"""
:param target: the target you are trying to hit
:type target: :py:class:`Combatant`
:return: the average damage per round (dpr)
"""
prob_hit = self.get_prob_hit(**kwargs)
result = prob_hit * self.get_average_damage()
if self.get_damage_on_success():
result += (1 - prob_hit) * (self.get_average_damage() // 2)
return result
[docs]
def make_attack(self, source, target, adv=0) -> Optional[int]:
"""
Call *target*. :py:meth:`take_saving_throw` to see if the target makes the save.
Call :py:meth:`send_damage` (that method will handle whether to damage on a successful save).
: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 saving throw
: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 *target* can't take attacks
"""
try:
# pylint: disable=consider-using-f-string
source.get_logger().info("%s attacks %s with %s." % (source.get_name(), target.get_name(), self.get_name()))
if self.do_saving_thow(source, target): # pylint: disable=no-else-return
# take_saving_throw returns True if target made the save
source.get_logger().info("%s saves." % target.get_name())
return self.on_miss(source, target)
else:
source.get_logger().info("%s fails the saving throw!" % target.get_name())
return self.on_hit(source, target)
except NameError:
source.get_logger().error("Tried to attack something that can't take attacks", stack_info=True)
raise ValueError(f"{source.get_name()} tried to attack something that can't take attacks")
[docs]
def on_hit(self, source, target, adv=0, crit=0):
"""
The target failed the save, now do whatever happens on a failed save
: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
"""
return self.send_damage(target)
[docs]
def on_miss(self, source, target, adv=0, crit=0):
"""
The target made the save, now do whatever happens on a successful save
: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
"""
return self.send_damage(target, saved=True)
[docs]
def send_damage(self, target, saved: bool = False) -> Optional[int]: # pylint: disable=arguments-differ
"""
Roll damage using *self.roll_damage* and store the result in variable *damage*.
Call *target*. :py:meth:`take_damage`, passing in *damage* and :py:attr:`_damage_type`.
Arguments crit and crit_multiplier are not needed because crits are not possible
:param target: the Combatant that is taking the damage
:type target: Combatant
:param saved: if this is True, damage is none if not :py:attr:`_damage_on_success` or half damage if :py:attr:`_damage_on_success`
:type saved: bool
:return: the damage taken (as returned by *target.take_damage*)
:rtype: non-negative integer or None
"""
roll_result = self.roll_damage()
try:
roll_result.roll_number # pylint: disable=pointless-statement
# stick namedtuple in list so that it can be processed the same way as output from DamageDiceBag
roll_result = [roll_result]
except AttributeError: # DamageDiceBag
pass
for result in roll_result:
if not saved:
if target.has_feature("evasion") and self._save_type == "dexterity":
return target.take_damage(result.roll_number // 2, damage_type=result.damage_type)
return target.take_damage(result.roll_number, damage_type=result.damage_type)
if self._damage_on_success:
if target.has_feature("evasion") and self._save_type == "dexterity":
return 0
return target.take_damage(result.roll_number // 2, damage_type=result.damage_type)
return None
[docs]
class SaveOrDieAttack(SavingThrowMixin, Attack):
"""
Attacks where the target must make a saving throw and if it fails it dies.
Does not work for attacks that have a saving throw and then a save or die. For that, you need a different approach (maybe a MultiAttack?)
"""
def __init__(self, **kwargs):
"""
Create the attack
:param kwargs: see superclasses
:param threshold: the hp below which the target must save or die
:type threshold: int
:param save_on_miss: whether the target still has to make the saving throw if the main attack missed
:type save_on_miss: bool
"""
if not kwargs.get("damage_dice"):
kwargs.update({"damage_dice": dice.NullDamageDice()})
self._threshold = kwargs.get("threshold")
if not isinstance(self._threshold, int):
raise ValueError("Must provide threshold (the hp below which the target must save or die)")
self._save_on_miss = bool(kwargs.get("save_on_miss"))
super().__init__(**kwargs)
[docs]
def get_threshold(self) -> int:
"""
:return: the hp below which the target must save or die
"""
return self._threshold
[docs]
def get_save_on_miss(self) -> bool:
"""
:return: True if the target still has to make the saving throw if the main attack missed, False otherwise
"""
return self._save_on_miss
[docs]
def get_dpr(self, **kwargs) -> float:
"""
Calculate the damage per round, assuming that if the target fails the saving throw (and thus dies),
they also take the damage of
:param target: the target you are trying to hit
:type target: :py:class:`Combatant`
:return: the average damage per round (dpr)
"""
target = kwargs.get("target")
prob_hit = self.get_prob_hit(target=target)
# calculate the damage you need to take to get below the threshold
damage_to_below_threshold = target.get_current_hp() - self.get_threshold() + 1
# possibility 1: failed attack. apply "save or die" if relevant.
if self.get_save_on_miss() and damage_to_below_threshold < 1:
miss_dpr = (1 - prob_hit) * target.get_current_hp()
else:
miss_dpr = 0
# possibility 2: successful attack. hp cannot possibly get low enough to trigger "save or die".
if damage_to_below_threshold > self.get_damage_dice().get_max_value():
hit_dpr = prob_hit * self.get_average_damage()
else:
# TODO: double check
hit_dpr = prob_hit * self.get_damage_dice().get_prob(target.get_current_hp(), kind="ge") * target.get_current_hp()
# possibility 3: successful attack. hp might get low enough to trigger "save or die".
if damage_to_below_threshold > 0:
prob_below_threshold = self.get_damage_dice().get_prob(damage_to_below_threshold, kind="lt")
else:
prob_below_threshold = 1
prob_fail_save = self.get_prob_fail_save(**kwargs)
hit_dpr = prob_hit * (self.get_average_damage() + prob_below_threshold * prob_fail_save * (self.get_threshold() - 1))
return miss_dpr + hit_dpr
[docs]
def make_attack(self, source, target, adv: int = 0):
"""
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
"""
damage_taken = super().make_attack(source, target, adv)
if damage_taken is not None or self.get_save_on_miss():
if target.get_current_hp() < self.get_threshold():
if self.do_saving_thow(source, target):
target.get_logger().info("%s saves and avoids death.", target.get_name())
else:
target.get_logger().info("%s failed the save and will now die.", target.get_name())
target.die()
[docs]
class HitAndSaveAttack(SavingThrowMixin, Attack):
"""
Attacks where the attacker makes a regular (roll to hit) attack and then, if that hits, makes a saving throw attack
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
save_damage_dice = kwargs.get("save_damage_dice")
if not save_damage_dice:
if not self._weapon:
raise ValueError("Must provide damage dice or a weapon to get damage dice from")
self._save_damage_dice = self._weapon.get_damage_dice().get_copy()
elif isinstance(save_damage_dice, dice.DamageDice):
self._save_damage_dice = save_damage_dice.get_copy()
else:
if isinstance(save_damage_dice, tuple):
kwargs.update(dice_tuple=save_damage_dice)
elif isinstance(save_damage_dice, str):
kwargs.update(str_val=save_damage_dice)
kwargs.update(modifier=kwargs.get("save_damage_mod", 0))
kwargs.update(critable=False)
kwargs.update(adv=0) # can't have advantage or disadvantage on damage
kwargs.update(damage_type=kwargs.get("save_damage_type"))
self._save_damage_dice = dice.DamageDice(**kwargs)
self._damage_on_success = bool(kwargs.get("damage_on_success"))
def __eq__(self, other):
return super().__eq__(other) and self.get_save_damage_dice() == other.get_save_damage_dice() \
and self.get_damage_on_success() == other.get_damage_on_success()
[docs]
def get_save_damage_dice(self):
"""
:return: saving throw damage dice
"""
return self._save_damage_dice
[docs]
def get_save_damage_type(self) -> str:
"""
:return: damage type for saving throw damage
"""
return self.get_save_damage_dice().get_damage_type()
[docs]
def get_damage_on_success(self) -> bool:
"""
:return: whether the target takes damage on a successful save
"""
return self._damage_on_success
[docs]
def on_hit(self, source, target, adv=0, crit=0):
"""
The attack hit! Do all the normal Attack stuff (i.e., send damage), then have the target make a saving throw.
: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 total damage taken
"""
damage = super().on_hit(source, target, crit)
save_result = self.do_saving_thow(source, target)
save_damage = self.send_save_damage(target, saved=save_result)
if save_damage:
damage += save_damage
return damage
[docs]
def roll_save_damage(self):
"""
Roll :py:attr:`_damage_dice` (if crit == 1, multiply the number of dice to roll by *crit_multiplier*),
then add :py:attr:`_damage_mod`
:return: the damage rolled
:rtype: namedtuple
:raise: ValueError if *crit_multiplier* is invalid
"""
return self.get_damage_dice().roll_dice()
[docs]
def send_save_damage(self, target, saved: bool = False) -> Optional[int]: # pylint: disable=arguments-differ
"""
Roll damage using *self.roll_save_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 saved: if this is True, damage is none if not :py:attr:`_damage_on_success` or half damage if :py:attr:`_damage_on_success`
:type saved: bool
:return: the damage taken (as returned by *target.take_damage*)
:rtype: non-negative integer or None
"""
roll_result = self.roll_save_damage()
try:
roll_result.roll_number # pylint: disable=pointless-statement
# stick namedtuple in list so that it can be processed the same way as output from DamageDiceBag
roll_result = [roll_result]
except AttributeError: # DamageDiceBag
pass
for result in roll_result:
if not saved:
if target.has_feature("evasion") and self._save_type == "dexterity":
return target.take_damage(result.roll_number // 2, damage_type=result.damage_type)
return target.take_damage(result.roll_number, damage_type=result.damage_type)
if self._damage_on_success:
if target.has_feature("evasion") and self._save_type == "dexterity":
return 0
return target.take_damage(result.roll_number // 2, damage_type=result.damage_type)
return None