diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 71384bd..ee37a8a 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -57,7 +57,6 @@ class Character(): """ # General attirubtes name = "" - class_name = "" player_name = "" alignment = "Neutral" dungeonsheets_version = __version__ @@ -133,6 +132,18 @@ class Character(): """Takes a bunch of attrs and passes them to ``set_attrs``""" self.weapons = [] # make sure class, race, background are set first + classes_levels = char_props.pop('classes_levels', []) + subclasses = char_props.pop('subclasses', []) + class_list = parse_classes( + classes_levels, subclasses, + feature_choices=char_props.get('feature_choices', [])) + # accept backwards compatability for single-class characters + if len(class_list) == 0: + name = char_props.pop('character_class').lower().capitalize() + level = char_props.pop('level') + CharClass = getattr(classes, name) + class_list = [CharClass(level)] + char_props['class_list'] = class_list class_list = attrs.pop('class_list', self.class_list) race = attrs.pop('race', self.race) background = attrs.pop('background', self.background) @@ -149,9 +160,24 @@ class Character(): @property def class_name(self): - return ' / '.join([f'{c.class_name}' + 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 list([c.subclass or '' for c in self.class_list]) @@ -165,35 +191,30 @@ class Character(): if self.num_classes == 0: return self._level else: - return sum(c.class_level for c in self.class_list) + return sum(c.level for c in self.class_list) @level.setter def level(self, new_level): if self.num_classes == 0: self._level = new_level else: - self.primary_class.class_level = 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.class_name)) + "level of primary class {:s}".format(self.primary_class.name)) @property def num_classes(self): return len(self.class_list) @property - def class_initialized(self): + def has_class(self): return (self.num_classes > 0) - @property - def classes_levels(self): - return [c.class_name.lower() + ' ' + str(c.class_level) - for c in self.class_list] - @property def primary_class(self): # for now, assume first class given must be primary class - if self.class_initialized: + if self.has_class: return self.class_list[0] else: return None @@ -219,7 +240,7 @@ class Character(): @property def features(self): fts = set(self.custom_features) - if not self.class_initialized: + if not self.has_class: return fts for c in self.class_list: fts |= set(c.features) @@ -271,11 +292,11 @@ class Character(): for c in self.spellcasting_classes: if type(c) in [classes.Bard, classes.Cleric, classes.Druid, classes.Sorceror, classes.Wizard]: - eff_level += c.class_level + eff_level += c.level elif type(c) in [classes.Paladin, classes.Ranger]: - eff_level += c.class_level // 2 + eff_level += c.level // 2 elif type(c) in [classes.Fighter, classes.Rogue]: - eff_level += c.class_level // 3 + eff_level += c.level // 3 if eff_level == 0: return 0 else: @@ -316,6 +337,11 @@ class Character(): elif attr == 'weapon_proficiencies': self.other_weapon_proficiencies = tuple([findattr(weapons, w) for w in val]) + elif attr == 'class_list': + if isinstance(val, str): + val = [val] + for cls in val: + if isinstance elif attr == 'race': if val is not None: MyRace = findattr(race, val) @@ -410,7 +436,7 @@ class Character(): def proficiencies_text(self): final_text = "" all_proficiencies = tuple(self._proficiencies_text) - if self.class_initialized: + if self.has_class: all_proficiencies += tuple(self.primary_class._proficiencies_text) if self.num_classes > 1: for c in self.class_list[1:]: @@ -518,7 +544,7 @@ class Character(): if self.num_classes == 0: return '{:d}d{:d}'.format(self.level, self._hit_dice_faces) else: - return ' + '.join([f'{c.class_level}d{c.hit_dice_faces}' + return ' + '.join([f'{c.level}d{c.hit_dice_faces}' for c in self.class_list]) @property @@ -602,48 +628,6 @@ class Character(): def load(cls, character_file): # Create a character from the character definition char_props = read_character_file(character_file) - classes_levels = char_props.pop('classes_levels', []) - if isinstance(classes_levels, str): - classes_levels = [classes_levels] - subclasses = char_props.pop('subclasses', []) - if isinstance(subclasses, str): - subclasses = [subclasses] - if len(subclasses) == 0: - subclasses = [None]*len(classes_levels) - assert len(classes_levels) == len(subclasses), ( - 'the length of classes_levels {:d} does not match length of ' - 'subclasses {:d}'.format(len(classes_levels), len(subclasses))) - circle = char_props.pop('circle', None) - class_list = [] - for cl, sub in zip(classes_levels, subclasses): - try: - c, _, lvl = cl.strip().rpartition(' ') # " wizard 3 " => "wizard", "3" - c = c.title().replace(' ', '') - except ValueError: - raise ValueError( - 'classes_levels not properly formatted. Each entry should ' - 'be formatted \"class level\", but got {:s}'.format(cl)) - try: - this_class = getattr(classes, c) - this_level = int(lvl) - except AttributeError: - raise AttributeError( - 'class was not recognized from classes.py: {:s}'.format(c)) - except ValueError: - raise ValueError( - 'level was not recognizable as an int: {:s}'.format(lvl)) - if issubclass(this_class, classes.Druid): - sub = circle or sub - params = {} - params['feature_choices'] = char_props.get('feature_choices', []) - class_list += [this_class(this_level, subclass=sub, **params)] - # accept backwards compatability for single-class characters - if len(class_list) == 0: - class_name = char_props.pop('character_class').lower().capitalize() - class_level = char_props.pop('level') - CharClass = getattr(classes, class_name) - class_list = [CharClass(class_level)] - char_props['class_list'] = class_list # Create the character with loaded properties char = cls(**char_props) return char @@ -668,6 +652,41 @@ class Character(): filename = filename.replace('pdf', 'py') make_sheet(filename, char=self, flatten=kwargs.get('flatten', True)) + +def parse_classes(classes_levels=[], subclasses=[], feature_choices=[]): + if isinstance(classes_levels, str): + classes_levels = [classes_levels] + if isinstance(subclasses, str): + subclasses = [subclasses] + if len(subclasses) == 0: + subclasses = [None]*len(classes_levels) + assert len(classes_levels) == len(subclasses), ( + 'the length of classes_levels {:d} does not match length of ' + 'subclasses {:d}'.format(len(classes_levels), len(subclasses))) + class_list = [] + for cls, sub in zip(classes_levels, subclasses): + if not isinstance(classes.CharClass, cls): + try: + c, _, lvl = cls.strip().rpartition(' ') # " wizard 3 " => "wizard", "3" + c = c.title().replace(' ', '') + except ValueError: + raise ValueError( + 'classes_levels not properly formatted. Each entry should ' + 'be formatted \"class level\", but got {:s}'.format(cl)) + try: + this_class = getattr(classes, c) + this_level = int(lvl) + except AttributeError: + raise AttributeError( + 'class was not recognized from classes.py: {:s}'.format(c)) + except ValueError: + raise ValueError( + 'level was not recognizable as an int: {:s}'.format(lvl)) + params = {} + params['feature_choices'] = char_props.get('feature_choices', []) + class_list += [this_class(this_level, subclass=sub, **params)] + return class_list + def read_character_file(filename): """Create a character object from the given definition file. diff --git a/dungeonsheets/classes/barbarian.py b/dungeonsheets/classes/barbarian.py index 6d38aab..651ec7f 100644 --- a/dungeonsheets/classes/barbarian.py +++ b/dungeonsheets/classes/barbarian.py @@ -97,7 +97,7 @@ class ZealotPath(SubClass): class Barbarian(CharClass): - class_name = 'Barbarian' + name = 'Barbarian' hit_dice_faces = 12 saving_throw_proficiencies = ('strength', 'constitution') weapon_proficiencies = (weapons.simple_weapons + weapons.martial_weapons) diff --git a/dungeonsheets/classes/bard.py b/dungeonsheets/classes/bard.py index b3e06cf..2ca099b 100644 --- a/dungeonsheets/classes/bard.py +++ b/dungeonsheets/classes/bard.py @@ -114,7 +114,7 @@ class CollegeOfWhispers(SubClass): class Bard(CharClass): - class_name = 'Bard' + name = 'Bard' hit_dice_faces = 8 saving_throw_proficiencies = ('dexterity', 'charisma') _proficiencies_text = ( diff --git a/dungeonsheets/classes/classes.py b/dungeonsheets/classes/classes.py index ea3a930..37ef5f9 100644 --- a/dungeonsheets/classes/classes.py +++ b/dungeonsheets/classes/classes.py @@ -6,8 +6,8 @@ class CharClass(): """ A generic Character Class (not to be confused with builtin class) """ - class_name = "" - class_level = 1 + name = "" + level = 1 hit_dice_faces = None weapon_proficiencies = () _proficiencies_text = () @@ -26,7 +26,7 @@ class CharClass(): def __init__(self, level, subclass=None, feature_choices=[], **params): - self.class_level = level + self.level = level # Instantiate the features self.features_by_level = defaultdict(list) cls = type(self) @@ -58,7 +58,7 @@ class CharClass(): return None for sc in self.subclasses_available: if subclass_str.lower() in sc.name.lower(): - return sc(level=self.class_level) + return sc(level=self.level) return None def apply_subclass(self): @@ -82,7 +82,7 @@ class CharClass(): @property def features(self): features = () - for lvl in range(1, self.class_level+1): + for lvl in range(1, self.level+1): features += tuple(self.features_by_level[lvl]) return features @@ -96,7 +96,7 @@ class CharClass(): if self.spell_slots_by_level is None: return 0 else: - return self.spell_slots_by_level[self.class_level][spell_level] + return self.spell_slots_by_level[self.level][spell_level] class SubClass(): @@ -114,7 +114,7 @@ class SubClass(): def __init__(self, level): self.__doc__ = self.__doc__ or SubClass.__doc__ - self.class_level = level + self.level = level def __str__(self): return self.name diff --git a/dungeonsheets/classes/cleric.py b/dungeonsheets/classes/cleric.py index e2e2e97..4819a42 100644 --- a/dungeonsheets/classes/cleric.py +++ b/dungeonsheets/classes/cleric.py @@ -181,7 +181,7 @@ class GraveDomain(SubClass): class Cleric(CharClass): - class_name = 'Cleric' + name = 'Cleric' hit_dice_faces = 8 saving_throw_proficiencies = ('wisdom', 'charisma') _proficiencies_text = ('light armor', 'medium armor', 'shields', diff --git a/dungeonsheets/classes/druid.py b/dungeonsheets/classes/druid.py index acf59f8..401bb22 100644 --- a/dungeonsheets/classes/druid.py +++ b/dungeonsheets/classes/druid.py @@ -82,7 +82,7 @@ class ShepherdCircle(SubClass): class Druid(CharClass): - class_name = 'Druid' + name = 'Druid' _wild_shapes = () _circle = '' hit_dice_faces = 8 @@ -137,7 +137,7 @@ class Druid(CharClass): for sc in self.subclasses_available: if ((subclass_str.lower() == sc.circle.lower()) or (subclass_str.lower() in sc.name.lower())): - return sc(level=self.class_level) + return sc(level=self.level) return None @property @@ -207,15 +207,15 @@ class Druid(CharClass): """ # Determine acceptable states based on druid level - if self.class_level < 2: + if self.level < 2: max_cr = -1 max_swim = 0 max_fly = 0 - elif self.class_level < 4: + elif self.level < 4: max_cr = 1/4 max_swim = 0 max_fly = 0 - elif self.class_level < 8: + elif self.level < 8: max_cr = 1/2 max_swim = None max_fly = 0 @@ -225,10 +225,10 @@ class Druid(CharClass): max_fly = None # Make adjustments for moon circle druids if self.circle == "moon": - if 2 <= self.class_level < 6: + if 2 <= self.level < 6: max_cr = 1 - elif self.class_level >= 6: - max_cr = math.floor(self.class_level / 3) + elif self.level >= 6: + max_cr = math.floor(self.level / 3) # Check if the beast shape can be assumed valid_cr = (max_cr is None or shape.challenge_rating <= max_cr) valid_swim = (max_swim is None or shape.swim_speed <= max_swim) diff --git a/dungeonsheets/classes/fighter.py b/dungeonsheets/classes/fighter.py index 1f092a3..3cd2d66 100644 --- a/dungeonsheets/classes/fighter.py +++ b/dungeonsheets/classes/fighter.py @@ -163,7 +163,7 @@ class Gunslinger(SubClass): class Fighter(CharClass): - class_name = 'Fighter' + name = 'Fighter' hit_dice_faces = 10 saving_throw_proficiencies = ('strength', 'constitution') _proficiencies_text = ('All armor', 'shields', 'simple weapons', diff --git a/dungeonsheets/classes/monk.py b/dungeonsheets/classes/monk.py index 48527ec..79cd142 100644 --- a/dungeonsheets/classes/monk.py +++ b/dungeonsheets/classes/monk.py @@ -108,7 +108,7 @@ class KenseiWay(SubClass): class Monk(CharClass): - class_name = 'Monk' + name = 'Monk' hit_dice_faces = 8 saving_throw_proficiencies = ('strength', 'dexterity') _proficiencies_text = ( @@ -132,4 +132,4 @@ class Monk(CharClass): super().__init__(level, subclass=subclass, **params) for f in self.features_by_level[1]: if isinstance(f, features.MartialArts): - f.level = self.class_level + f.level = self.level diff --git a/dungeonsheets/classes/paladin.py b/dungeonsheets/classes/paladin.py index 76db64a..e7a30c1 100644 --- a/dungeonsheets/classes/paladin.py +++ b/dungeonsheets/classes/paladin.py @@ -220,7 +220,7 @@ class OathOfRedemption(SubClass): class Paladin(CharClass): - class_name = 'Paladin' + name = 'Paladin' hit_dice_faces = 10 saving_throw_proficiencies = ('wisdom', 'charisma') _proficiencies_text = ('All armor', 'shields', 'simple weapons', diff --git a/dungeonsheets/classes/ranger.py b/dungeonsheets/classes/ranger.py index e697727..ca982e3 100644 --- a/dungeonsheets/classes/ranger.py +++ b/dungeonsheets/classes/ranger.py @@ -72,7 +72,7 @@ class MonsterSlayer(SubClass): class Ranger(CharClass): - class_name = 'Ranger' + name = 'Ranger' hit_dice_faces = 10 saving_throw_proficiencies = ('strength', 'dexterity') _proficiencies_text = ("light armor", "medium armor", "shields", @@ -157,7 +157,7 @@ class DeepStalkerConclave(SubClass): class RevisedRanger(Ranger): - class_name = 'Revised Ranger' + name = 'Revised Ranger' features_by_level = defaultdict(list) subclasses_available = (BeastConclave, HunterConclave, DeepStalkerConclave) diff --git a/dungeonsheets/classes/rogue.py b/dungeonsheets/classes/rogue.py index 77101e5..b5e14c4 100644 --- a/dungeonsheets/classes/rogue.py +++ b/dungeonsheets/classes/rogue.py @@ -116,7 +116,7 @@ class Swashbuckler(SubClass): class Rogue(CharClass): - class_name = 'Rogue' + name = 'Rogue' hit_dice_faces = 8 saving_throw_proficiencies = ('dexterity', 'intelligence') _proficiencies_text = ( diff --git a/dungeonsheets/classes/sorceror.py b/dungeonsheets/classes/sorceror.py index 12a56dd..f87c731 100644 --- a/dungeonsheets/classes/sorceror.py +++ b/dungeonsheets/classes/sorceror.py @@ -92,7 +92,7 @@ class StormSorcery(SubClass): class Sorceror(CharClass): - class_name = 'Sorceror' + name = 'Sorceror' hit_dice_faces = 6 saving_throw_proficiencies = ('constitution', 'charisma') _proficiencies_text = ('daggers', 'darts', 'slings', diff --git a/dungeonsheets/classes/warlock.py b/dungeonsheets/classes/warlock.py index 62865cc..593c713 100644 --- a/dungeonsheets/classes/warlock.py +++ b/dungeonsheets/classes/warlock.py @@ -113,7 +113,7 @@ class Hexblade(SubClass): class Warlock(CharClass): - class_name = 'Warlock' + name = 'Warlock' hit_dice_faces = 8 saving_throw_proficiencies = ('wisdom', 'charisma') _proficiencies_text = ("light Armor", "simple weapons") diff --git a/dungeonsheets/classes/wizard.py b/dungeonsheets/classes/wizard.py index a5f0cf7..141e492 100644 --- a/dungeonsheets/classes/wizard.py +++ b/dungeonsheets/classes/wizard.py @@ -157,7 +157,7 @@ class WarMagic(SubClass): class Wizard(CharClass): - class_name = 'Wizard' + name = 'Wizard' hit_dice_faces = 6 saving_throw_proficiencies = ('intelligence', 'wisdom') _proficiencies_text = ('daggers', 'darts', 'slings', diff --git a/dungeonsheets/create_character.py b/dungeonsheets/create_character.py index 06a363a..c43a4aa 100755 --- a/dungeonsheets/create_character.py +++ b/dungeonsheets/create_character.py @@ -23,7 +23,7 @@ def read_version(): return version -char_classes = {c.class_name: c for c in classes.available_classes} +char_classes = {c.name: c for c in classes.available_classes} races = {r.name: r for r in race.available_races} @@ -152,7 +152,7 @@ class CharacterClassForm(npyscreen.ActionForm): else: self.class_options = list(char_classes.keys()) for c in self.parentApp.character.class_list[:self.class_num-1]: - self.class_options.remove(c.class_name) + self.class_options.remove(c.name) self.character_class.values = tuple(self.class_options) self.character_class.update() @@ -160,13 +160,13 @@ class CharacterClassForm(npyscreen.ActionForm): if self.class_num > 1: self.add(npyscreen.FixedText, editable=False, value="Current Classes: {}".format( - self.parentApp.character.class_name)) + self.parentApp.character.name)) if self.class_num == 1: t = 'Primary Class:' else: t = 'Class #{:d}:'.format(self.class_num) for c in self.parentApp.character.class_list: - self.class_options.remove(c.class_name) + self.class_options.remove(c.name) self.level = self.add( npyscreen.TitleText, name='Level:', value="1", use_two_lines=False) self.subclass = self.add(npyscreen.Checkbox, name="Choose a Subclass?", value=False) @@ -194,7 +194,7 @@ class CharacterClassForm(npyscreen.ActionForm): new_name = 'SUBCLASS{:d}'.format(self.class_num) new_form = self.parentApp.addForm(new_name, SubclassForm, - name="Select your {:s} Subclass".format(newclass.class_name), + name="Select your {:s} Subclass".format(newclass.name), newclass=newclass, level=level, num=self.class_num) @@ -208,7 +208,7 @@ class CharacterClassForm(npyscreen.ActionForm): if self.character_class.value is not None: selected_class = self.character_class.get_selected_objects()[0] selected_class = char_classes[selected_class] - log.debug('Selected character class %s', selected_class.class_name) + log.debug('Selected character class %s', selected_class.name) new_class = selected_class(level=int(self.level.value), subclass=None) if len(self.parentApp.character.class_list) < self.class_num: @@ -410,7 +410,7 @@ class AbilityScoreForm(npyscreen.ActionForm): attrs = ('strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma') self.class_text = self.add(npyscreen.FixedText, editable=False, - value="Key stats for your primary class {:s} are listed with **".format(self.parentApp.character.primary_class.class_name)) + value="Key stats for your primary class {:s} are listed with **".format(self.parentApp.character.primary_class.name)) self.race_text = self.add(npyscreen.FixedText, editable=False, value="Do not add racial bonuses, they will be added for you as listed.") for attr in attrs: diff --git a/dungeonsheets/forms/character_template.txt b/dungeonsheets/forms/character_template.txt index 1d287f0..fe40879 100644 --- a/dungeonsheets/forms/character_template.txt +++ b/dungeonsheets/forms/character_template.txt @@ -8,9 +8,11 @@ sheet by running ``makesheets`` from the command line. dungeonsheets_version = "{{ char.dungeonsheets_version }}" name = "{{ char.name }}" -classes_levels = {{ char.classes_levels }} -subclasses = {{ char.subclasses }} player_name = "{{ char.player_name }}" + +classes = {{ char.class_names }} +levels = {{ char.levels }} +subclasses = {{ char.subclasses }} background = "{{ char.background.name }}" race = "{{ char.race.name }}" alignment = "{{ char.alignment }}" diff --git a/dungeonsheets/forms/empty_template.txt b/dungeonsheets/forms/empty_template.txt index 3e865ba..2f2992e 100644 --- a/dungeonsheets/forms/empty_template.txt +++ b/dungeonsheets/forms/empty_template.txt @@ -11,9 +11,11 @@ sheet by running ``makesheets`` from the command line. dungeonsheets_version = "{{ char.dungeonsheets_version }}" name = "{{ char.name }}" -classes_levels = {{ char.classes_levels }} -subclasses = {{ char.subclasses }} player_name = "{{ char.player_name }}" + +classes = {{ char.class_names }} +levels = {{ char.levels }} +subclasses = {{ char.subclasses }} background = "{{ char.background.name }}" race = "{{ char.race.name }}" alignment = "{{ char.alignment }}" diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index 043d252..e880ea0 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -117,8 +117,8 @@ def create_latex_pdf(character, basename, template): def create_spells_pdf(character, basename, flatten=False): - class_level = ' / '.join([c.class_name + ' ' + str(c.class_level) - for c in character.spellcasting_classes]) + classes_and_levels = ' / '.join([c.name + ' ' + str(c.level) + for c in character.spellcasting_classes]) abilities = ' / '.join([c.spellcasting_ability.upper()[:3] for c in character.spellcasting_classes]) DCs = ' / '.join([str(character.spell_save_dc(c)) @@ -127,7 +127,7 @@ def create_spells_pdf(character, basename, flatten=False): for c in character.spellcasting_classes]) spell_level = lambda x : (x or 0) fields = { - 'Spellcasting Class 2': class_level, + 'Spellcasting Class 2': classes_and_levels, 'SpellcastingAbility 2': abilities, 'SpellSaveDC 2': DCs, 'SpellAtkBonus 2': bonuses, @@ -193,7 +193,7 @@ def create_character_pdf(character, basename, flatten=False): fields = { # Character description 'CharacterName': character.name, - 'ClassLevel': character.class_name, + 'ClassLevel': character.classes_and_levels, 'Background': str(character.background), 'PlayerName': character.player_name, 'Race ': str(character.race), diff --git a/tests/test_character.py b/tests/test_character.py index 595d25d..c33a2a2 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -4,7 +4,7 @@ from unittest import TestCase import warnings from dungeonsheets import race, monsters, exceptions, spells -from dungeonsheets.character import Character, Wizard, Druid +from dungeonsheets.character import Character from dungeonsheets.weapons import Weapon, Shortsword from dungeonsheets.armor import Armor, LeatherArmor, Shield