diff --git a/VERSION b/VERSION index 09a3acf..7ceb040 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.6.1 \ No newline at end of file diff --git a/docs/character_files.rst b/docs/character_files.rst index f7f46a8..09380e0 100644 --- a/docs/character_files.rst +++ b/docs/character_files.rst @@ -151,6 +151,14 @@ correspond to spells described in the `player's handbook`_. spells_prepared = ('blindness deafness', 'false life', 'mage armor', 'ray of sickness', 'shield', 'sleep',) +.. note:: + + Some character classes have modified spellcasting mechanics that + affects how these entries are intepreted. + + - `Druid`_ + + Personality and Backstory ========================= @@ -210,6 +218,47 @@ source file more readable, but are not required. priests there for assistance that won’t endanger them.""") +Class-Specific Features +======================= + +Druid +----- + +At level 2, druids choose a **circle**. This choice can affect +available wild_forms, and spellcasting abilities. The ``circle`` entry +should be set appropriately. + +Druid's can transform into **wild shapes**, allowing them to adopt +some of the abilities of their new form. To aid in keeping track on +the possible shapes, Druids can have a listing for +``wild_shapes``. This list should contain names of beasts listed in +:py:mod:`dungeonsheets.monsters`, or instances of a subclass of +:py:class:`dungeonsheets.monsters.Monster`. If given, an extra *monster +sheet* will be produced as part of the PDF. Beasts familiar to the +druid but not yet accessible should still be listed to aid in record +keeping; they will be greyed-out on the sheet. + +Additionally, druids don't learn spells, instead **druids can prepare +any spell available** provided it meets their level requirements. As +such, the listing for ``spells`` is not needed and **all prepared +spells and known cantrips** should be listed in the +``spells_prepared`` entry. + +.. code:: python + + # We're a moon druid, why not + circle = 'Moon' + + # Spells are empty because we don't learn any spells + spells = [] + # This one has all prepared spells and cantrips + spells_prepared = ['druidcraft', 'cure wounds'] + + # List of all the known wild shapes + wild_shapes = ["wolf", "crocodile", 'ape', 'ankylosaurus'] + + + .. _player's handbook: http://dnd.wizards.com/products/tabletop-games/rpg-products/rpg_playershandbook .. _issue: https://github.com/canismarko/dungeon-sheets/issues diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 641b164..124d7f7 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -2,6 +2,7 @@ import re import warnings +import math from .stats import Ability, Skill, findattr from .dice import read_dice_str @@ -86,8 +87,6 @@ class Character(): spellcasting_ability = None spells = tuple() spells_prepared = tuple() - # Druid wilf shape transofmration options - _wild_shapes = () def __init__(self, **attrs): """Takes a bunch of attrs and passes them to ``set_attrs``""" @@ -100,82 +99,6 @@ class Character(): def __repr__(self): return f"<{self.class_name}: {self.name}>" - @property - def all_wild_shapes(self): - """Return all wild shapes, regardless of validity.""" - return self._wild_shapes - - @property - def wild_shapes(self): - """Return a list of valid wild shapes for this Druid.""" - valid_shapes = [] - for shape in self._wild_shapes: - # Check if shape can be transformed into - if self.can_assume_shape(shape): - valid_shapes.append(shape) - return valid_shapes - - @wild_shapes.setter - def wild_shapes(self, new_shapes): - actual_shapes = [] - # Retrieve the actual monster classes if possible - for shape in new_shapes: - if isinstance(shape, monsters.Monster): - # Already a monster shape so just add it as is - new_shape = shape - else: - # Not already a monster so see if we can find one - try: - NewMonster = findattr(monsters, shape) - new_shape = NewMonster() - except AttributeError: - msg = f'Wild shape "{shape}" not found. Please add it to ``monsters.py``' - raise exceptions.MonsterError(msg) - actual_shapes.append(new_shape) - # Save the updated list for later - self._wild_shapes = actual_shapes - - def can_assume_shape(self, shape: monsters.Monster)-> bool: - """Determine if a given shape meets the requirements for transforming. - - See Pg 66 of player's handbook. - - Parameters - ========== - shape - A monster that the Druid wishes to transform into. - - Returns - ======= - can_assume - True if the monster meets the C/R, swim and flying speed - restrictions. - - """ - # Determine acceptable states based on druid level - if self.level < 2: - max_cr = -1 - max_swim = 0 - max_fly = 0 - elif self.level < 4: - max_cr = 1/4 - max_swim = 0 - max_fly = 0 - elif self.level < 8: - max_cr = 1/2 - max_swim = None - max_fly = 0 - else: - max_cr = None - max_swim = None - max_fly = None - # 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) - valid_fly = (max_fly is None or shape.fly_speed <= max_fly) - can_assume = shape.is_beast and valid_cr and valid_swim and valid_fly - return can_assume - @property def speed(self): return getattr(self.race, 'speed', 30) @@ -424,6 +347,8 @@ class Cleric(Character): class Druid(Character): class_name = 'Druid' + circle = "" # Moon, land + _wild_shapes = () hit_dice_faces = 8 saving_throw_proficiencies = ('intelligence', 'wisdom') spellcasting_ability = 'wisdom' @@ -460,6 +385,99 @@ class Druid(Character): 19: (4, 4, 3, 3, 3, 3, 2, 1, 1, 1), 20: (4, 4, 3, 3, 3, 3, 2, 2, 1, 1), } + + @property + def all_wild_shapes(self): + """Return all wild shapes, regardless of validity.""" + return self._wild_shapes + + @property + def wild_shapes(self): + """Return a list of valid wild shapes for this Druid.""" + valid_shapes = [] + for shape in self._wild_shapes: + # Check if shape can be transformed into + if self.can_assume_shape(shape): + valid_shapes.append(shape) + return valid_shapes + + @wild_shapes.setter + def wild_shapes(self, new_shapes): + actual_shapes = [] + # Retrieve the actual monster classes if possible + for shape in new_shapes: + if isinstance(shape, monsters.Monster): + # Already a monster shape so just add it as is + new_shape = shape + else: + # Not already a monster so see if we can find one + try: + NewMonster = findattr(monsters, shape) + new_shape = NewMonster() + except AttributeError: + msg = f'Wild shape "{shape}" not found. Please add it to ``monsters.py``' + raise exceptions.MonsterError(msg) + actual_shapes.append(new_shape) + # Save the updated list for later + self._wild_shapes = actual_shapes + + def can_assume_shape(self, shape: monsters.Monster)-> bool: + """Determine if a given shape meets the requirements for transforming. + + See Pg 66 of player's handbook. + + Parameters + ========== + shape + A monster that the Druid wishes to transform into. + + Returns + ======= + can_assume + True if the monster meets the C/R, swim and flying speed + restrictions. + + """ + # Determine acceptable states based on druid level + if self.level < 2: + max_cr = -1 + max_swim = 0 + max_fly = 0 + elif self.level < 4: + max_cr = 1/4 + max_swim = 0 + max_fly = 0 + elif self.level < 8: + max_cr = 1/2 + max_swim = None + max_fly = 0 + else: + max_cr = 1 + max_swim = None + max_fly = None + # Make adjustments for moon cirlce druids + if self.circle.lower() == "moon": + if 2 <= self.level < 6: + max_cr = 1 + 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) + valid_fly = (max_fly is None or shape.fly_speed <= max_fly) + can_assume = shape.is_beast and valid_cr and valid_swim and valid_fly + return can_assume + + @property + def spells(self): + return tuple(S() for S in self.spells_prepared) + + @spells.setter + def spells(self, val): + if len(val) > 0: + warnings.warn("Druids cannot learn spells, " + "use ``spells_prepared`` instead.", + RuntimeWarning) class Fighter(Character): diff --git a/dungeonsheets/druid_shapes_template.tex b/dungeonsheets/druid_shapes_template.tex index e6ee999..af65594 100644 --- a/dungeonsheets/druid_shapes_template.tex +++ b/dungeonsheets/druid_shapes_template.tex @@ -26,9 +26,17 @@ ] -[% for shape in character.wild_shapes|sort(attribute='challenge_rating') %] +[% for shape in character.all_wild_shapes|sort(attribute='challenge_rating') %] + [% if not character.can_assume_shape(shape) %] + {\color{mygrey} + [% else %] + { + [% endif %] + \section*{[[ shape.name ]]} + [% if shape.description %] \subsection*{[[ shape.description ]]} + [% endif %] \begin{tabular}{c | c | c} Armor Class & Hit Points & Speed \\ @@ -57,15 +65,17 @@ \vspace{0.2cm} - \begin{tabular}{l l} + \begin{tabular}{p{0.1\textwidth} p{0.32\textwidth}} \textbf{Skills:} & [[ shape.skills ]] \\ \textbf{Senses:} & [[ shape.senses ]] \\ + \textbf{Languages:} & [[ shape.languages ]] \\ \end{tabular} \vspace{0.2cm} [[ shape.__doc__ | rst_to_latex ]] + } %\color [% endfor %] \end{document} diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index aca2dc4..84f39ab 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -24,13 +24,19 @@ character sheet.""" bold_re = re.compile(r'\*\*([^*]+)\*\*') it_re = re.compile(r'\*([^*]+)\*') tt_re = re.compile(r'``([^`]+)``') +# A dice string, with optinal backticks: ``1d6 + 3`` +dice_re = re.compile(r'`*(\d+d\d+(?:\s*\+\s*\d+)?)`*') def rst_to_latex(rst): """Basic markup of RST to LaTeX code.""" - tex = rst - tex = bold_re.sub(r'\\textbf{\1}', tex) - tex = it_re.sub(r'\\textit{\1}', tex) - tex = tt_re.sub(r'\\texttt{\1}', tex) + if rst is None: + tex = "" + else: + tex = rst + tex = bold_re.sub(r'\\textbf{\1}', tex) + tex = it_re.sub(r'\\textit{\1}', tex) + tex = dice_re.sub(r'\\texttt{\1}', tex) + tex = tt_re.sub(r'\\texttt{\1}', tex) return tex @@ -437,7 +443,6 @@ def _make_pdf_pdftk(fields, src_pdf, basename, flatten=False): def make_sheet(character_file, flatten=False): - """Prepare a PDF character sheet from the given character file. Parameters @@ -472,7 +477,8 @@ def make_sheet(character_file, flatten=False): else: sheets.append(spellbook_base + '.pdf') # Create a list of Druid wild_shapes - if len(char.wild_shapes) > 0: + wild_shapes = getattr(char, 'wild_shapes', []) + if len(wild_shapes) > 0: shapes_base = os.path.splitext(character_file)[0] + '_wild_shapes' try: create_druid_shapes_pdf(character=char, basename=shapes_base) diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py index f6a05f2..cc986a7 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -12,6 +12,8 @@ class Monster(): challenge_rating = 0 armor_class = 0 skills = "Perception +3, Stealth +4" + senses = "" + languages = "" strength = Ability() dexterity = Ability() constitution = Ability() @@ -30,12 +32,129 @@ class Monster(): return is_beast -class Crocodile(Monster): - name = "Crocodile" +class Ankylosaurus(Monster): + """Thick armor plating covers the body of the plant-eating dinosaur + ankylosaurus, which defends itself against predators with a + knobbed tail that delivers a devastating strike. + + **Tail:** *Melee Weapon Attack:* +7 to hit, reach 10 ft., one + target. *Hit:* 18 (4d6+4) bludgeoning damage. If the target is a + creature, it must succeed on a DC 14 Strength saving throw or be + knocked prone. + + """ + name = "Ankylosaurus" + description = "Huge beast, unaligned" + challenge_rating = 3 + armor_class = 15 + skills = "" + senses = "Passive perception 11" + strength = Ability(19) + dexterity = Ability(11) + constitution = Ability(15) + intelligence = Ability(2) + wisdom = Ability(12) + charisma = Ability(5) + speed = 30 + swim_speed = 0 + fly_speed = 0 + hp_max = 68 + hit_dice = '8d12+16' +class Ape(Monster): + """**Multiattack:** The ape makes two fist attacks. + + **Fist:** *Melee Weapon Attack:* +5 to hit, reach 5 ft., one + target. *Hit:* 6 (1d6+3) bludgeoning damage. + + **Rock:** *Ranged Weapon Attack:* +5 to hit, range 25/50 ft., one + target. *Hit:* 6 (1d6+3) bludgeoning damage. + + """ + name = "Ape" + description = "Medium beast, unaligned" + challenge_rating = 1 / 2 + armor_class = 12 + skills = "Athletics +5, Perception +3" + senses = "Passive perception 13" + strength = Ability(16) + dexterity = Ability(14) + constitution = Ability(14) + intelligence = Ability(6) + wisdom = Ability(12) + charisma = Ability(7) + speed = 30 + swim_speed = 0 + fly_speed = 0 + hp_max = 19 + hit_dice = '3d8+6' + + +class Crocodile(Monster): + """**Hold Breath:** The crocodile can hold its breath for 15 minutes. + + **Bite:** *Melee Weapon Attack:* +4 to hit, reach 5 ft., one + creature. *Hit:* 7 (1d10+2) piercing damage, and the target is + Grappled (escape DC 12). Until this grapple ends, the target is + Restrained, and the crocodile can't bite another target. + + """ + name = "Crocodile" + description = "Large beast, unaligned" + challenge_rating = 1/2 + armor_class = 12 + skills = "Stealth +2" + senses = "Passive perception 10" + strength = Ability(15) + dexterity = Ability(10) + constitution = Ability(13) + intelligence = Ability(2) + wisdom = Ability(10) + charisma = Ability(5) + speed = 30 + swim_speed = 30 + fly_speed = 0 + hp_max = 19 + hit_dice = '3d10+3' + class GiantEagle(Monster): + """A giant eagle is a noble creature that speaks its own language and + understands Speech in the Common tongue. A mated pair of giant + eagles typically has up to four eggs or young in their nest (treat + the young as normal eagles). + + **Keen Sight:** The eagle has advantage on Wisdom (Perception) + checks that rely on sight. + + **Multiattack:** The eagle makes two attacks: one with its beak + and one with its talons. + + **Beak:** *Melee Weapon Attack:* +5 to hit, reach 5 ft., one + target. *Hit:* 6 (1d6 + 3) piercing damage. + + **Talons:** *Melee Weapon Attack:* +5 to hit, reach 5 ft., one + target. *Hit:* 10 (2d6 + 3) slashing damage. + + """ name = "Giant eagle" + description = "Large beast, neutral good" + challenge_rating = 1 + armor_class = 13 + skills = "Perception +4" + senses = "Passive perception 14" + languages = "Giant Eagle, understands common and Auran but can't speak." + strength = Ability(16) + dexterity = Ability(17) + constitution = Ability(13) + intelligence = Ability(8) + wisdom = Ability(14) + charisma = Ability(10) + speed = 10 + swim_speed = 0 + fly_speed = 80 + hp_max = 26 + hit_dice = '4d10+4' class Spider(Monster): @@ -105,7 +224,7 @@ class Wolf(Monster): the creature and the ally isn't incapacitated. Actions **Bite.** *Melee Weapon Attack:* +4 to hit, reach 5 ft., one - target. *Hit:* (2d4 + 2) piercing damage. If the target is a + target. *Hit:* (2d4+2) piercing damage. If the target is a creature, it must succeed on a DC 11 Strength saving throw or be knocked prone diff --git a/examples/druid.pdf b/examples/druid.pdf index cd76775..bfc5040 100644 Binary files a/examples/druid.pdf and b/examples/druid.pdf differ diff --git a/examples/druid.py b/examples/druid.py index f3dbb12..217cd2d 100644 --- a/examples/druid.py +++ b/examples/druid.py @@ -9,6 +9,7 @@ dungeonsheets_version = "0.5.0" name = 'Dain Torunn' character_class = 'Druid' +circle = 'moon' player_name = 'Emily' background = "Sailor" race = "Hill Dwarf" @@ -45,13 +46,11 @@ shield = "" # Eg "shield" equipment = "TODO: Describe your equipment from your Druid class and Sailor background." attacks_and_spellcasting = "TODO: Describe specifics for how your Druid attacks." -wild_shapes = ["wolf", "crocodile", "giant eagle"] +wild_shapes = ["wolf", "crocodile", "giant eagle", 'ape', 'ankylosaurus'] # List of known spells -# Example: spells = ('magic missile', 'mage armor') -spells = () # Todo: Learn some spells -# Which spells have been prepared (not including cantrips) -spells_prepared = () +# Which spells have been prepared (including cantrips) +spells_prepared = ('shillelagh', 'poison spray', 'druidcraft','speak with animals', 'entangle', 'cure wounds', 'create or destroy water') # Backstory # TODO: Describe your backstory here diff --git a/examples/rogue.pdf b/examples/rogue.pdf index 893949a..05378a4 100644 Binary files a/examples/rogue.pdf and b/examples/rogue.pdf differ diff --git a/examples/warlock.pdf b/examples/warlock.pdf index 305ad65..4e4c840 100644 Binary files a/examples/warlock.pdf and b/examples/warlock.pdf differ diff --git a/examples/wizard.pdf b/examples/wizard.pdf index fed0b7d..c9b6094 100644 Binary files a/examples/wizard.pdf and b/examples/wizard.pdf differ diff --git a/tests/test_character.py b/tests/test_character.py index 8a43f90..595d25d 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -1,8 +1,9 @@ #!/usr/bin/env python from unittest import TestCase +import warnings -from dungeonsheets import race, monsters, exceptions +from dungeonsheets import race, monsters, exceptions, spells from dungeonsheets.character import Character, Wizard, Druid from dungeonsheets.weapons import Weapon, Shortsword from dungeonsheets.armor import Armor, LeatherArmor, Shield @@ -131,6 +132,51 @@ class TestCharacter(TestCase): self.assertEqual(char.spell_slots(spell_level=1), 3) self.assertEqual(char.spell_slots(spell_level=2), 0) + def test_equip_armor(self): + char = Character(dexterity=16) + char.wear_armor('leather armor') + self.assertTrue(isinstance(char.armor, Armor)) + # Now make sure the armor class is correct + self.assertEqual(char.armor_class, 14) + # Try passing an Armor object directly + char.wear_armor(LeatherArmor()) + self.assertEqual(char.armor_class, 14) + # Test equipped armor with max dexterity mod_str + char.armor.dexterity_mod_max = 1 + self.assertEqual(char.armor_class, 12) + + def test_wield_shield(self): + char = Character(dexterity=16) + char.wield_shield('shield') + self.assertTrue(isinstance(char.shield, Shield), msg=char.shield) + # Now make sure the armor class is correct + self.assertEqual(char.armor_class, 15) + # Try passing an Armor object directly + char.wield_shield(Shield) + self.assertEqual(char.armor_class, 15) + + def test_speed(self): + # Check that the speed pulls from the character's race + char = Character(race='halfling') + self.assertEqual(char.speed, 25) + # Check that a character with no race defaults to 30 feet + char = Character() + char.race = None + self.assertEqual(char.speed, 30) + + +class DruidTestCase(TestCase): + def test_learned_spells(self): + """For a druid, learning spells is not necessary and this field should + be ignored.""" + char = Druid() + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', message="Druids cannot learn spells") + char.set_attrs(spells=['invisibility'], + spells_prepared=['druidcraft']) + self.assertEqual(len(char.spells), 1) + self.assertIsInstance(char.spells[0], spells.Druidcraft) + def test_wild_shapes(self): char = Druid() # Druid level 2 @@ -149,6 +195,16 @@ class TestCharacter(TestCase): self.assertEqual(len(char.wild_shapes), 1) self.assertIsInstance(char.wild_shapes[0], monsters.Wolf) + def test_moon_druid_wild_shapes(self): + # Moon druid level 2 gets beasts up to CR 1 + char = Druid(level=2, wild_shapes=['Ape'], circle='moon') + self.assertEqual(len(char.wild_shapes), 1) + self.assertIsInstance(char.wild_shapes[0], monsters.Ape) + # Moon druid above level 6 gets beasts up to CR level / 3 + char = Druid(level=9, wild_shapes=['ankylosaurus'], circle='moon') + self.assertEqual(len(char.wild_shapes), 1) + self.assertIsInstance(char.wild_shapes[0], monsters.Ankylosaurus) + def test_can_assume_shape(self): class Beast(monsters.Monster): description = 'beast' @@ -188,35 +244,3 @@ class TestCharacter(TestCase): not_beast = monsters.Monster() not_beast.description = "monster" self.assertFalse(low_druid.can_assume_shape(not_beast)) - - def test_equip_armor(self): - char = Character(dexterity=16) - char.wear_armor('leather armor') - self.assertTrue(isinstance(char.armor, Armor)) - # Now make sure the armor class is correct - self.assertEqual(char.armor_class, 14) - # Try passing an Armor object directly - char.wear_armor(LeatherArmor()) - self.assertEqual(char.armor_class, 14) - # Test equipped armor with max dexterity mod_str - char.armor.dexterity_mod_max = 1 - self.assertEqual(char.armor_class, 12) - - def test_wield_shield(self): - char = Character(dexterity=16) - char.wield_shield('shield') - self.assertTrue(isinstance(char.shield, Shield), msg=char.shield) - # Now make sure the armor class is correct - self.assertEqual(char.armor_class, 15) - # Try passing an Armor object directly - char.wield_shield(Shield) - self.assertEqual(char.armor_class, 15) - - def test_speed(self): - # Check that the speed pulls from the character's race - char = Character(race='halfling') - self.assertEqual(char.speed, 25) - # Check that a character with no race defaults to 30 feet - char = Character() - char.race = None - self.assertEqual(char.speed, 30) diff --git a/tests/test_make_sheets.py b/tests/test_make_sheets.py index 89c77bb..7c90421 100644 --- a/tests/test_make_sheets.py +++ b/tests/test_make_sheets.py @@ -30,3 +30,20 @@ class PdfOutputTeestCase(unittest.TestCase): char.saving_throw_proficiencies = ['strength'] make_sheets.create_character_pdf(character=char, basename=self.basename) self.assertTrue(os.path.exists(pdf_name), f'{pdf_name} not created.') + + +class MarkdownTestCase(unittest.TestCase): + """Check that conversion of markdown formats to LaTeX code works + correctly.""" + + def test_rst_bold(self): + text = make_sheets.rst_to_latex('**hello**') + self.assertEqual(text, '\\textbf{hello}') + + def test_hit_dice(self): + text = make_sheets.rst_to_latex('1d6+3') + self.assertEqual(text, '\\texttt{1d6+3}') + + def test_no_text(self): + text = make_sheets.rst_to_latex(None) + self.assertEqual(text, '')