diff --git a/README.rst b/README.rst index 2de2a927..8643acee 100644 --- a/README.rst +++ b/README.rst @@ -48,7 +48,7 @@ Installation .. note:: - Dungeon sheets requires **at least python 3.6**. This is mostly due + dungeon-sheets requires **at least python 3.6**. This is mostly due to the liberal use of f-strings_. If you want to use it with previous versions of python 3, you'll probably have to replace all the f-strings with the older ``.format()`` method or string @@ -72,8 +72,9 @@ pdftk is available in Debian and derivatives as **pdftk**, the package is not available in some RPM distributions, such as Fedora and CentOS. One alternative would be to build your PC sheets using docker. -If the ``pdflatex`` command is available on your system, spellcasters -will include a spellbook with descriptions of each spell known. If +If the ``pdflatex`` command is available on your system, dungeon-sheets +will include a description of a character's features. For spellcasters, +it will include a spellbook with descriptions of each spell known. If not, then this feature will be skipped. In order to properly format descriptions for spells/features/etc., @@ -115,6 +116,14 @@ By default, your character's spells are ordered alphabetically. If you would like your spellbook to be ordered by level, you can use the ``-S`` option to do so. +Furthermore, your character's features are ordered alphabetically by +default as well. Pass the ``-N`` option to order feats by type +(character feats, class feats, racial feats and background feat) and, +if applicable, by sub-type (e.g., for Sorcerers, metamagic feature +choices are collected under the Metamagic feature; for the Battle +Master subclass, Maneuver feature choices are collected under +the Combat Superiority feature.) + If you'd like a **step-by-step walkthrough** for creating a new character, just run ``create-character`` from a command line and a helpful menu system will take care of the basics for you. diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index a1c737e2..f427291e 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -488,6 +488,48 @@ def features(self): return sorted(tuple(fts), key=(lambda x: x.name)) + @property + def features_by_type(self): + fts: dict[str, list[type[Feature]]] = { + "Feats": [], + "Class Features": [], + "Racial Features": [], + "Background Features": [], + } + other_feat_choices = list() + # Add player choices; distinguish between general feats and + # feat choices such as fighting styles and metamagic options. + for item in self.custom_features: + if item.source == "Feats": + fts["Feats"].append(item) + else: + other_feat_choices.append(item) + for c in self.class_list: + for item in list(c.features): + if item not in other_feat_choices: + fts["Class Features"].append(item) + # Now check whether any items in class_feat_choices + # is a subclass of current item. + for choice in other_feat_choices: + if choice.__class__.__bases__[0] is item.__class__: + fts["Class Features"].append(choice) + # Make sure we didn't miss any feat choices: + for choice in other_feat_choices: + if choice not in fts["Class Features"]: + fts["Class Features"].insert(0, choice) + if self.race is not None: + for item in getattr(self.race, "features", ()): + fts["Racial Features"].append(item) + # some races have level-based features (Ex: Aasimar) + if hasattr(self.race, "features_by_level"): + for lvl in range(1, self.level + 1): + for item in list(self.race.features_by_level[lvl]): + fts["Racial Features"].append(item) + if self.background is not None: + for item in getattr(self.background, "features", ()): + fts["Background Features"].append(item) + return fts + @property def custom_features_text(self): return tuple([f.name for f in self.custom_features]) diff --git a/dungeonsheets/features/bloodhunter.py b/dungeonsheets/features/bloodhunter.py index 742a3451..93c7177e 100644 --- a/dungeonsheets/features/bloodhunter.py +++ b/dungeonsheets/features/bloodhunter.py @@ -27,6 +27,22 @@ class BloodMaledict(Feature): name = "Blood Maledict" source = "Blood Hunter" + at_will_spells = () + + def cast_spell_at_will(self, spell): + s = spell() + s.level = 0 + if "M" in s.components: + c = list(s.components) + c.remove("M") + s.components = tuple(c) + self.spells_known += (s,) + self.spells_prepared += (s,) + + def __init__(self, owner): + super().__init__(owner) + for s in self.at_will_spells: + self.cast_spell_at_will(s) class BloodHunterFightingStyle(FeatureSelector): @@ -75,6 +91,22 @@ class CrimsonRites(Feature): name = "Crimson Rites" source = "Blood Hunter" + at_will_spells = () + + def cast_spell_at_will(self, spell): + s = spell() + s.level = 0 + if "M" in s.components: + c = list(s.components) + c.remove("M") + s.components = tuple(c) + self.spells_known += (s,) + self.spells_prepared += (s,) + + def __init__(self, owner): + super().__init__(owner) + for s in self.at_will_spells: + self.cast_spell_at_will(s) class ExtraAttackBloodHunter(Feature): @@ -147,29 +179,7 @@ class SanguineMastery(Feature): # All Rites -class Rites(Feature): - """ - A generic Rite. Add details in features/bloodhunter.py - """ - - name = "Unnamed rite" - source = "BloodHunter (Crimson Rites)" - at_will_spells = () - - def cast_spell_at_will(self, spell): - s = spell() - s.level = 0 - if "M" in s.components: - c = list(s.components) - c.remove("M") - s.components = tuple(c) - self.spells_known += (s,) - self.spells_prepared += (s,) - - def __init__(self, owner): - super().__init__(owner) - for s in self.at_will_spells: - self.cast_spell_at_will(s) +Rites = CrimsonRites class RiteOfTheFlame(Rites): @@ -226,29 +236,7 @@ class RiteOfTheRoar(Rites): #Blood Curses -class BloodCurses(Feature): - """ - A generic BloodCurse. Add details in features/bloodhunter.py - """ - - name = "Unnamed Curse" - source = "BloodHunter (Blood Maledict)" - at_will_spells = () - - def cast_spell_at_will(self, spell): - s = spell() - s.level = 0 - if "M" in s.components: - c = list(s.components) - c.remove("M") - s.components = tuple(c) - self.spells_known += (s,) - self.spells_prepared += (s,) - - def __init__(self, owner): - super().__init__(owner) - for s in self.at_will_spells: - self.cast_spell_at_will(s) +BloodCurses = BloodMaledict class BloodCurseoftheAnxious(BloodCurses): @@ -535,8 +523,24 @@ class Formulas(Feature): name = "Formulas" source = "Blood Hunter (Order of the Mutant)" - - + at_will_spells = () + + def cast_spell_at_will(self, spell): + s = spell() + s.level = 0 + if "M" in s.components: + c = list(s.components) + c.remove("M") + s.components = tuple(c) + self.spells_known += (s,) + self.spells_prepared += (s,) + + def __init__(self, owner): + super().__init__(owner) + for s in self.at_will_spells: + self.cast_spell_at_will(s) + + class Mutagencraft(Feature): """At 3rd level, you can concoct a single mutagen when you finish a short or long rest. Starting at 7th level, the number of mutagens you can create when you finish a rest increases to two, and at 15th level, you can now create three mutagens. @@ -593,31 +597,6 @@ class ExaltedMutation(Feature): #Formulas -class Formulas(Feature): - """ - A generic Formula. Add details in features/bloodhunter.py - """ - - name = "Unnamed rite" - source = "BloodHunter (Crimson Rites)" - at_will_spells = () - - def cast_spell_at_will(self, spell): - s = spell() - s.level = 0 - if "M" in s.components: - c = list(s.components) - c.remove("M") - s.components = tuple(c) - self.spells_known += (s,) - self.spells_prepared += (s,) - - def __init__(self, owner): - super().__init__(owner) - for s in self.at_will_spells: - self.cast_spell_at_will(s) - - class Aether(Formulas): """**Prerequisite: 11th level.** You gain a flying speed of 20 feet for 1 hour. diff --git a/dungeonsheets/features/fighter.py b/dungeonsheets/features/fighter.py index a58d646a..58635798 100644 --- a/dungeonsheets/features/fighter.py +++ b/dungeonsheets/features/fighter.py @@ -383,13 +383,7 @@ class Relentless(Feature): # Maneuvers -class Maneuver(Feature): - """ - A generic Maneuver - """ - - name = "Maneuver" - source = "Fighter Maneuver (Battle Master)" +Maneuver = CombatSuperiority class BaitAndSwitch(Maneuver): @@ -823,7 +817,7 @@ class EverReadyShot(Feature): source = "Fighter (Arcane Archer)" -class BanishingArrow(Feature): +class BanishingArrow(ArcaneShot): """You use abjuration magic to try to temporarily banish your target to a harmless location in the Feywild. The creature hit by the arrow must also succeed on a Charisma saving throw or be banished. While banished in this @@ -840,7 +834,7 @@ class BanishingArrow(Feature): source = "Fighter (Arcane Archer)" -class BeguilingArrow(Feature): +class BeguilingArrow(ArcaneShot): """Your enchantment magic causes this arrow to temporarily beguile its target. The creature hit by the arrow takes an extra 2d6 psychic damage, and choose one of your allies within 30 feet of the target. The target must @@ -856,7 +850,7 @@ class BeguilingArrow(Feature): source = "Fighter (Arcane Archer)" -class BurstingArrow(Feature): +class BurstingArrow(ArcaneShot): """You imbue your arrow with force energy drawn from the school of evocation. The energy detonates after your attack. Immediately after the arrow hits the creature, the target and all other creatures within 10 @@ -869,7 +863,7 @@ class BurstingArrow(Feature): source = "Fighter (Arcane Archer)" -class EnfeeblingArrow(Feature): +class EnfeeblingArrow(ArcaneShot): """You weave necromantic magic into your arrow. The creature hit by the arrow takes an extra 2d6 necrotic damage. The target must also succeed on a Constitution saving throw, or the damage dealt by its weapon attacks is @@ -882,7 +876,7 @@ class EnfeeblingArrow(Feature): source = "Fighter (Arcane Archer)" -class GraspingArrow(Feature): +class GraspingArrow(ArcaneShot): """When this arrow strikes its target, conjuration magic creates grasping, poisonous brams bles, which wrap around the target. The creature hit by the arrow takes an extra 2(16 poison damage, its speed is reduced by 10 feet, @@ -900,7 +894,7 @@ class GraspingArrow(Feature): source = "Fighter (Arcane Archer)" -class PiercingArrow(Feature): +class PiercingArrow(ArcaneShot): """You use transmutation magic to give your arrow an ethereal quality. When you use this option, you don't make an attack roll for the attack. Instead, the arrow shoots forward in a line, which is 1 foot wide and 30 feet long, @@ -917,7 +911,7 @@ class PiercingArrow(Feature): source = "Fighter (Arcane Archer)" -class SeekingArrow(Feature): +class SeekingArrow(ArcaneShot): """Using divination magic, you grant your arrow the ability to seek out a target. When you use this option, you don't make an attack roll for the attack. Instead, choose one creature you have seen in the past minute. The @@ -937,7 +931,7 @@ class SeekingArrow(Feature): source = "Fighter (Arcane Archer)" -class ShadowArrow(Feature): +class ShadowArrow(ArcaneShot): """You weave illusion magic into your arrow, causing it to occlude your fees vision with shadows. The creature hit by the arrow takes an extra 2d6 psychic damage, and it must succeed on a Wisdom saving throw or be unable @@ -947,7 +941,7 @@ class ShadowArrow(Feature): """ - name = "Shadow Arrow" + name = "Arcane Shot: Shadow Arrow" source = "Fighter (Arcane Archer)" @@ -1231,6 +1225,9 @@ class AdeptMarksman(Feature): source = "Fighter (Gunslinger" +TrickShot = AdeptMarksman + + class QuickDraw(Feature): """When you reach 7th level, you add your proficiency bonus to your initiative. You can also stow a firearm, then draw another firearm as a @@ -1281,7 +1278,7 @@ class HemorrhagingCritical(Feature): source = "Fighter (Gunslinger)" -class BullyingShot(Feature): +class BullyingShot(TrickShot): """You can use the powerful blast and thundering sound of your firearm to shake the resolve of a creature. You can expend one grit point while making a Charisma (Intimidation) check to gain advantage on the roll. @@ -1292,7 +1289,7 @@ class BullyingShot(Feature): source = "Gunslinger (Trick Shot)" -class DazingShot(Feature): +class DazingShot(TrickShot): """When you make a firearm attack against a creature, you can expend one grit point to attempt to dizzy your opponent. On a hit, the creature suffers normal damage and must make a Constitution saving throw or suffer @@ -1304,7 +1301,7 @@ class DazingShot(Feature): source = "Gunslinger (Trick Shot)" -class DeadeyeShot(Feature): +class DeadeyeShot(TrickShot): """When you make a firearm attack against a creature, you can expend one grit point to gain advantage on the attack roll. @@ -1314,7 +1311,7 @@ class DeadeyeShot(Feature): source = "Gunslinger (Trick Shot)" -class DisarmingShot(Feature): +class DisarmingShot(TrickShot): """When you make a firearm attack against a creature, you can expend one grit point to attempt to shoot an object from their hands. On a hit, the creature suffers normal damage and must succeed on a Strength saving throw @@ -1327,7 +1324,7 @@ class DisarmingShot(Feature): source = "Gunslinger (Trick Shot)" -class ForcefulShot(Feature): +class ForcefulShot(TrickShot): """When you make a firearm attack against a creature, you can expend one grit point to attempt to trip them up and force them back. On a hit, the creature suffers normal damage and must succeed on a Strength saving throw @@ -1339,7 +1336,7 @@ class ForcefulShot(Feature): source = "Gunslinger (Trick Shot)" -class PiercingShot(Feature): +class PiercingShot(TrickShot): """When you make a firearm attack against a creature, you can expend one grit point to attempt to fire through multiple opponents. The initial attack gains a +1 to the firearm's misfire score. On a hit, the creature suffers @@ -1353,7 +1350,7 @@ class PiercingShot(Feature): source = "Gunslinger (Trick Shot)" -class WingingShot(Feature): +class WingingShot(TrickShot): """When you make a firearm attack against a creature, you can expend one grit point to attempt to topple a moving target. On a hit, the creature suffers normal damage and must make a Strength saving throw or be knocked prone. @@ -1364,7 +1361,7 @@ class WingingShot(Feature): source = "Gunslinger (Trick Shot)" -class ViolentShot(Feature): +class ViolentShot(TrickShot): """When you make a firearm attack against a creature, you can expend one or more grit points to enhance the volatility of the attack. For each grit point expended, the attack gains a +2 to the firearm's misfire score. If diff --git a/dungeonsheets/features/warlock.py b/dungeonsheets/features/warlock.py index 3deaad3a..14fcd975 100644 --- a/dungeonsheets/features/warlock.py +++ b/dungeonsheets/features/warlock.py @@ -21,6 +21,22 @@ class EldritchInvocation(Feature): name = "Eldritch Invocations" source = "Warlock" + at_will_spells = () + + def cast_spell_at_will(self, spell): + s = spell() + s.level = 0 + if "M" in s.components: + c = list(s.components) + c.remove("M") + s.components = tuple(c) + self.spells_known += (s,) + self.spells_prepared += (s,) + + def __init__(self, owner): + super().__init__(owner) + for s in self.at_will_spells: + self.cast_spell_at_will(s) class PactOfTheChain(Feature): @@ -780,29 +796,7 @@ class LimitedWish(Feature): # All Invocations -class Invocation(Feature): - """ - A generic Eldritch Invocation. Add details in features/warlock.py - """ - - name = "Unnamed Invocation" - source = "Warlock (Eldritch Invocations)" - at_will_spells = () - - def cast_spell_at_will(self, spell): - s = spell() - s.level = 0 - if "M" in s.components: - c = list(s.components) - c.remove("M") - s.components = tuple(c) - self.spells_known += (s,) - self.spells_prepared += (s,) - - def __init__(self, owner): - super().__init__(owner) - for s in self.at_will_spells: - self.cast_spell_at_will(s) +Invocation = EldritchInvocation # PHB diff --git a/dungeonsheets/forms/features_template.tex b/dungeonsheets/forms/features_template.tex index 2949297c..7482c14c 100644 --- a/dungeonsheets/forms/features_template.tex +++ b/dungeonsheets/forms/features_template.tex @@ -1,19 +1,25 @@ -\pdfbookmark[0]{Features}{Features} -\section*{Features} -[% if use_dnd_decorations %] - [%- for feat in character.features %] - \pdfbookmark[1]{[[ feat.name ]]}{Features - [[ feat.name ]]} - \DndFeatHeader{[[ feat.name ]]}[Source: [[ feat.source ]]] - [[- feat.__doc__|rst_to_latex(use_dnd_decorations=use_dnd_decorations) -]] - [%- endfor -%] -[% else %] - [%- for feat in character.features %] - \pdfbookmark[1]{[[ feat.name ]]}{Features - [[ feat.name ]]} - \subsection*{[[ feat.name ]]} - \textbf{Source:} [[ feat.source ]] \\ +[%- if feat_order -%] + [%- set feat_dict = character.features_by_type -%] +[%- else -%] + [%- set feat_dict = {"Features": character.features} -%] +[%- endif -%] +[%- for feat_type, feat_list in feat_dict.items() -%] + [%- if feat_list|length -%] + \pdfbookmark[0]{[[ feat_type ]]}{[[ feat_type ]]} + \section*{[[ feat_type ]]} + [% endif %] + [%- for feat in feat_list -%] + [%- if use_dnd_decorations %] + \pdfbookmark[1]{[[ feat.name ]]}{[[ feat_type ]] - [[ feat.name ]]} + \DndFeatHeader{[[ feat.name ]]}[Source: [[ feat.source ]]] + [%- else %] + \pdfbookmark[1]{[[ feat.name ]]}{[[ feat_type ]] - [[ feat.name ]]} + \subsection*{[[ feat.name ]]} + \textbf{Source:} [[ feat.source ]] + [%- endif %] [%- if feat.needs_implementation %] \textbf{**Not included in stats on Character Sheet} - [%- endif -%] - [[- feat.__doc__|rst_to_latex -]] - [%- endfor -%] -[% endif %] + [%- endif %] + [[ feat.__doc__|rst_to_latex(use_dnd_decorations=use_dnd_decorations) ]] + [% endfor %] +[%- endfor %] diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index 623c8207..ea97d621 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -72,7 +72,8 @@ def __call__( character: Character, content_suffix: str = "tex", use_dnd_decorations: bool = False, - spell_order: bool = False + spell_order: bool = False, + feat_order: bool = False, ): template = jinja_env.get_template( self.template_name.format(suffix=content_suffix) @@ -81,6 +82,7 @@ def __call__( character=character, use_dnd_decorations=use_dnd_decorations, spell_order=spell_order, + feat_order=feat_order, ordinals=ORDINALS, ) @@ -164,6 +166,7 @@ def make_sheet( debug: bool = False, use_tex_template: bool = False, spell_order: bool = False, + feat_order: bool = False, ): """Make a character or GM sheet into a PDF. Parameters @@ -204,6 +207,7 @@ def make_sheet( debug=debug, use_tex_template=use_tex_template, spell_order=spell_order, + feat_order=feat_order, ) return ret @@ -394,6 +398,7 @@ def make_character_content( content_format: str, fancy_decorations: bool = False, spell_order: bool = False, + feat_order: bool = False, ) -> List[str]: """Prepare the inner content for a character sheet. @@ -455,6 +460,7 @@ def make_character_content( character, content_suffix=content_format, use_dnd_decorations=fancy_decorations, + feat_order=feat_order, ) ) if character.magic_items: @@ -554,6 +560,7 @@ def make_character_sheet( debug: bool = False, use_tex_template: bool = False, spell_order: bool = False, + feat_order: bool = False, ): """Prepare a PDF character sheet from the given character file. @@ -594,6 +601,7 @@ def make_character_sheet( content_format=content_suffix, fancy_decorations=fancy_decorations, spell_order=spell_order, + feat_order=feat_order, ) # Typeset combined LaTeX file if output_format == "pdf": @@ -703,6 +711,7 @@ def _build(filename, args) -> int: fancy_decorations=args.fancy_decorations, use_tex_template=args.use_tex_template, spell_order=args.spell_order, + feat_order=args.feat_order, ) except exceptions.CharacterFileFormatError: # Only raise the failed exception if this file is explicitly given @@ -748,6 +757,14 @@ def main(args=None): help="Order spells by level in the feature pages.", dest="spell_order", ) + parser.add_argument( + "--feats-by-type", + "-N", + default=False, + action="store_true", + help="Order feats by type in the feature pages.", + dest="feat_order", + ) parser.add_argument( "--fancy-decorations", "--fancy", diff --git a/tests/test_character.py b/tests/test_character.py index 40b857c1..96266a36 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -189,6 +189,35 @@ def test_proficiencies_by_type(self): char.proficiencies_by_type["Weapons"].lower() + char.proficiencies_by_type["Other"].lower()) + def test_features_by_type(self): + char = Character( + classes=["Fighter", "Sorcerer"], + subclasses=["Gunslinger", "Divine Soul"], + levels=[15, 5], + race="Protector Aasimar", + features = ("Bullying Shot", "Disarming Shot", "Forceful Shot", "Violent Shot", + "Winging Shot", "Twinned Spell", "Empowered Spell", "resilient", "turn undead"), + feature_choices = ("great-weapon fighting",), + background = "Pirate" + ) + assert str(char.features_by_type["Feats"][0]) == "Resilient" + feats = "\n".join([str(feat) for feat in char.features_by_type["Class Features"]]) + e = ( + "Channel Divinity: Turn Undead\nFighting Style (Great Weapon Fighting)\nSecond Wind\n" + "Action Surge\nGunsmith\nAdept Marksman\nBullying Shot\n" + "Disarming Shot\nForceful Shot\nViolent Shot\nWinging Shot\n" + "Extra Attack (3x)\nQuick Draw\nIndomitable (2x/LR)\nRapid Repair\nLightning Repaid\n" + "Divine Magic\nFavored by the Gods\nFont of Magic\nMetamagic\nTwinned Spell\n" + "Empowered Spell" + ) + assert(feats == e) + feats = "\n".join([str(feat) for feat in char.features_by_type["Racial Features"]]) + e = ( + "Darkvision (60')\nCelestial Resistance\nHealing Hands\n" + "Light Bearer\nAasimar Radiant Soul" + ) + assert str(char.features_by_type["Background Features"][0]) == "Ship\'s Passage" + def test_proficiency_bonus(self): char = Character() char.level = 1 @@ -247,7 +276,7 @@ def test_wield_shield(self): # Try passing an Armor object directly char.wield_shield(Shield) self.assertEqual(char.armor_class, 15) - + def test_carrying_weight(self): char = Character(race="lightfoot halfling", strength=12) # Check carrying capacity @@ -366,9 +395,9 @@ class Beast(monsters.Monster): not_beast = monsters.Monster() not_beast.description = "monster" self.assertFalse(low_druid.can_assume_shape(not_beast)) - + class BeastMasterTestCase(TestCase): - + def test_ranger_beast(self): char = Ranger(6, subclasses = ["Beast Master"]) char.ranger_beast = "Panther" @@ -386,6 +415,3 @@ def test_ranger_beast(self): char = Ranger(3, subclasses = ["Beast Master"]) char.ranger_beast = "Panther" self.assertEqual(char.ranger_beast.hp_max, 13) - - -