Files
dungeon-sheets/dungeonsheets/character.py
T

1053 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
import jinja2
from dungeonsheets import (
armor,
background,
classes,
features,
infusions,
magic_items,
monsters,
race,
spells,
weapons,
)
from dungeonsheets.stats import findattr
from dungeonsheets.weapons import Weapon
from dungeonsheets.readers import read_character_file
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, module, 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::
>>> from dungeonsheets import spells
>>> _resolve_mechanic("mage_hand", spells, None)
>>> class MySpell(spells.Spell): pass
>>> _resolve_mechanic(MySpell, None, spells.Spell)
>>> _resolve_mechanic("hocus pocus", spells, None)
The acceptable entries for *mechanic*, in priority order, are:
1. A subclass of *SuperClass*
2. A string with the name of a defined spell in *module*
3. The name of an unknown spell (creates generic object using *factory*)
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.
module : module
A python module in which to look for the defined string in *name*.
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
Mechanic = findattr(module, mechanic)
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
# Hit points
hp_max = None
hp_current = None
hp_temp = None
# Base stats (ability scores)
strength = Ability()
dexterity = Ability()
constitution = Ability()
intelligence = Ability()
wisdom = Ability()
charisma = Ability()
armor_class = ArmorClass()
initiative = Initiative()
speed = Speed()
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()
# Magic
spellcasting_ability = None
_spells = list()
_spells_prepared = list()
infusions = list()
# Features IN MAJOR DEVELOPMENT
custom_features = list()
feature_choices = 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 = findattr(race, newrace)(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 = findattr(background, bg)(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,
module=magic_items,
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, weapons, weapons.Weapon, 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,
module=features,
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,
module=spells,
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,
module=infusions,
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,
module=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 = findattr(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,
module=weapons,
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, character_file):
# Create a character from the character definition
char_props = read_character_file(character_file)
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)