import DnD_5e.attack_class.spell_attacks
from DnD_5e.combatant import Combatant
[docs]
class SpellCaster(Combatant):
"""
A Combatant that can cast spells
"""
def __init__(self, **kwargs):
"""
Validate the input and set the instance variables
:param kwargs: keyword arguments. Uses all keyword arguments from superclass, plus these:
:param spell_ability: the ability used for spellcasting
:type spell_ability: str
:param spell_slots: how many slots *self* has for each spell level
:type spell_slots: dict that maps int (spell level) to int (spell slots)
:param spells: the Spells that *self* knows how to cast
:type spells: list or tuple of Spells
:raise: ValueError if input is invalid
"""
super().__init__(**kwargs)
spell_ability = kwargs.get("spell_ability", "wisdom")
try:
self._spell_ability_mod = self.get_ability(spell_ability)
self._spell_ability = spell_ability
except ValueError:
self.get_logger().error("Spell ability must be strength, dexterity, constitution, "
"intelligence, wisdom, or charisma", stack_info=True)
raise ValueError(
"Spell ability must be strength, dexterity, constitution, intelligence, wisdom, or charisma")
self._spell_save_dc = 8 + self._proficiency_mod + self._spell_ability_mod
self._spell_attack_mod = self._proficiency_mod + self._spell_ability_mod
spell_slots = kwargs.get("spell_slots")
if isinstance(spell_slots, dict):
for key in spell_slots:
if key not in list(range(1, 10)) or not (isinstance(spell_slots[key], int) and spell_slots[key] > -1):
self.get_logger().error("Spell slots must be a dictionary mapping "
"spell levels (1-20) to slots (0 or more)", stack_info=True)
raise ValueError(
"Spell slots must be a dictionary mapping spell levels (1-20) to slots (0 or more)")
elif not spell_slots:
for i in range(1, 10):
value = kwargs.get(f"level_{i}")
if value:
if isinstance(value, int):
spell_slots[i] = value
else:
self.get_logger().error("Spell slot number for level %d must be an integer", i,
stack_info=True)
raise ValueError(f"Spell slot number for level {i} must be an integer")
else:
self.get_logger().error("Spell slots must be a dictionary. {1: 3, 2:1} "
"would mean 3 1st level spells and 1 2nd level spell", stack_info=True)
raise ValueError(
"Spell slots must be a dictionary. {1: 3, 2:1} would mean 3 1st level spells and 1 2nd level spell")
self._spell_slots = spell_slots
self._full_spell_slots = {}
self._full_spell_slots.update(self._spell_slots)
self._spells = []
spells = kwargs.get("spells")
if not spells:
self.get_logger().warning("Created a SpellCaster with no spells")
elif not isinstance(spells, (list, tuple)):
self.get_logger().error("Spells must be a list or tuple", stack_info=True)
raise ValueError("Spells must be a list or tuple")
else:
for spell in spells:
try:
self.add_spell(spell)
except ValueError:
self.get_logger().error("spells must contain only Spells", stack_info=True)
raise ValueError("spells must contain only Spells")
def __eq__(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are equal based on the superclass method and these attributes:
:py:attr:`spell_ability`, :py:attr:`spell_ability_mod`, :py:attr:`spell_save_dc`, :py:attr:`spell_attack_mod`,
:py:attr:`spells`, and :py:attr:`full_spell_slots`
:param other: the SpellCaster to compare
:type other: SpellCaster
:return: True if *self* equals *other*, False otherwise
:rtype: bool
"""
return super().__eq__(other) \
and self.get_spell_ability() == other.get_spell_ability() \
and self.get_spell_ability_mod() == other.get_spell_ability_mod() \
and self.get_spell_save_dc() == other.get_spell_save_dc() \
and self.get_spell_attack_mod() == other.get_spell_attack_mod() \
and self.get_spells() == other.get_spells() \
and self.get_full_spell_slots() == other.get_full_spell_slots()
[docs]
def current_eq(self, other) -> bool:
"""
Compare *self* and *other* to determine if they are identical based on the attributes checked in *equals*
and also these attributes: :py:attr:`spell_slots`
:param other: the SpellCaster to compare
:type other: SpellCaster
:return: True if *self* is identical to *other*, False otherwise
:rtype: bool
"""
return super().current_eq(other) and self.get_spell_slots() == other.get_spell_slots()
[docs]
def get_spell_ability(self) -> str:
"""
:return: spell ability
:rtype: str
"""
return self._spell_ability
[docs]
def get_spell_ability_mod(self) -> int:
"""
:return: spell ability mod
:rtype: int
"""
return self._spell_ability_mod
[docs]
def get_spell_save_dc(self) -> int:
"""
:return: spell save dc
:rtype: int
"""
return self._spell_save_dc
[docs]
def get_spell_attack_mod(self) -> int:
"""
:return: spell attack mod
:rtype: int
"""
return self._spell_attack_mod
[docs]
def get_spell_slots(self) -> dict:
"""
:return: spell slots
:rtype: dict mapping int to int
"""
return self._spell_slots
[docs]
def get_full_spell_slots(self) -> dict:
"""
:return: full/maximum spell slots
:rtype: dict mapping int to int
"""
return self._full_spell_slots
[docs]
def get_level_spell_slots(self, level: int) -> int:
"""
Determine the number of spell slots available for a given level
:param level: the spell level to look at
:type level: integer from 1 to 9 (inclusive)
:return: the number of available spell slots
:rtype: int
:raise: ValueError if *level* is invalid
"""
if not isinstance(level, int):
raise ValueError("Level provided must be an integer")
try:
return self._spell_slots[level]
except KeyError:
return 0
[docs]
def get_spells(self) -> list:
"""
:return: spells
:rtype: list of Spells
"""
return self._spells
[docs]
def can_cast(self, spell: DnD_5e.attack_class.spell_attacks.Spell) -> bool:
"""
Determine if *self* can cast the given Spell. Note: does not check spell slots.
:param spell: the Spell *self* is trying to cast
:type spell: py:class:`Spell`
:return: True if *self* can cast *spell*, False otherwise
:rtype: bool
"""
# TODO: check conditions that would impair verbal, somatic, or material components
result = self.get_armor() is None or self.has_armor_proficiency(self.get_armor())
if self.has_feature_method("can_cast"):
result = result and self.get_feature_dict()["can_cast"](self, spell)
return result
[docs]
def add_spell(self, spell: DnD_5e.attack_class.spell_attacks.Spell):
"""
Add a given spell
:param spell: the Spell to add
:type spell: Spell
:return: None
:raise: ValueError if *spell* is not a Spell
"""
if isinstance(spell, DnD_5e.attack_class.spell_attacks.Spell):
if isinstance(spell, DnD_5e.attack_class.spell_attacks.HealingSpell):
spell.set_damage_mod(self._spell_ability_mod)
spell.set_attack_mod(self._spell_attack_mod)
self._spells.append(spell)
self.get_logger().info("%s adds spell %s", self.get_name(), spell.get_name())
else:
self.get_logger().error("Cannot add a non-Spell object as an attack.", stack_info=True)
raise ValueError("Cannot add a non-Spell object as an attack.")
[docs]
def spend_spell_slot(self, level: int, spell=None): # pylint: disable=unused-argument
"""
Spend a spell slot of the given level
:param level: the spell level
:type level: an integer from 0 to 9 (inclusive)
:param spell: the spell that is being used
:type spell: Spell
:return: None
:raise: ValueError if *self* doesn't have any spell slots of *level* level
"""
if level == 0:
return # don't spend a slot
if not isinstance(level, int) or not (0 < level < 10): # pylint: disable=superfluous-parens
self.get_logger().error("level must be an integer between 1 and 9", stack_info=True)
raise ValueError("level must be an integer between 1 and 9")
if self.get_level_spell_slots(level) > 0:
self._spell_slots[level] -= 1
else:
self.get_logger().error("Tried to use a level %d spell slot that you don't have", level, stack_info=True)
raise ValueError("Tried to use a level %d spell slot that you don't have" % level)
[docs]
def reset_spell_slots(self):
"""
Set spell slots to the maximum/full amount
:return: None
"""
self.get_logger().info("%s resetting spell slots", self.get_name())
spell_slots = {}
spell_slots.update(self.get_full_spell_slots())
self._spell_slots = spell_slots # pylint: disable=protected-access
self.get_logger().info("%s resets spell slots", self.get_name())