From 905ecee4c985e32e5cf80b5a5f6e021d87af547c Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Thu, 3 Jun 2021 23:35:42 -0500 Subject: [PATCH] Now creates GM sheet PDF, but with monsters only. --- dungeonsheets/character.py | 2 +- dungeonsheets/features/bard.py | 42 +++--- dungeonsheets/forms/monsters_template.tex | 89 +++++++++++ dungeonsheets/forms/preamble.tex | 3 +- dungeonsheets/make_sheets.py | 172 ++++++++++++++++++---- examples/gm.py | 4 + tests/test_features.py | 78 +++++++++- tests/test_make_sheets.py | 54 ++++++- 8 files changed, 385 insertions(+), 59 deletions(-) create mode 100644 dungeonsheets/forms/monsters_template.tex diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 0a06a3a..38891c8 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -882,7 +882,7 @@ class Character(Entity): return tuple([i.name for i in self.infusions]) else: return () - + @classmethod def load(Cls, char_props: MutableMapping): """Factory Creates a character from the character definition. diff --git a/dungeonsheets/features/bard.py b/dungeonsheets/features/bard.py index af34c6a..09a1a76 100644 --- a/dungeonsheets/features/bard.py +++ b/dungeonsheets/features/bard.py @@ -238,20 +238,21 @@ class BardBattleMagic(Feature): source = "Bard (College of Valor)" -# College of Glamour +# XgtE - College of Glamour: class MantleOfInspiration(Feature): - """When you join the College of Glamour at 3rd level, you gain the ability to - weave a song of fey magic that imbues your allies with vigor and speed. As - a bonus action, you can expend one use of your Bardic Inspiration to grant - yourself a wondrous appearance. When you do so, choose a number of - creatures you can see and that can see you within 60 feet of you, up to a - number equal to your Charisma modifier (mini mum of one). Each of them - gains 5 temporary hit points. When a creature gains these temporary hit - points, it can immediately use its reaction to move up to its speed, - without provoking opportunity attacks. The number of temporary hit points - inoreases when you reach certain levels in this class, increasing to 8 at - 5th level, 11 at 10th level, and 14 at 15th level. - + """When you join the College of Glamour at 3rd level, you gain the + ability to weave a song of fey magic that imbues your allies with + vigor and speed. As a bonus action, you can expend one use of your + Bardic Inspiration to grant yourself a wondrous appearance. When + you do so, choose a number of creatures you can see and that can + see you within 60 feet of you, up to a number equal to your + Charisma modifier (minimum of one). Each of them gains 5 temporary + hit points. When a creature gains these temporary hit points, it + can immediately use its reaction to move up to its speed, without + provoking opportunity attacks. The number of temporary hit points + increases when you reach certain levels in this class, increasing + to 8 at 5th level, 11 at 10th level, and 14 at 15th level. + """ _name = "Mantle of Inspiration" @@ -402,13 +403,14 @@ class MastersFlourish(Feature): # College of Whispers class PsychicBlades(Feature): - """When you join the College of Whispers at 3rd level, you gain the ability to - make your weapon attacks magically toxic to a creature's mind. When you hit - a creature with a weapon attack, you can expend one use ofyour Bardic - Inspiration to deal an extra 2d6 psychic damage to that target. You can do - so only once per round on your turn. The psychic damage increases when you - reach certain levels in this class, increasing to 3d6 at 5th level, 5d6 - at 10th level, and 8d6 at 15th level. + """When you join the College of Whispers at 3rd level, you gain the + ability to make your weapon attacks magically toxic to a + creature's mind. When you hit a creature with a weapon attack, you + can expend one use ofyour Bardic Inspiration to deal an extra 2d6 + psychic damage to that target. You can do so only once per round + on your turn. The psychic damage increases when you reach certain + levels in this class, increasing to 3d6 at 5th level, 5d6 at 10th + level, and 8d6 at 15th level. """ diff --git a/dungeonsheets/forms/monsters_template.tex b/dungeonsheets/forms/monsters_template.tex new file mode 100644 index 0000000..76dc7d5 --- /dev/null +++ b/dungeonsheets/forms/monsters_template.tex @@ -0,0 +1,89 @@ +\section*{Monsters} + +[% if use_dnd_decorations %] + [% for monster in monsters|sort(attribute='name') %] + \begin{DndMonster}{[[ monster.name ]]} + \DndMonsterType{[[ monster.description ]]} + + % If you want to use commas in the key values, enclose the values in braces. + \DndMonsterBasics[ + armor-class = {[[ monster.armor_class ]]}, + hit-points = {[[ monster.hp_max ]] ([[ monster.hit_dice ]])}, + speed = {[[ monster.speed ]] ft.[% if monster.swim_speed %], [[ monster.swim_speed ]]ft. swim[% endif %][% if monster.fly_speed %], [[ monster.fly_speed ]] ft. fly[% endif %]}, + ] + + \DndMonsterAbilityScores[ + str = [[ monster.strength.value ]], + dex = [[ monster.dexterity.value ]], + con = [[ monster.constitution.value ]], + int = [[ monster.intelligence.value ]], + wis = [[ monster.wisdom.value ]], + cha = [[ monster.charisma.value ]], + ] + + \DndMonsterDetails[ + %saving-throws = {Str +0, Dex +0, Con +0, Int +0, Wis +0, Cha +0}, + skills = {[[ monster.skills ]]}, + %damage-vulnerabilities = {cold}, + damage-resistances = {[[ monster.damage_resistance ]]}, + %damage-immunities = {poison}, + condition-immunities = {[[ monster.condition_immunities ]]}, + senses = {[[ monster.senses ]]}, + languages = {[% if monster.languages %][[ monster.languages ]][% else %] --- [% endif %]}, + challenge = {[[ monster.challenge_rating ]]}, + ] + %\DndMonsterSection{Actions} + [[ monster.__doc__ | rst_to_latex(top_heading_level=2) ]] + \end{DndMonster} + [% endfor %] + +[% else %] + [% for monster in monsters|sort(attribute='name') %] + { + \section*{[[ monster.name ]]} + [% if monster.description %] + \subsection*{[[ monster.description ]]} + [% endif %] + + \begin{tabular}{c | c | c} + Armor Class & Hit Points & Speed \\ + \hline + [[ monster.armor_class ]] & + [[ monster.hp_max ]] ([[ monster.hit_dice ]]) & + [[ monster.speed ]] \\ + [% if monster.swim_speed %] + & & [[ monster.swim_speed ]] swim \\ + [% endif %] + [% if monster.fly_speed %] + & & [[ monster.fly_speed ]] fly \\ + [% endif %] + + \end{tabular} + + \vspace{0.2cm} + + \begin{tabular}{c | c | c} + STR & DEX & CON \\ + \hline + [[ monster.strength.value ]] ([[ monster.strength.modifier|mod_str ]]) & + [[ monster.dexterity.value ]] ([[ monster.dexterity.modifier|mod_str ]]) & + [[ monster.constitution.value ]] ([[ monster.constitution.modifier|mod_str ]]) \\ + \end{tabular} + + \vspace{0.2cm} + + \begin{tabular}{p{0.1\textwidth} p{0.32\textwidth}} + \textbf{Skills:} & [[ monster.skills ]] \\ + \textbf{Senses:} & [[ monster.senses ]] \\ + \textbf{Languages:} & [[ monster.languages ]] \\ + \textbf{Resistance:} & [[ monster.damage_resistance ]] \\ + \textbf{Immunities:} & [[ monster.condition_immunities ]] \\ + \end{tabular} + + \vspace{0.2cm} + + [[ monster.__doc__ | rst_to_latex(top_heading_level=2) ]] + + } %\color + [% endfor %] +[% endif %] diff --git a/dungeonsheets/forms/preamble.tex b/dungeonsheets/forms/preamble.tex index f2f543c..81646a2 100644 --- a/dungeonsheets/forms/preamble.tex +++ b/dungeonsheets/forms/preamble.tex @@ -51,4 +51,5 @@ [% endraw %] \begin{document} -\chapter*{Features, Magical Items and Spells} \ No newline at end of file + +\chapter*{[[ title ]]} diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index f9d2d63..acd42ee 100755 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -9,11 +9,12 @@ import re from pathlib import Path from multiprocessing import Pool, cpu_count from itertools import product +from typing import Union, Mapping, Sequence from jinja2 import Environment, PackageLoader -from dungeonsheets import character as _char, exceptions, readers, latex -from dungeonsheets.stats import mod_str +from dungeonsheets import character as _char, exceptions, readers, latex, monsters +from dungeonsheets.stats import mod_str, findattr from dungeonsheets.fill_pdf_template import ( create_character_pdf_template, create_spells_pdf_template, @@ -23,8 +24,6 @@ from dungeonsheets.character import Character """Program to take character definitions and build a PDF of the character sheet.""" -PDFTK_CMD = "pdftk" - log = logging.getLogger(__name__) ORDINALS = { @@ -53,6 +52,10 @@ jinja_env.filters["mod_str"] = mod_str PDFTK_CMD = "pdftk" +# Custom types +File = Union[Path, str] + + def create_subclasses_tex( character: Character, use_dnd_decorations: bool = False, @@ -77,6 +80,15 @@ def create_magic_items_tex( return template.render(character=character, use_dnd_decorations=use_dnd_decorations) +def create_monsters_tex( + monsters: Sequence[Union[monsters.Monster, str]], + use_dnd_decorations: bool = False, +) -> str: + # Convert strings to Monster objects + template = jinja_env.get_template("monsters_template.tex") + return template.render(monsters=monsters, use_dnd_decorations=use_dnd_decorations) + + def create_spellbook_tex( character: Character, use_dnd_decorations: bool = False, @@ -103,23 +115,15 @@ def create_druid_shapes_tex( return template.render(character=character, use_dnd_decorations=use_dnd_decorations) -def make_sheet( - character_file, - character=None, - flatten=False, - latex_template=True, - fancy_decorations=False, - debug=False, -): - """Prepare a PDF character sheet from the given character file. - +def make_sheet(sheet_file: File, + flatten: bool = False, + fancy_decorations: bool = False, + debug: bool = False): + """Make a character or GM sheet into a PDF. Parameters ---------- - character_file : str + sheet_file File (.py) to load character from. Will save PDF using same name - character : Character, optional - If provided, will not load from the character file, just use - file for PDF name flatten : bool, optional If true, the resulting PDF will look better and won't be fillable form. @@ -129,18 +133,120 @@ def make_sheet( debug : bool, optional Provide extra info and preserve temporary files. + """ + # Parse the file + sheet_file = Path(sheet_file) + base_name = sheet_file.stem + sheet_props = readers.read_sheet_file(sheet_file) + # Create the sheet + if sheet_props.get("sheet_type", "") == "gm": + ret = make_gm_sheet(basename=base_name, gm_props=sheet_props, + fancy_decorations=fancy_decorations, + debug=debug) + else: + ret = make_character_sheet( + basename=base_name, + character_props=sheet_props, + flatten=flatten, + fancy_decorations=fancy_decorations, + debug=debug) + return ret + + +def make_gm_sheet( + basename: str, + gm_props: Mapping, + fancy_decorations: bool = False, + debug: bool = False, +): + """Prepare a PDF character sheet from the given character file. + + Parameters + ---------- + basename + The basename for saving files. + gm_props + Properties for creating the GM notes. + fancy_decorations + Use fancy page layout and decorations for extra sheets, namely + the dnd style file: https://github.com/rpgtex/DND-5e-LaTeX-Template. + debug + Provide extra info and preserve temporary files. + + """ + tex = [ + jinja_env.get_template("preamble.tex").render( + use_dnd_decorations=fancy_decorations, + title=gm_props['session_title'], + ) + ] + # Add the monsters + monsters_ = [findattr(monsters, m)() for m in gm_props.get("monsters", [])] + if len(monsters_) > 0: + tex.append( + create_monsters_tex(monsters_, use_dnd_decorations=fancy_decorations) + ) + # Add the closing TeX + tex.append( + jinja_env.get_template("postamble.tex").render( + use_dnd_decorations=fancy_decorations + ) + ) + # Typeset combined LaTeX file + try: + if len(tex) > 2: + latex.create_latex_pdf( + tex="".join(tex), + basename=basename, + keep_temp_files=debug, + use_dnd_decorations=fancy_decorations, + ) + except exceptions.LatexNotFoundError: + log.warning( + f"``pdflatex`` not available. Skipping {basename}" + ) + + +def make_character_sheet( + basename: str, + character_props: Mapping, + character: Character = None, + flatten: bool = False, + fancy_decorations: bool = False, + debug: bool = False, +): + """Prepare a PDF character sheet from the given character file. + + Parameters + ---------- + basename + The basename for saving files (PDFs, etc). + character_props + Properties to load character from. + character + If provided, will not load from the character file, just use + file for PDF name + flatten + If true, the resulting PDF will look better and won't be + fillable form. + fancy_decorations + Use fancy page layout and decorations for extra sheets, namely + the dnd style file: https://github.com/rpgtex/DND-5e-LaTeX-Template. + debug + Provide extra info and preserve temporary files. + """ if character is None: - char_props = readers.read_sheet_file(character_file) - character = _char.Character.load(char_props) + character = _char.Character.load(character_props) # Set the fields in the FDF - char_base = os.path.splitext(character_file)[0] + "_char" + char_base = basename + "_char" sheets = [char_base + ".pdf"] pages = [] tex = [ jinja_env.get_template("preamble.tex").render( - use_dnd_decorations=fancy_decorations + use_dnd_decorations=fancy_decorations, + title="Features, Magical Items and Spells", ) ] @@ -151,14 +257,13 @@ def make_sheet( pages.append(char_pdf) if character.is_spellcaster: # Create spell sheet - spell_base = "{:s}_spells".format(os.path.splitext(character_file)[0]) + spell_base = "{:s}_spells".format(basename) create_spells_pdf_template( character=character, basename=spell_base, flatten=flatten ) sheets.append(spell_base + ".pdf") # end of PDF gen - - features_base = "{:s}_features".format(os.path.splitext(character_file)[0]) + features_base = "{:s}_features".format(basename) # Create a list of subcasses if character.subclasses: tex.append( @@ -199,18 +304,18 @@ def make_sheet( use_dnd_decorations=fancy_decorations ) ) - + # Typeset combined LaTeX file try: if len(tex) > 2: latex.create_latex_pdf( - "".join(tex), - features_base, + tex="".join(tex), + basename=features_base, keep_temp_files=debug, use_dnd_decorations=fancy_decorations, ) sheets.append(features_base + ".pdf") - final_pdf = os.path.splitext(character_file)[0] + ".pdf" + final_pdf = f"{basename}.pdf" merge_pdfs(sheets, final_pdf, clean_up=True) except exceptions.LatexNotFoundError: log.warning( @@ -250,7 +355,7 @@ def _build(filename, args) -> int: print(f"Processing {basename}...") try: make_sheet( - character_file=filename, + sheet_file=filename, flatten=(not args.editable), debug=args.debug, fancy_decorations=args.fancy_decorations, @@ -268,7 +373,7 @@ def _build(filename, args) -> int: return 1 -def main(): +def main(args=None): # Prepare an argument parser parser = argparse.ArgumentParser( description="Prepare Dungeons and Dragons character sheets as PDFs" @@ -307,7 +412,7 @@ def main(): action="store_true", help="Provide verbose logging for debugging purposes.", ) - args = parser.parse_args() + args = parser.parse_args(args) # Prepare logging if necessary if args.debug: logging.basicConfig(level=logging.DEBUG) @@ -326,6 +431,8 @@ def main(): valid_files.extend(get_char_files(f, parse_dirs=args.recursive)) elif fpath.suffix in known_extensions: valid_files.append(fpath) + else: + log.info(f"Unhandled file: {str(fpath)}") return valid_files temp_filenames = [] @@ -344,6 +451,7 @@ def main(): # Process the requested files if args.debug: for filename in filenames: + print("building") _build(filename, args) else: with Pool(cpu_count()) as p: diff --git a/examples/gm.py b/examples/gm.py index 172ff85..09942ed 100644 --- a/examples/gm.py +++ b/examples/gm.py @@ -8,3 +8,7 @@ monsters, etc. dungeonsheets_version = "0.14.0" sheet_type = "gm" + +session_title = "Objects in Space" + +monsters = ["wolf", "giant eagle"] diff --git a/tests/test_features.py b/tests/test_features.py index 0226fdf..a81d228 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -2,8 +2,8 @@ from unittest import TestCase -from dungeonsheets import features -from dungeonsheets.features import create_feature, Feature, all_features +from dungeonsheets import features, character +from dungeonsheets.features import create_feature, Feature, all_features, bard class TestFeatures(TestCase): @@ -31,3 +31,77 @@ class TestFeatures(TestCase): self.assertEqual(NewFeature.name, "Hello world") feature = NewFeature() print(feature, feature.__class__, type(feature)) + + +class BardTests(TestCase): + def test_bardic_inspiration(self): + # Level 1-4 Bard + char = character.Character(classes=["bard"], levels=[2]) + bi = bard.BardicInspiration(owner=char) + self.assertEqual(bi.name, "Bardic Inspiration (1d6/LR)") + # Level 5-9 Bard + char = character.Character(classes=["bard"], levels=[5]) + bi = bard.BardicInspiration(owner=char) + self.assertEqual(bi.name, "Bardic Inspiration (1d8/SR)") + # Level 10-14 Bard + char = character.Character(classes=["bard"], levels=[10]) + bi = bard.BardicInspiration(owner=char) + self.assertEqual(bi.name, "Bardic Inspiration (1d10/SR)") + # Level 15+ Bard + char = character.Character(classes=["bard"], levels=[15]) + bi = bard.BardicInspiration(owner=char) + self.assertEqual(bi.name, "Bardic Inspiration (1d12/SR)") + + def test_song_of_rest(self): + # Level 1-8 Bard + char = character.Character(classes=["bard"], levels=[2]) + sor = bard.SongOfRest(owner=char) + self.assertEqual(sor.name, "Song of Rest (1d6)") + # Level 9-12 Bard + char = character.Character(classes=["bard"], levels=[9]) + sor = bard.SongOfRest(owner=char) + self.assertEqual(sor.name, "Song of Rest (1d8)") + # Level 13-16 Bard + char = character.Character(classes=["bard"], levels=[13]) + sor = bard.SongOfRest(owner=char) + self.assertEqual(sor.name, "Song of Rest (1d10)") + # Level 17+ Bard + char = character.Character(classes=["bard"], levels=[17]) + sor = bard.SongOfRest(owner=char) + self.assertEqual(sor.name, "Song of Rest (1d12)") + + def test_mantle_of_inspiration(self): + for lvl in range(1, 5): + char = character.Character(classes=["bard"], levels=[lvl]) + moi = bard.MantleOfInspiration(owner=char) + self.assertEqual(moi.name, "Mantle of Inspiration (5HP)") + for lvl in range(5, 10): + char = character.Character(classes=["bard"], levels=[lvl]) + moi = bard.MantleOfInspiration(owner=char) + self.assertEqual(moi.name, "Mantle of Inspiration (8HP)") + for lvl in range(10, 15): + char = character.Character(classes=["bard"], levels=[lvl]) + moi = bard.MantleOfInspiration(owner=char) + self.assertEqual(moi.name, "Mantle of Inspiration (11HP)") + for lvl in range(15, 20): + char = character.Character(classes=["bard"], levels=[lvl]) + moi = bard.MantleOfInspiration(owner=char) + self.assertEqual(moi.name, "Mantle of Inspiration (14HP)") + + def test_psychic_blades(self): + for lvl in range(1, 5): + char = character.Character(classes=["bard"], levels=[lvl]) + pb = bard.PsychicBlades(owner=char) + self.assertEqual(pb.name, "Psychic Blades (2d6)") + for lvl in range(5, 10): + char = character.Character(classes=["bard"], levels=[lvl]) + pb = bard.PsychicBlades(owner=char) + self.assertEqual(pb.name, "Psychic Blades (3d6)") + for lvl in range(10, 15): + char = character.Character(classes=["bard"], levels=[lvl]) + pb = bard.PsychicBlades(owner=char) + self.assertEqual(pb.name, "Psychic Blades (5d6)") + for lvl in range(15, 20): + char = character.Character(classes=["bard"], levels=[lvl]) + pb = bard.PsychicBlades(owner=char) + self.assertEqual(pb.name, "Psychic Blades (8d6)") diff --git a/tests/test_make_sheets.py b/tests/test_make_sheets.py index c294357..c1a98dc 100644 --- a/tests/test_make_sheets.py +++ b/tests/test_make_sheets.py @@ -1,12 +1,55 @@ import unittest import os +from pathlib import Path -from dungeonsheets import make_sheets, character +from dungeonsheets import make_sheets, character, monsters from dungeonsheets.classes import monk +EG_DIR = Path(__file__).parent.parent.resolve() / "examples" +CHARFILE = EG_DIR / "rogue1.py" +GMFILE = EG_DIR / "gm.py" -EG_DIR = os.path.abspath(os.path.join(os.path.split(__file__)[0], "../examples/")) -CHARFILE = os.path.join(EG_DIR, "rogue1.py") + +class MakeSheetsTestCase(unittest.TestCase): + char_pdf = Path(f"{CHARFILE.stem}.pdf") + gm_pdf = Path(f"{GMFILE.stem}.pdf").resolve() + + def tearDown(self): + if self.char_pdf.exists(): + self.char_pdf.unlink() + if self.gm_pdf.exists(): + self.gm_pdf.unlink() + + def test_main(self): + make_sheets.main(args=[str(CHARFILE), "--debug"]) + + def test_make_sheets(self): + # Character PDF + make_sheets.make_sheet(sheet_file=CHARFILE) + # Was the PDF created? + self.assertTrue(self.char_pdf.exists(), + f"Character PDF ({self.char_pdf.resolve()}) not created.") + # GM PDF + make_sheets.make_sheet(sheet_file=GMFILE) + self.assertTrue(self.gm_pdf.exists) + # Was the PDF created? + self.assertTrue(self.gm_pdf.exists(), + f"GM PDF ({self.gm_pdf.resolve()}) not created.") + + def test_make_fancy_sheets(self): + # Character PDF + make_sheets.make_sheet(sheet_file=CHARFILE, + fancy_decorations=True) + # Was the PDF created? + self.assertTrue(self.char_pdf.exists(), + f"Character PDF ({self.char_pdf.resolve()}) not created.") + # GM PDF + make_sheets.make_sheet(sheet_file=GMFILE, + fancy_decorations=True) + self.assertTrue(self.gm_pdf.exists) + # Was the PDF created? + self.assertTrue(self.gm_pdf.exists(), + f"GM PDF ({self.gm_pdf.resolve()}) not created.") class PdfOutputTestCase(unittest.TestCase): @@ -80,3 +123,8 @@ class TexCreatorTestCase(unittest.TestCase): tex = make_sheets.create_druid_shapes_tex(character=char) self.assertIn(r"\section*{Known Beasts}", tex) self.assertIn(r"\section*{Crocodile}", tex) + + def test_create_monsters_tex(self): + monsters_ = [monsters.GiantEagle()] + tex = make_sheets.create_monsters_tex(monsters=monsters_) + self.assertIn(r"Giant eagle", tex)