"""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 Ability, ArmorClass, Initiative, Skill, Speed, findattr from dungeonsheets.weapons import Weapon from dungeonsheets.readers import read_character_file def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() __version__ = read("../VERSION").strip() 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: """A generic player character.""" # General attirubtes name = "" player_name = "" alignment = "Neutral" dungeonsheets_version = __version__ class_list = list() _race = None _background = None 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 _saving_throw_proficiencies = tuple() # use to overwrite class proficiencies other_weapon_proficiencies = tuple() # add to class/race proficiencies skill_proficiencies = list() skill_expertise = list() languages = "" # Skills acrobatics = Skill(ability="dexterity") animal_handling = Skill(ability="wisdom") arcana = Skill(ability="intelligence") athletics = Skill(ability="strength") deception = Skill(ability="charisma") history = Skill(ability="intelligence") insight = Skill(ability="wisdom") intimidation = Skill(ability="charisma") investigation = Skill(ability="intelligence") medicine = Skill(ability="wisdom") nature = Skill(ability="intelligence") perception = Skill(ability="wisdom") performance = Skill(ability="charisma") persuasion = Skill(ability="charisma") religion = Skill(ability="intelligence") sleight_of_hand = Skill(ability="dexterity") stealth = Skill(ability="dexterity") survival = Skill(ability="wisdom") # Characteristics attacks_and_spellcasting = "" 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." # Inventory cp = 0 sp = 0 ep = 0 gp = 0 pp = 0 equipment = "" weapons = list() magic_items = list() armor = None shield = None _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. """ 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-definied 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 compatability 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)