import warnings
from typing import Optional
from DnD_5e.combatant.character import Character
from DnD_5e.combatant.creature import Creature
from DnD_5e.combatant.spellcaster import SpellCaster
from DnD_5e.utility_methods_dnd import ability_to_mod, proficiency_bonus_per_level, TYPE_DICE_TUPLE
[docs]
class Druid(SpellCaster, Character):
"""
Druid character class
"""
def __init__(self, **kwargs):
"""
Validate the input and set the instance variables
:param kwargs: keyword arguments. Some of the keyword arguments are overridden by this class.
:param wild_shapes: all the Creatures available to shapeshift into
:type wild_shapes: set, list, or tuple of Creatures (will be converted to set of creatures)
:param name: what *self* is called. A unique name is recommended but not required
:type name: str
: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 ac: *self's* armor class
:type ac: positive integer
:param temp_hp: temporary hit points
:type temp_hp: non-negative integer
:param conditions: all conditions currently affecting *self*
:type conditions: list 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 death_saves: NOT IMPLEMENTED YET
:param attacks: NOT IMPLEMENTED YET
:param weapons: Weapons (see weapons module) that *self* has available to use
:type weapons: list of Weapons
:param size: size
:type size: one of these strings: "tiny", "small", "medium", "large", "huge", "gargantuan"
:param items: NOT IMPLEMENTED YET
:param level: character level
:type level: integer between 1 and 20 (inclusive)
:raise: ValueError if input is invalid
"""
level = kwargs.get("level")
if not level:
raise ValueError("No level provided or level is 0")
if not isinstance(level, int) or level < 1 or level > 20:
raise ValueError("Level must be an integer between 1 and 20")
hit_dice = (1 * level, 8)
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:
raise ValueError("Constitution mod must be an integer")
else:
raise ValueError("Must provide constitution score or modifier")
else: # pragma: no cover
constitution_mod = ability_to_mod(constitution)
max_hp = kwargs.get("max_hp")
if max_hp and (not isinstance(max_hp, int) or max_hp <= 0):
raise ValueError("Must provide positive integer max hp")
max_hp = 8 + constitution_mod
if level > 1:
max_hp += (5 + constitution_mod) * (level - 1)
proficiencies = kwargs.get('proficiencies')
if isinstance(proficiencies, (tuple, list, set)):
proficiencies = set(proficiencies)
elif proficiencies is None: # pragma: no cover
proficiencies = set()
else: # pragma: no cover
raise ValueError("Proficiencies must be provided as a set, list, or tuple")
proficiencies.add("club")
proficiencies.add("dagger")
proficiencies.add("javelin")
proficiencies.add("mace")
proficiencies.add("quarterstaff")
proficiencies.add("scimitar")
proficiencies.add("sickle")
proficiencies.add("sling")
proficiencies.add("spear")
proficiencies.add("intelligence")
proficiencies.add("wisdom")
proficiency_mod = proficiency_bonus_per_level(level)
if level == 1:
spell_slots = {1: 2}
elif level == 2:
spell_slots = {1: 3}
elif level == 3:
spell_slots = {1: 4, 2: 2}
elif level == 4:
spell_slots = {1: 4, 2: 3}
elif level == 5:
spell_slots = {1: 4, 2: 3, 3: 2}
elif level == 6:
spell_slots = {1: 4, 2: 3, 3: 3}
elif level == 7:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 1}
elif level == 8:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 2}
elif level == 9:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 1}
elif level == 10:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 2}
elif level < 13:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 2, 6: 1}
elif level < 15:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 2, 6: 1, 7: 1}
elif level < 17:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 2, 6: 1, 7: 1, 8: 1}
elif level == 17:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 2, 6: 1, 7: 1, 8: 1, 9: 1}
elif level == 18:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 3, 6: 1, 7: 1, 8: 1, 9: 1}
elif level == 19:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 3, 6: 2, 7: 1, 8: 1, 9: 1}
else:
spell_slots = {1: 4, 2: 3, 3: 3, 4: 3, 5: 3, 6: 2, 7: 2, 8: 1, 9: 1}
kwargs.update({"hit_dice": hit_dice, "max_hp": max_hp, "proficiencies": proficiencies,
"proficiency_mod": proficiency_mod, "spell_slots": spell_slots, "spell_ability": "wisdom"})
super().__init__(**kwargs)
if self.get_level() > 1:
self._wild_shape_slots = 2
wild_shapes = kwargs.get("wild_shapes")
self._wild_shapes = set()
if isinstance(wild_shapes, (list, tuple, set)):
for beast in wild_shapes:
if isinstance(beast, Creature):
self.add_wild_shape(beast)
else:
raise ValueError("Wild shapes must be Creatures")
elif wild_shapes is None:
warnings.warn("Created a druid of level 2 or above without any wild shapes")
self._wild_shapes = []
else:
raise ValueError("Wild shapes must be a list, tuple, or set of Creatures")
self._current_shape = None
[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:
wild shape slots, wild shapes, current shape
:param other: the Druid to be compared
:type other: Druid
:return: True if *self* is identical to *other*, False otherwise
:rtype: bool
"""
return super().current_eq(other) \
and self.get_wild_shape_slots() == other.get_wild_shape_slots() \
and self.get_wild_shapes() == other.get_wild_shapes() \
and self.get_current_shape() == other.get_current_shape()
[docs]
def get_wild_shape_slots(self) -> int:
"""
:return: wild shape slots
:rtype: int
"""
try:
return self._wild_shape_slots
except AttributeError:
return 0
[docs]
def get_wild_shapes(self) -> set:
"""
:return: wild shapes
:rtype: set of Creatures
"""
try:
return self._wild_shapes
except AttributeError:
return set()
[docs]
def get_current_shape(self) -> Optional[Creature]:
"""
:return: current shape (or None if in original shape)
:rtype: Creature (or None)
"""
try:
return self._current_shape
except AttributeError:
return None
[docs]
def get_ac(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: armor class
:rtype: positive integer
"""
if self.get_current_shape():
return self.get_current_shape().get_ac()
return super().get_ac()
[docs]
def get_hit_dice(self) -> TYPE_DICE_TUPLE:
"""
If a wild shape is active, use the statistics of the wild shape
:return: hit dice
:rtype: TYPE_DICE_TUPLE
"""
if self.get_current_shape():
return self.get_current_shape().get_hit_dice()
return super().get_hit_dice()
[docs]
def get_max_hp(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: max hit points
:rtype: positive integer
"""
if self.get_current_shape():
return self.get_current_shape().get_max_hp()
return super().get_max_hp()
[docs]
def get_temp_hp(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: temporary hit points
:rtype: non-negative integer
"""
if self.get_current_shape():
return 0 # beasts don't have temp hp. Right?
return super().get_temp_hp()
[docs]
def get_current_hp(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: current hit points
:rtype: non-negative integer
"""
if self.get_current_shape():
return self.get_current_shape().get_current_hp()
return super().get_current_hp()
[docs]
def is_bloodied(self) -> bool:
"""
Tell whether *self* is bloodied (current hit points at or below half of maximum).
If a wild shape is active, use the statistics of the wild shape.
:return: True if *self* is bloodied, False otherwise
:rtype: bool
"""
if self.get_current_shape():
return self.get_current_shape().is_bloodied()
return super().is_bloodied()
[docs]
def is_hp_max(self) -> bool:
"""
If a wild shape is active, use the statistics of the wild shape
:return: True if current hp equals max hp, False otherwise
:rtype: bool
"""
if self.get_current_shape():
return self.get_current_shape().is_hp_max()
return super().is_hp_max()
[docs]
def get_speed(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: speed
:rtype: positive integer
"""
if self.get_current_shape():
return self.get_current_shape().get_speed()
return super().get_speed()
[docs]
def get_vision(self) -> str:
"""
If a wild shape is active, use the statistics of the wild shape
:return: vision
:rtype: one of these strings: "normal", "darkvision", "blindsight", "truesight"
"""
if self.get_current_shape():
return self.get_current_shape().get_vision()
return super().get_vision()
[docs]
def get_ability(self, ability: str) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
: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 self.get_current_shape():
return self.get_current_shape().get_ability(ability)
return super().get_ability(ability)
[docs]
def get_strength(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: strength modifier
:rtype: int
"""
if self.get_current_shape():
return self.get_current_shape().get_strength()
return super().get_strength()
[docs]
def get_dexterity(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: dexterity modifier
:rtype: int
"""
if self.get_current_shape():
return self.get_current_shape().get_dexterity()
return super().get_dexterity()
[docs]
def get_constitution(self) -> int:
"""
If a wild shape is active, use the statistics of the wild shape
:return: constitution modifier
:rtype: int
"""
if self.get_current_shape():
return self.get_current_shape().get_constitution()
return super().get_constitution()
[docs]
def get_proficiencies(self) -> set:
"""
If a wild shape is active, use the statistics of the wild shape
:return: proficiencies
:rtype: set of strings
"""
if self.get_current_shape():
return self.get_current_shape().get_proficiencies()
return super().get_proficiencies()
[docs]
def get_vulnerabilities(self) -> set:
"""
If a wild shape is active, use the statistics of the wild shape
:return: vulnerabilities
:rtype: set of strings
"""
if self.get_current_shape():
return self.get_current_shape().get_vulnerabilities()
return super().get_vulnerabilities()
[docs]
def get_resistances(self) -> set:
"""
If a wild shape is active, use the statistics of the wild shape
:return: resistances
:rtype: set of strings
"""
if self.get_current_shape():
return self.get_current_shape().get_resistances()
return super().get_resistances()
[docs]
def get_immunities(self) -> set:
"""
If a wild shape is active, use the statistics of the wild shape
:return: immunities
:rtype: set of strings
"""
if self.get_current_shape():
return self.get_current_shape().get_immunities()
return super().get_immunities()
[docs]
def get_saving_throw(self, ability: str) -> int:
"""
Get the modifier for an *ability* saving throw. Note: this does NOT roll the saving throw.
If a wild shape is active, use the statistics of the wild shape.
: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
"""
if self.get_current_shape():
return self.get_current_shape().get_saving_throw(ability)
return super().get_saving_throw(ability)
[docs]
def get_attacks(self) -> list:
"""
If a wild shape is active, use the statistics of the wild shape
:return: attacks
:rtype: list of Attacks
"""
if self.get_current_shape():
return self.get_current_shape().get_attacks()
return super().get_attacks()
[docs]
def add_wild_shape(self, beast: Creature):
"""
Add a wild shape
:param beast: the wild shape to add
:type beast: Creature
:return: None
"""
if not isinstance(beast, Creature):
self.get_logger().error("Wild shape must be a Creature", stack_info=True)
raise ValueError("Wild shape must be a Creature")
if beast in self.get_wild_shapes():
self.get_logger().warning("Tried to add a wild shape you already have", stack_info=True)
warnings.warn("Tried to add a wild shape you already have")
return
# pylint: disable=protected-access
beast._intelligence = self._intelligence
beast._wisdom = self._wisdom
beast._charisma = self._charisma
for proficiency in self._proficiencies:
beast.get_proficiencies().add(proficiency)
# rebuild saving throws
beast._saving_throws = {"strength": beast._strength, "dexterity": beast._dexterity,
"constitution": beast._constitution, "intelligence": beast._intelligence,
"wisdom": beast._wisdom, "charisma": beast._charisma}
for ability in beast._saving_throws:
if ability in beast._proficiencies:
beast._saving_throws[ability] += self._proficiency_mod
# pylint: enable=protected-access
self._wild_shapes.append(beast)
[docs]
def can_cast(self, spell):
return super().can_cast(spell) and (not self.get_current_shape()
or (self.get_level() > 17 and not spell.has_component("material"))
or self.get_level() > 20)
[docs]
def take_damage(self, damage, damage_type=None, is_critical: bool = False):
"""
Take damage, applying vulnerabilities, resistances, and immunities as necessary.
If *self* has a wild shape active, that beast takes damage first.
If the beast reaches 0 hit points,
the wild shape ends and *self* goes back to their original form and takes the rest of the damage.
: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
"""
# TODO: minimize code duplication
if self.get_current_shape():
beast = self.get_current_shape()
if beast.is_vulnerable(damage_type):
self.get_logger().info("%s (in beast form) is vulnerable to %s!", self.get_name(), damage_type)
damage *= 2
elif beast.is_resistant(damage_type):
self.get_logger().info("%s (in beast form) is resistant to %s.", self.get_name(), damage_type)
damage //= 2
elif beast.is_immune(damage_type):
self.get_logger().info("%s (in beast form) is immune to %s.", self.get_name(), damage_type)
return
self.get_logger().info("%s (in beast form) takes %d damage", self.get_name(), damage)
# skip temp hp
if damage <= beast.get_current_hp():
beast._current_hp -= damage
else:
damage -= beast.get_current_hp()
self.end_shape()
super().take_damage(damage, damage_type, is_critical)
else:
super().take_damage(damage, damage_type, is_critical)
[docs]
def start_shape(self, beast: Creature):
"""
Shift into the given wild shape
:param beast: the beast to shift into
:type beast: Creature
:return: None
:raise: ValueError if *self* has no wild shape slots left
"""
if not isinstance(beast, Creature):
self.get_logger().error("Cannot shift into something that is not a creature", stack_info=True)
raise ValueError("Cannot shift into something that is not a creature")
if not self.get_wild_shape_slots():
self.get_logger().error("You don't have any wild shape slots left", stack_info=True)
raise ValueError("You don't have any wild shape slots left")
self._wild_shape_slots -= 1
if beast not in self.get_wild_shapes():
self.get_logger().warning("You are shifting into a Creature not assigned to you. Stats may not be set correctly", stack_info=True)
warnings.warn("You are shifting into a Creature not assigned to you. Stats may not be set correctly")
self.get_logger().info("%s shifts into %s", self.get_name(), beast.get_name())
self._current_shape = beast
[docs]
def end_shape(self):
"""
End the current wild shape
:return: None
:raise: ValueError if *self* has no wild shape active
"""
if not self._current_shape:
self.get_logger().error("Can't end wild shape when no wild shape is active", stack_info=True)
raise ValueError("Can't end wild shape when no wild shape is active")
self.get_logger().info("%s shifts out of %s into their original form.", self._name, self.get_current_shape().get_name())
self._current_shape = None