Source code for DnD_5e.character_classes.druid

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