Files
dungeon-sheets/dungeonsheets/character.py
T

1061 lines
35 KiB
Python

"""Tools for describing a player character."""
import os
import re
import warnings
import math
from types import ModuleType
from typing import Sequence, Union, MutableMapping
import jinja2
from dungeonsheets import (
armor,
background,
classes,
features,
infusions,
magic_items,
monsters,
race,
spells,
weapons,
)
from dungeonsheets.content_registry import find_content
from dungeonsheets.weapons import Weapon
from dungeonsheets.entity import Entity
dice_re = re.compile(r"(\d+)d(\d+)")
__all__ = (
"Artificer",
"Barbarian",
"Bard",
"Cleric",
"Character",
"Druid",
"Fighter",
"Monk",
"Paladin",
"Ranger",
"Rogue",
"Sorceror",
"Warlock",
"Wizard",
)
multiclass_spellslots_by_level = {
# char_lvl: (cantrips, 1st, 2nd, 3rd, ...)
1: (0, 2, 0, 0, 0, 0, 0, 0, 0, 0),
2: (0, 3, 0, 0, 0, 0, 0, 0, 0, 0),
3: (0, 4, 2, 0, 0, 0, 0, 0, 0, 0),
4: (0, 4, 3, 0, 0, 0, 0, 0, 0, 0),
5: (0, 4, 3, 2, 0, 0, 0, 0, 0, 0),
6: (0, 4, 3, 3, 0, 0, 0, 0, 0, 0),
7: (0, 4, 3, 3, 1, 0, 0, 0, 0, 0),
8: (0, 4, 3, 3, 2, 0, 0, 0, 0, 0),
9: (0, 4, 3, 3, 3, 1, 0, 0, 0, 0),
10: (0, 4, 3, 3, 3, 2, 0, 0, 0, 0),
11: (0, 4, 3, 3, 3, 2, 1, 0, 0, 0),
12: (0, 4, 3, 3, 3, 2, 1, 0, 0, 0),
13: (0, 4, 3, 3, 3, 2, 1, 1, 0, 0),
14: (0, 4, 3, 3, 3, 2, 1, 1, 0, 0),
15: (0, 4, 3, 3, 3, 2, 1, 1, 1, 0),
16: (0, 4, 3, 3, 3, 2, 1, 1, 1, 0),
17: (0, 4, 3, 3, 3, 2, 1, 1, 1, 1),
18: (0, 4, 3, 3, 3, 3, 1, 1, 1, 1),
19: (0, 4, 3, 3, 3, 3, 2, 1, 1, 1),
20: (0, 4, 3, 3, 3, 3, 2, 2, 1, 1),
}
def _resolve_mechanic(mechanic, SuperClass, warning_message=None):
"""Take a raw entry in a character sheet and turn it into a usable object.
Eg: spells can be defined in many ways. This function accepts all
of those options and returns an actual *Spell* class that can be
used by a character::
>>> _resolve_mechanic("mage_hand", SuperClass=spells.Spell)
>>> _resolve_mechanic("mage_hand", SuperClass=None)
>>> from dungeonsheets import spells
>>> class MySpell(spells.Spell): pass
>>> _resolve_mechanic(MySpell, SuperClass=spells.Spell)
>>> _resolve_mechanic("hocus pocus", SuperClass=spells.Spell)
The acceptable entries for *mechanic*, in priority order, are:
1. A subclass of *SuperClass*
2. A string with the name of defined content
3. The name of an unknown spell (creates generic object using *factory*)
*SuperClass* can be ``None`` to match any class, however this will
raise an exception if more than one content type has this
name. For example, "shield" can refer to both the armor or the
spell, so ``_resolve_mechanic("shield")`` will raise an exception.
Parameters
==========
mechanic : str, type
The thing to be resolved, either a string with the name of the
mechanic, or a subclass of *ParentClass* describing the
mechanic.
SuperClass : type
Class to determine whether *mechanic* should just be allowed
through as is.
error_message : str, optional
A string whose ``str.format()`` method (receiving one positional
argument *mechanic*) will be used for displaying a warning when an
unknown mechanic is resolved. If omitted, no warning will be
displayed.
Returns
=======
Mechanic
A class representing the resolved game mechanic. This will
likely be a subclass of *SuperClass* if the other parameters are
well behaved, but this is not enforced.
"""
is_already_resolved = isinstance(mechanic, type) and issubclass(
mechanic, SuperClass
)
if is_already_resolved:
Mechanic = mechanic
else:
try:
# Retrieve pre-defined mechanic
valid_classes = [SuperClass] if SuperClass is not None else []
Mechanic = find_content(mechanic, valid_classes=valid_classes)
except AttributeError:
# No pre-defined mechanic available
if warning_message is not None:
# Emit the warning
msg = warning_message.format(mechanic)
warnings.warn(msg)
else:
# Create a generic message so we can make a docstring later.
msg = f'Mechanic "{mechanic}" not defined. Please add it.'
# Create generic mechanic from the factory
class_name = "".join([s.title() for s in mechanic.split("_")])
mechanic_name = mechanic.replace("_", " ").title()
attrs = {"name": mechanic_name, "__doc__": msg, "source": "Unknown"}
Mechanic = type(class_name, (SuperClass,), attrs)
return Mechanic
class Character(Entity):
"""A generic player character."""
# Character-specific
player_name = ""
xp = 0
# Extra hit points info, for characters only
hp_current = None
hp_temp = 0
# Base stats (ability scores)
inspiration = False
attacks_and_spellcasting = ""
class_list = list()
_background = None
# Characteristics
personality_traits = (
"TODO: Describe how your character behaves, interacts with others"
)
ideals = "TODO: Describe what values your character believes in."
bonds = "TODO: Describe your character's commitments or ongoing quests."
flaws = "TODO: Describe your character's interesting flaws."
features_and_traits = "Describe any other features and abilities."
_proficiencies_text = list()
# Appearance
# portrait = placeholder not sure how to implement
age = 0
height = ""
weight = ""
eyes = ""
skin = ""
hair = ""
# Background
allies = ""
faction_name = ""
# faction_symbol = placeholder not sure how to implement
backstory = ""
other_feats_traits = ""
treasure = ""
def __init__(
self,
classes: Sequence = [],
levels: Sequence[int] = [],
subclasses: Sequence = [],
**attrs,
):
"""Create a new character from attributes *attrs*.
**Multiclassing** can be accomplished by a list of class names
*classes*, and a list of class levels *levels*.
Parameters
==========
classes
Strings with class names, or character class definitions
representing the characters various D&D classes.
levels
The class levels for each corresponding class entry in
*classes*.
subclasses
Subclasses that apply for this character.
**attrs
Additional keyword parameters to set as attributes for this
character.
"""
super(Character, self).__init__()
self.clear()
# make sure class, race, background are set first
my_classes = classes
my_levels = levels
my_subclasses = subclasses
# backwards compatability
if len(my_classes) == 0:
if "class" in attrs:
my_classes = [attrs.pop("class")]
my_levels = [attrs.pop("level", 1)]
my_subclasses = [attrs.pop("subclass", None)]
else: # if no classes or levels given, default to Lvl 1 Fighter
my_classes = ["Fighter"]
my_levels = [1]
my_subclasses = [None]
# Generate the list of class objects
self.add_classes(
my_classes,
my_levels,
my_subclasses,
feature_choices=attrs.get("feature_choices", []),
)
# parse race and background
self.race = attrs.pop("race", None)
self.background = attrs.pop("background", None)
# parse all other attributes
self.set_attrs(**attrs)
self.__set_max_hp(attrs.get("hp_max", None))
def clear(self):
# reset class-defined items
self.class_list = list()
self.weapons = list()
self.magic_items = list()
self._saving_throw_proficiencies = tuple()
self.other_weapon_proficiencies = tuple()
self.skill_proficiencies = list()
self.skill_expertise = list()
self._proficiencies_text = list()
self._spells = list()
self._spells_prepared = list()
self.infusions = list()
self.custom_features = list()
self.feature_choices = list()
def __str__(self):
return self.name
def __repr__(self):
return f"<{self.class_name}: {self.name}>"
def add_class(
self,
cls: (classes.CharClass, type, str),
level: (int, str),
subclass=None,
feature_choices: Sequence = [],
):
"""Add a class, level, and subclass the character has attained."""
if isinstance(cls, str):
cls = cls.strip().title().replace(" ", "")
try:
cls = getattr(classes, cls)
except AttributeError:
raise AttributeError(
"class was not recognized from classes.py: {:s}".format(cls)
)
if isinstance(level, str):
level = int(level)
self.class_list.append(
cls(level, owner=self, subclass=subclass, feature_choices=feature_choices)
)
def add_classes(
self,
classes_list: Sequence[Union[str, classes.CharClass]] = [],
levels: Sequence[Union[int, float, str]] = [],
subclasses: Sequence = [],
feature_choices: Sequence = [],
):
"""Add several classes, levels, etc.
The lists can also be single values for a single class
character.
"""
if isinstance(classes_list, str):
classes_list = [classes_list]
if (
isinstance(levels, int)
or isinstance(levels, float)
or isinstance(levels, str)
):
levels = [levels]
if len(levels) == 0:
levels = [1] * len(classes_list)
if isinstance(subclasses, str):
subclasses = [subclasses]
if len(subclasses) == 0:
subclasses = [None] * len(classes_list)
assert len(classes_list) == len(
levels
), "the length of classes {:d} does not match length of levels {:d}".format(
len(classes), len(levels)
)
assert len(classes_list) == len(
subclasses
), "the length of classes {:d} does not match length of subclasses {:d}".format(
len(classes_list), len(subclasses)
)
for cls, lvl, sub in zip(classes_list, levels, subclasses):
params = {}
params["feature_choices"] = feature_choices
self.add_class(cls=cls, level=lvl, subclass=sub, **params)
@property
def race(self):
return self._race
@race.setter
def race(self, newrace):
if isinstance(newrace, race.Race):
self._race = newrace
self._race.owner = self
elif isinstance(newrace, type) and issubclass(newrace, race.Race):
self._race = newrace(owner=self)
elif isinstance(newrace, str):
try:
self._race = find_content(newrace, valid_classes=[race.Race])(
owner=self
)
except AttributeError:
msg = f'Race "{newrace}" not defined. Please add it to ``race.py``'
self._race = race.Race(owner=self)
warnings.warn(msg)
elif newrace is None:
self._race = race.Race(owner=self)
@property
def background(self):
return self._background
@background.setter
def background(self, bg):
if isinstance(bg, background.Background):
self._background = bg
self._background.owner = self
elif isinstance(bg, type) and issubclass(bg, background.Background):
self._background = bg(owner=self)
elif isinstance(bg, str):
try:
self._background = find_content(
bg, valid_classes=[background.Background]
)(owner=self)
except AttributeError:
msg = (
f'Background "{bg}" not defined. Please add it to ``background.py``'
)
self._background = background.Background(owner=self)
warnings.warn(msg)
@property
def class_name(self):
if self.num_classes >= 1:
return self.primary_class.name
else:
return ""
@property
def classes_and_levels(self):
return " / ".join([f"{c.name} {c.level}" for c in self.class_list])
@property
def class_names(self):
return [c.name for c in self.class_list]
@property
def levels(self):
return [c.level for c in self.class_list]
@property
def subclasses(self):
return [c.subclass for c in self.class_list if c.subclass is not None]
@property
def level(self):
return sum(c.level for c in self.class_list)
@level.setter
def level(self, new_level):
self.primary_class.level = new_level
if self.num_classes > 1:
warnings.warn(
"Unable to tell which level to set. Updating "
"level of primary class {:s}".format(self.primary_class.name)
)
@property
def num_classes(self):
return len(self.class_list)
@property
def has_class(self):
return self.num_classes > 0
@property
def primary_class(self):
# for now, assume first class given must be primary class
if self.has_class:
return self.class_list[0]
else:
return None
def __set_max_hp(self, hp_max):
"""
Set maximum HP based on value in charlist py or calc from classes
"""
if hp_max:
assert isinstance(hp_max, int), hp_max.__class__
self.hp_max = hp_max
else:
const_mod = self.constitution.modifier
level_one_hp = self.primary_class.hit_dice_faces + const_mod
self.hp_max = level_one_hp
for char_cls in self.class_list:
hp_per_lvl = char_cls.hit_dice_faces / 2 + 1 + const_mod
levels = char_cls.level
if char_cls == self.primary_class:
levels -= 1
assert levels >= 0
self.hp_max += int(hp_per_lvl * levels)
@property
def weapon_proficiencies(self):
wp = set(self.other_weapon_proficiencies)
if self.num_classes > 0:
wp |= set(self.primary_class.weapon_proficiencies)
if self.num_classes > 1:
for c in self.class_list[1:]:
wp |= set(c.multiclass_weapon_proficiencies)
if self.race is not None:
wp |= set(getattr(self.race, "weapon_proficiencies", ()))
if self.background is not None:
wp |= set(getattr(self.background, "weapon_proficiencies", ()))
return tuple(wp)
@weapon_proficiencies.setter
def weapon_proficiencies(self, new_weapons):
self.other_weapon_proficiencies = tuple(new_weapons)
@property
def other_weapon_proficiencies_text(self):
return tuple(w.name for w in self.other_weapon_proficiencies)
@property
def features(self):
fts = set(self.custom_features)
fighting_style_defined = False
set_of_fighting_styles = {
"Fighting Style (Archery)",
"Fighting Style (Defense)",
"Fighting Style (Dueling)",
"Fighting Style (Great Weapon Fighting)",
"Fighting Style (Protection)",
"Fighting Style (Two-Weapon Fighting)",
}
for temp_feature in fts:
fighting_style_defined = temp_feature.name in set_of_fighting_styles
if fighting_style_defined:
break
if not self.has_class:
return fts
for c in self.class_list:
fts |= set(c.features)
for feature in fts:
if (
fighting_style_defined
and feature.name == "Fighting Style (Select One)"
):
temp_feature = feature
fts.remove(temp_feature)
break
if self.race is not None:
fts |= set(getattr(self.race, "features", ()))
# some races have level-based features (Ex: Aasimar)
if hasattr(self.race, "features_by_level"):
for lvl in range(1, self.level + 1):
fts |= set(self.race.features_by_level[lvl])
if self.background is not None:
fts |= set(getattr(self.background, "features", ()))
return sorted(tuple(fts), key=(lambda x: x.name))
@property
def custom_features_text(self):
return tuple([f.name for f in self.custom_features])
def has_feature(self, feat):
return any([isinstance(f, feat) for f in self.features])
@property
def saving_throw_proficiencies(self):
if self.primary_class is None:
return self._saving_throw_proficiencies
else:
return (
self._saving_throw_proficiencies
or self.primary_class.saving_throw_proficiencies
)
@saving_throw_proficiencies.setter
def saving_throw_proficiencies(self, vals):
self._saving_throw_proficiencies = vals
@property
def spellcasting_classes(self):
return [c for c in self.class_list if c.is_spellcaster]
@property
def spellcasting_classes_excluding_warlock(self):
return [c for c in self.spellcasting_classes if not type(c) == classes.Warlock]
@property
def is_spellcaster(self):
return len(self.spellcasting_classes) > 0
def spell_slots(self, spell_level):
warlock_slots = 0
for c in self.spellcasting_classes:
if type(c) is classes.Warlock:
warlock_slots = c.spell_slots(spell_level)
if len(self.spellcasting_classes_excluding_warlock) == 0:
return warlock_slots
if len(self.spellcasting_classes_excluding_warlock) == 1:
return (
self.spellcasting_classes_excluding_warlock[0].spell_slots(spell_level)
+ warlock_slots
)
else:
if spell_level == 0:
return sum([c.spell_slots(0) for c in self.spellcasting_classes])
else:
# compute effective level from PHB pg 164
eff_level = 0
for c in self.spellcasting_classes_excluding_warlock:
if type(c) in [
classes.Bard,
classes.Cleric,
classes.Druid,
classes.Sorceror,
classes.Wizard,
]:
eff_level += c.level
elif type(c) in [classes.Paladin, classes.Ranger]:
eff_level += c.level // 2
elif type(c) in [classes.Fighter, classes.Rogue]:
eff_level += c.level // 3
elif type(c) is classes.Artificer:
eff_level += math.ceil(c.level / 2)
if eff_level == 0:
return warlock_slots
else:
return (
multiclass_spellslots_by_level[eff_level][spell_level]
+ warlock_slots
)
@property
def spells(self):
spells = set(self._spells) | set(self._spells_prepared)
for f in self.features:
spells |= set(f.spells_known) | set(f.spells_prepared)
for c in self.spellcasting_classes:
spells |= set(c.spells_known) | set(c.spells_prepared)
if self.race is not None:
spells |= set(self.race.spells_known) | set(self.race.spells_prepared)
return sorted(tuple(spells), key=(lambda x: x.name))
@property
def spells_prepared(self):
spells = set(self._spells_prepared)
for f in self.features:
spells |= set(f.spells_prepared)
for c in self.spellcasting_classes:
spells |= set(c.spells_prepared)
if self.race is not None:
spells |= set(self.race.spells_prepared)
return sorted(tuple(spells), key=(lambda x: x.name))
def set_attrs(self, **attrs):
"""
Bulk setting of attributes
Useful for loading a character from a dictionary
"""
for attr, val in attrs.items():
if attr == "dungeonsheets_version":
pass # Maybe we'll verify this later?
elif attr == "weapons":
if isinstance(val, str):
val = [val]
# Treat weapons specially
for weap in val:
self.wield_weapon(weap)
elif attr == "magic_items":
if isinstance(val, str):
val = [val]
for mitem in val:
msg = (
f'Magic Item "{mitem}" not defined. '
"Please add it to ``magic_items.py``"
)
ThisMagicItem = _resolve_mechanic(
mechanic=mitem,
SuperClass=magic_items.MagicItem,
warning_message=msg,
)
self.magic_items.append(ThisMagicItem(owner=self))
elif attr == "weapon_proficiencies":
self.other_weapon_proficiencies = ()
msg = 'Magic Item "{}" not defined. Please add it to ``weapons.py``'
wps = set(
[
_resolve_mechanic(
w, SuperClass=weapons.Weapon, warning_message=msg
)
for w in val
]
)
wps -= set(self.weapon_proficiencies)
self.other_weapon_proficiencies = list(wps)
elif attr == "armor":
self.wear_armor(val)
elif attr == "shield":
self.wield_shield(val)
elif attr == "circle":
if hasattr(self, "Druid"):
self.Druid.circle = val
elif attr == "features":
if isinstance(val, str):
val = [val]
_features = []
for f in val:
msg = 'Feature "{}" not defined. Please add it to ``features.py``'
ThisFeature = _resolve_mechanic(
mechanic=f,
SuperClass=features.Feature,
warning_message=msg,
)
_features.append(ThisFeature)
self.custom_features += tuple(F(owner=self) for F in _features)
elif (attr == "spells") or (attr == "spells_prepared"):
# Create a list of actual spell objects
_spells = []
for spell_name in val:
msg = 'Spell "{}" not defined. Please add it to ``spells.py``'
ThisSpell = _resolve_mechanic(
mechanic=spell_name,
SuperClass=spells.Spell,
warning_message=msg,
)
_spells.append(ThisSpell)
# Sort by name
_spells.sort(key=lambda spell: spell.name)
# Save list of spells to character atribute
if attr == "spells":
# Instantiate them all for the spells list
self._spells = tuple(S() for S in _spells)
else:
# Instantiate them all for the spells list
self._spells_prepared = tuple(S() for S in _spells)
elif attr == "infusions":
if hasattr(self, "Artificer"):
_infusions = []
for infusion_name in val:
msg = (
"Infusion '{}' not defined. Please add it to"
" ``infusions.py``"
)
ThisInfusion = _resolve_mechanic(
mechanic=infusion_name,
SuperClass=infusions.Infusion,
warning_message=msg,
)
_infusions.append(ThisInfusion)
_infusions.sort(key=lambda infusion: infusion.name)
self.infusions = tuple(i() for i in _infusions)
elif type(val) not in (type, ModuleType):
# Some other generic attribute
is_unknown = not hasattr(self, attr) and not attr.startswith("_")
if is_unknown:
warnings.warn(
f"Setting unknown character attribute {attr}", RuntimeWarning
)
# Lookup general attributes
setattr(self, attr, val)
def spell_save_dc(self, class_type):
ability_mod = getattr(self, class_type.spellcasting_ability).modifier
return 8 + self.proficiency_bonus + ability_mod
def spell_attack_bonus(self, class_type):
ability_mod = getattr(self, class_type.spellcasting_ability).modifier
return self.proficiency_bonus + ability_mod
def is_proficient(self, weapon: Weapon):
"""Is the character proficient with this item?
Considers class proficiencies and race proficiencies.
Parameters
----------
weapon
The weapon to be tested for proficiency.
Returns
-------
Boolean: is this character proficient with this weapon?
"""
all_proficiencies = self.weapon_proficiencies
is_proficient = any((isinstance(weapon, W) for W in all_proficiencies))
return is_proficient
@property
def proficiencies_text(self):
final_text = ""
all_proficiencies = tuple(self._proficiencies_text)
if self.has_class:
all_proficiencies += tuple(self.primary_class._proficiencies_text)
if self.num_classes > 1:
for c in self.class_list[1:]:
all_proficiencies += tuple(c._multiclass_proficiencies_text)
if self.race is not None:
all_proficiencies += tuple(self.race.proficiencies_text)
if self.background is not None:
all_proficiencies += tuple(self.background.proficiencies_text)
# Create a single string out of all the proficiencies
for txt in all_proficiencies:
if not final_text:
# Capitalize the first entry
txt = txt.capitalize()
else:
# Put a comma first
txt = ", " + txt
# Add this item to the list text
final_text += txt
# Add a period at the end
final_text += "."
return final_text
@property
def features_text(self):
s = "\n\n--".join(
[f.name + ("**" if f.needs_implementation else "") for f in self.features]
)
if s != "":
s = "(See Features Page)\n\n--" + s
s += "\n\n=================\n\n"
return s
@property
def magic_items_text(self):
s = ", ".join(
[
f.name + ("**" if f.needs_implementation else "")
for f in sorted(self.magic_items, key=(lambda x: x.name))
]
)
if s:
s += ", "
return s
def wear_armor(self, new_armor):
"""Accepts a string or Armor class and replaces the current armor.
If a string is given, then a subclass of
:py:class:`~dungeonsheets.armor.Armor` is retrived from the
``armor.py`` file. Otherwise, an subclass of
:py:class:`~dungeonsheets.armor.Armor` can be provided
directly.
"""
if new_armor not in ("", "None", None):
if isinstance(new_armor, armor.Armor):
new_armor = new_armor
else:
msg = 'Unnown armor "{}". Please add it to ``armor.py``.'
NewArmor = _resolve_mechanic(
mechanic=new_armor,
SuperClass=armor.Armor,
warning_message=msg,
)
new_armor = NewArmor()
self.armor = new_armor
def wield_shield(self, shield):
"""Accepts a string or Shield class and replaces the current armor.
If a string is given, then a subclass of
:py:class:`~dungeonsheets.armor.Shield` is retrived from the
``armor.py`` file. Otherwise, an subclass of
:py:class:`~dungeonsheets.armor.Shield` can be provided
directly.
"""
if shield not in ("", "None", None):
try:
NewShield = find_content(shield, valid_classes=[armor.Shield])
except AttributeError:
# Not a string, so just treat it as Armor
NewShield = shield
self.shield = NewShield()
def wield_weapon(self, weapon):
"""Accepts a string and adds it to the list of wielded weapons.
Parameters
----------
weapon : str
Case-insensitive string with a name of the weapon.
"""
# Retrieve the weapon class from the weapons module
if isinstance(weapon, weapons.Weapon):
ThisWeapon = type(weapon)
else:
msg = 'Unknown weapon "{}". Please add it to ``weapons.py``.'
ThisWeapon = _resolve_mechanic(
mechanic=weapon,
SuperClass=weapons.Weapon,
warning_message=msg,
)
# Save it to the array
self.weapons.append(ThisWeapon(wielder=self))
@property
def hit_dice(self):
"""What type and how many dice to use for re-gaining hit points.
To change, set hit_dice_num and hit_dice_faces."""
return " + ".join([f"{c.level}d{c.hit_dice_faces}" for c in self.class_list])
@property
def hit_dice_faces(self):
# Not a valid function if multiclass
if self.num_classes > 1:
warnings.warn("hit_dice_faces is not valid for multiclass characters")
return self.primary_class.hit_dice_faces
@hit_dice_faces.setter
def hit_dice_faces(self, faces):
self.primary_class.hit_dice_faces = faces
@property
def proficiency_bonus(self):
if self.level < 5:
prof = 2
elif 5 <= self.level < 9:
prof = 3
elif 9 <= self.level < 13:
prof = 4
elif 13 <= self.level < 17:
prof = 5
elif 17 <= self.level:
prof = 6
return prof
def can_assume_shape(self, shape: monsters.Monster):
return hasattr(self, "Druid") and self.Druid.can_assume_shape(shape)
@property
def all_wild_shapes(self):
if hasattr(self, "Druid"):
return self.Druid.all_wild_shapes
else:
return ()
@property
def wild_shapes(self):
if hasattr(self, "Druid"):
return self.Druid.wild_shapes
else:
return ()
@wild_shapes.setter
def wild_shapes(self, new_shapes):
if hasattr(self, "Druid"):
self.Druid.wild_shapes = new_shapes
@property
def infusions_text(self):
if hasattr(self, "Artificer"):
return tuple([i.name for i in self.infusions])
else:
return ()
@classmethod
def load(Cls, char_props: MutableMapping):
"""Factory Creates a character from the character definition.
Parameters
==========
char_props
Keys and values holding all the attributes of the
character. E.g. ``char_props['strength'] = 16``
Returns
=======
char
The initialized ``Character`` object with associated
parameters.
"""
# Parse the sheet type
char_props.pop("sheet_type", "")
# Load classes
classes = char_props.get("classes", [])
# backwards compatability
if (len(classes) == 0) and ("character_class" in char_props):
char_props["classes"] = [
char_props.pop("character_class").lower().capitalize()
]
char_props["levels"] = [str(char_props.pop("level"))]
# Create the character with loaded properties
char = Cls(**char_props)
return char
def save(self, filename, template_file="character_template.txt"):
# Create the template context
context = dict(
char=self,
)
# Render the template
src_path = os.path.join(os.path.dirname(__file__), "forms/")
text = (
jinja2.Environment(loader=jinja2.FileSystemLoader(src_path or "./"))
.get_template(template_file)
.render(context)
)
# Save the file
with open(filename, mode="w") as f:
f.write(text)
def to_pdf(self, filename, **kwargs):
from dungeonsheets.make_sheets import make_sheet
if filename.endswith(".pdf"):
filename = filename.replace("pdf", "py")
make_sheet(filename, character=self, flatten=kwargs.get("flatten", True))
# Add backwards compatibility for tests
class Artificer(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Artificer"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Barbarian(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Barbarian"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Bard(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Bard"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Cleric(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Cleric"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Druid(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Druid"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Fighter(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Fighter"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Monk(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Monk"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Paladin(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Paladin"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Ranger(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Ranger"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Rogue(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Rogue"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Sorceror(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Sorceror"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Warlock(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Warlock"]
attrs["levels"] = [level]
super().__init__(**attrs)
class Wizard(Character):
def __init__(self, level=1, **attrs):
attrs["classes"] = ["Wizard"]
attrs["levels"] = [level]
super().__init__(**attrs)