diff --git a/VERSION b/VERSION index 8f0916f..09a3acf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 \ No newline at end of file diff --git a/dungeonsheets/armor.py b/dungeonsheets/armor.py index b25f5a2..4d5e1f1 100644 --- a/dungeonsheets/armor.py +++ b/dungeonsheets/armor.py @@ -8,6 +8,10 @@ class Shield(): return self.name +class WoodenShield(Shield): + name = 'Wooden shield' + + class NoShield(Shield): """If a character is carrying no shield.""" name = "No shield" @@ -64,22 +68,22 @@ class LightPaddedArmor(Armor): stealth_disadvantage = True -class LightLeatherArmor(Armor): - name = "Light leather armor" +class LeatherArmor(Armor): + name = "Leather armor" cost = "10 gp" base_armor_class = 11 weight = 10 -class LightStuddedArmor(Armor): - name = "Light studded armor" +class StuddedArmor(Armor): + name = "Studded armor" cost = "45 gp" base_armor_class = 12 weight = 13 -class MediumHideArmor(Armor): - name = "Medium hide armor" +class HideArmor(Armor): + name = "Hide armor" cost = "10 gp" base_armor_class = 12 dexterity_mod_max = 2 diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index a2d300d..641b164 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -5,7 +5,7 @@ import warnings from .stats import Ability, Skill, findattr from .dice import read_dice_str -from . import weapons, race, spells, armor +from . import weapons, race, spells, armor, monsters, exceptions from .weapons import Weapon from .armor import Armor, NoArmor, Shield, NoShield @@ -86,6 +86,8 @@ 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``""" @@ -98,6 +100,82 @@ 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) @@ -126,7 +204,8 @@ class Character(): try: _spells.append(findattr(spells, spell_name)) except AttributeError: - msg = f'Spell "{spell_name}" not defined. Please add it to ``spells.py``' + msg = (f'Spell "{spell_name}" not defined. ' + f'Please add it to ``spells.py``') warnings.warn(msg) # Create temporary spell _spells.append(spells.create_spell(name=spell_name, level=9)) @@ -213,12 +292,12 @@ class Character(): """ if new_armor not in ('', None): - try: + if isinstance(new_armor, armor.Armor): + new_armor = new_armor + else: NewArmor = findattr(armor, new_armor) - except AttributeError: - # Not a string, so just treat it as Armor - NewArmor = new_armor - self.armor = NewArmor() + new_armor = NewArmor() + self.armor = new_armor def wield_shield(self, shield): """Accepts a string or Shield class and replaces the current armor. @@ -347,6 +426,8 @@ class Druid(Character): class_name = 'Druid' hit_dice_faces = 8 saving_throw_proficiencies = ('intelligence', 'wisdom') + spellcasting_ability = 'wisdom' + languages = 'Druidic' _proficiencies_text = ( 'Light armor', 'medium armor', 'shields (druids will not wear armor or use shields made of metal)', @@ -357,6 +438,28 @@ class Druid(Character): weapons.Scimitar, weapons.Sickle, weapons.Sling, weapons.Spear) class_skill_choices = ('Arcana', 'Animal Handling', 'Insight', 'Medicine', 'Nature', 'Perception', 'Religion', 'Survival') + spell_slots_by_level = { + 1: (2, 2, 0, 0, 0, 0, 0, 0, 0, 0), + 2: (2, 3, 0, 0, 0, 0, 0, 0, 0, 0), + 3: (2, 4, 2, 0, 0, 0, 0, 0, 0, 0), + 4: (3, 4, 3, 0, 0, 0, 0, 0, 0, 0), + 5: (3, 4, 3, 2, 0, 0, 0, 0, 0, 0), + 6: (3, 4, 3, 3, 0, 0, 0, 0, 0, 0), + 7: (3, 4, 3, 3, 1, 0, 0, 0, 0, 0), + 8: (3, 4, 3, 3, 2, 0, 0, 0, 0, 0), + 9: (3, 4, 3, 3, 3, 1, 0, 0, 0, 0), + 10: (4, 4, 3, 3, 3, 2, 0, 0, 0, 0), + 11: (4, 4, 3, 3, 3, 2, 1, 0, 0, 0), + 12: (4, 4, 3, 3, 3, 2, 1, 0, 0, 0), + 13: (4, 4, 3, 3, 3, 2, 1, 1, 0, 0), + 14: (4, 4, 3, 3, 3, 2, 1, 1, 0, 0), + 15: (4, 4, 3, 3, 3, 2, 1, 1, 1, 0), + 16: (4, 4, 3, 3, 3, 2, 1, 1, 1, 0), + 17: (4, 4, 3, 3, 3, 2, 1, 1, 1, 1), + 18: (4, 4, 3, 3, 3, 3, 1, 1, 1, 1), + 19: (4, 4, 3, 3, 3, 3, 2, 1, 1, 1), + 20: (4, 4, 3, 3, 3, 3, 2, 2, 1, 1), + } class Fighter(Character): diff --git a/dungeonsheets/character_template.txt b/dungeonsheets/character_template.txt index 94fc076..9c21ce9 100644 --- a/dungeonsheets/character_template.txt +++ b/dungeonsheets/character_template.txt @@ -5,6 +5,8 @@ sheet by running ``makesheets`` from the command line. """ +dungeonsheets_version = "{{ dungeonsheets_version }}" + name = '{{ char.name }}' character_class = '{{ char.class_name }}' player_name = '{{ char.player_name }}' diff --git a/dungeonsheets/create_character.py b/dungeonsheets/create_character.py index 28e6cb7..8690cb0 100755 --- a/dungeonsheets/create_character.py +++ b/dungeonsheets/create_character.py @@ -16,6 +16,13 @@ import jinja2 from dungeonsheets import character, race, dice, background + +def read_version(): + version = open(os.path.join(os.path.dirname(__file__), '../VERSION')).read() + version = version.replace('\n', '') + return version + + char_classes = { 'Barbarian': character.Barbarian, 'Bard': character.Bard, @@ -69,7 +76,8 @@ class App(npyscreen.NPSAppManaged): def save_character(self): # Create the template context context = dict( - char=self.character + char=self.character, + dungeonsheets_version=read_version(), ) # Render the template src_path = os.path.dirname(__file__) @@ -353,7 +361,7 @@ class BasicInfoForm(npyscreen.ActionForm): self.parentApp.setNextForm('CLASS') def on_cancel(self): - self.parentApp.setNextForm(None) + raise KeyboardInterrupt class SaveForm(npyscreen.ActionForm): diff --git a/dungeonsheets/druid_shapes_template.tex b/dungeonsheets/druid_shapes_template.tex new file mode 100644 index 0000000..e6ee999 --- /dev/null +++ b/dungeonsheets/druid_shapes_template.tex @@ -0,0 +1,71 @@ +\documentclass[twocolumn,lettersize]{article} + +%% \usepackage{fullpage} +\usepackage[margin=1.5cm]{geometry} +\usepackage[dvipsnames]{color} +\usepackage{indentfirst} +\definecolor{mygrey}{gray}{0.7} + +\title{Wild Shapes} +\date{} + +\author{[[ character.name ]]} + +\begin{document} + + +\twocolumn[ + \begin{@twocolumnfalse} + \maketitle + \section*{Known Beasts} + [% for shape in character.all_wild_shapes|sort(attribute="name") %]% + [[ shape.name ]][% if not loop.last %], [% endif %]% + [% endfor %]% + \vspace{3ex} + \end{@twocolumnfalse} +] + + +[% for shape in character.wild_shapes|sort(attribute='challenge_rating') %] + \section*{[[ shape.name ]]} + \subsection*{[[ shape.description ]]} + + \begin{tabular}{c | c | c} + Armor Class & Hit Points & Speed \\ + \hline + [[ shape.armor_class ]] & + [[ shape.hit_points_max ]] ([[ shape.hit_dice ]]) & + [[ shape.speed ]] \\ + [% if shape.swim_speed %] + & & [[ shape.swim_speed ]] swim \\ + [% endif %] + [% if shape.fly_speed %] + & & [[ shape.fly_speed ]] fly \\ + [% endif %] + + \end{tabular} + + \vspace{0.2cm} + + \begin{tabular}{c | c | c} + STR & DEX & CON \\ + \hline + [[ shape.strength.value ]] ([[ shape.strength.modifier|mod_str ]]) & + [[ shape.dexterity.value ]] ([[ shape.dexterity.modifier|mod_str ]]) & + [[ shape.constitution.value ]] ([[ shape.constitution.modifier|mod_str ]]) \\ + \end{tabular} + + \vspace{0.2cm} + + \begin{tabular}{l l} + \textbf{Skills:} & [[ shape.skills ]] \\ + \textbf{Senses:} & [[ shape.senses ]] \\ + \end{tabular} + + \vspace{0.2cm} + + [[ shape.__doc__ | rst_to_latex ]] + +[% endfor %] + +\end{document} diff --git a/dungeonsheets/exceptions.py b/dungeonsheets/exceptions.py index 6a72e38..2023ec7 100644 --- a/dungeonsheets/exceptions.py +++ b/dungeonsheets/exceptions.py @@ -9,3 +9,6 @@ class LatexError(OSError): class LatexNotFoundError(LatexError): """PDFLatex did not execute correctly.""" + +class MonsterError(AttributeError): + """Error retriving or using a D&D Monster.""" diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index 44d8d42..aca2dc4 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -8,9 +8,11 @@ import os import subprocess import warnings import re +from io import StringIO from fdfgen import forge_fdf import pdfrw +from jinja2 import Environment, PackageLoader from dungeonsheets import character, exceptions from dungeonsheets.stats import mod_str @@ -19,6 +21,29 @@ from dungeonsheets.stats import mod_str """Program to take character definitions and build a PDF of the character sheet.""" +bold_re = re.compile(r'\*\*([^*]+)\*\*') +it_re = re.compile(r'\*([^*]+)\*') +tt_re = re.compile(r'``([^`]+)``') + +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) + return tex + + +jinja_env = Environment( + loader=PackageLoader('dungeonsheets', ''), + block_start_string='[%', + block_end_string='%]', + variable_start_string='[[', + variable_end_string=']]', +) +jinja_env.filters['rst_to_latex'] = rst_to_latex +jinja_env.filters['mod_str'] = mod_str + CHECKBOX_ON = 'Yes' CHECKBOX_OFF = 'Off' @@ -51,7 +76,7 @@ def load_character_file(filename): if ext != '.py': raise ValueError(f"Character definition {filename} is not a python file.") # Check if this file contains the version string - version_re = re.compile('dungeonsheets_version\s*=\s*[\'"]([0-4.]+)[\'"]') + version_re = re.compile('dungeonsheets_version\s*=\s*[\'"]([0-9.]+)[\'"]') with open(filename, mode='r') as f: version = None for line in f: @@ -62,7 +87,7 @@ def load_character_file(filename): if version is None: # Not a valid DND character file raise exceptions.CharacterFileFormatError( - "No ``dungeonsheets_version = `` entry.") + f"No ``dungeonsheets_version = `` entry in `{filename}`.") # Import the module to extract the information spec = importlib.util.spec_from_file_location('module', filename) module = importlib.util.module_from_spec(spec) @@ -75,16 +100,17 @@ def load_character_file(filename): return char_props +def create_druid_shapes_pdf(character, basename): + template = jinja_env.get_template('druid_shapes_template.tex') + return create_latex_pdf(character, basename, template) + + def create_spellbook_pdf(character, basename): - from jinja2 import Environment, PackageLoader - env = Environment( - loader=PackageLoader('dungeonsheets', ''), - block_start_string='[%', - block_end_string='%]', - variable_start_string='[[', - variable_end_string=']]', - ) - template = env.get_template('spellbook_template.tex') + template = jinja_env.get_template('spellbook_template.tex') + return create_latex_pdf(character, basename, template) + + +def create_latex_pdf(character, basename, template): tex = template.render(character=character) # Create tex document tex_file = f'{basename}.tex' @@ -300,7 +326,8 @@ def create_character_pdf(character, basename, flatten=False): fields[dmg_field] = f'{weapon.damage} {weapon.damage_type}' # Other attack information attack_str = f'Armor: {character.armor}' - attack_str += f'Shield: {character.shield}\n\n' + attack_str += '\n\r' + attack_str += f'Shield: {character.shield}' attack_str += character.attacks_and_spellcasting fields['AttacksSpellcasting'] = text_box(attack_str) # Other proficiencies and languages @@ -444,6 +471,16 @@ def make_sheet(character_file, flatten=False): f'for {char.name}') else: sheets.append(spellbook_base + '.pdf') + # Create a list of Druid wild_shapes + if len(char.wild_shapes) > 0: + shapes_base = os.path.splitext(character_file)[0] + '_wild_shapes' + try: + create_druid_shapes_pdf(character=char, basename=shapes_base) + except exceptions.LatexNotFoundError as e: + log.warning('``pdflatex`` not available. Skipping wild shapes list ' + f'for {char.name}') + else: + sheets.append(shapes_base + '.pdf') # Combine sheets into final pdf final_pdf = os.path.splitext(character_file)[0] + '.pdf' merge_pdfs(sheets, final_pdf, clean_up=True) diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py new file mode 100644 index 0000000..bb87461 --- /dev/null +++ b/dungeonsheets/monsters.py @@ -0,0 +1,69 @@ +"""A collection of monsters. Also useful for building a list of wild +shape forms.""" + + +from .stats import Ability + + +class Monster(): + """A monster that may be encountered when adventuring.""" + name = "Generic Monster" + description = "" + challenge_rating = 0 + armor_class = 0 + skills = "Perception +3, Stealth +4" + strength = Ability() + dexterity = Ability() + constitution = Ability() + intelligence = Ability() + wisdom = Ability() + charisma = Ability() + speed = 30 + swim_speed = 0 + fly_speed = 0 + hp_max = 10 + hit_dice = '1d6' + + @property + def is_beast(self): + is_beast = 'beast' in self.description.lower() + return is_beast + + +class Crocodile(Monster): + name = "Crocodile" + + +class GiantEagle(Monster): + name = "Giant eagle" + + +class Wolf(Monster): + """**Keen Hearing and Smell.** The wolf has advantage on Wisdom + (Perception) checks that rely on hearing or smell. + + **Pack Tactics.** The wolf has advantage on an attack roll against a + creature if at least one of the wolf's allies is within 5 ft. of + 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 + creature, it must succeed on a DC 11 Strength saving throw or be + knocked prone + + """ + name = "Wolf" + description = "Medium beast, unaligned" + challenge_rating = 1/4 + armor_class = 13 + skills = "Perception +3, Stealth +4" + senses = "Passive Perception 13" + strength = Ability(12) + dexterity = Ability(15) + constitution = Ability(12) + intelligence = Ability(6) + wisdom = Ability(12) + charisma = Ability(6) + speed = 40 + hp_max = 11 + hit_dice = '2d8+2' diff --git a/dungeonsheets/spellbook_template.tex b/dungeonsheets/spellbook_template.tex index cd0b9a4..83f9126 100644 --- a/dungeonsheets/spellbook_template.tex +++ b/dungeonsheets/spellbook_template.tex @@ -6,8 +6,8 @@ \definecolor{mygrey}{gray}{0.7} \title{Spells and Incantations} - \author{[[ character.name ]]} +\date{} \begin{document} @@ -29,9 +29,13 @@ [% else %] % \textit{[[ spl.magic_school ]] Cantrip} % [% endif %] % - [% if spl.ritual %] % - \textit{(ritual)} - [% endif %] + [% if spl.ritual and spl.concentration %]% + (\textit{ritual}, \textit{concentration})% + [% elif spl.ritual %]% + (\textit{ritual})% + [% elif spl.concentration %]% + (\textit{concentration})% + [% endif %]% \noindent \textbf{Casting Time:} [[ spl.casting_time ]] \\ @@ -39,7 +43,7 @@ \textbf{Components:} [[ spl.component_string() ]] \\ \textbf{Duration:} [[ spl.duration ]] - [[ spl.__doc__ ]] + [[ spl.__doc__|rst_to_latex ]] } %\color [% endfor %] diff --git a/dungeonsheets/spells.py b/dungeonsheets/spells.py index b80f566..dac341e 100644 --- a/dungeonsheets/spells.py +++ b/dungeonsheets/spells.py @@ -1540,6 +1540,35 @@ class Counterspell(Spell): classes = () +class CreateOrDestroyWater(Spell): + """You either create or destroy water. + + **Create Water.** You create up to 10 gallons of clean water + within range in an open container. Alternatively, the water falls + as rain in a 30-foot cube within range, extinguishing exposed + flames in the area. + + **Destroy Water.** You destroy up to 10 gallons of water in an open + container within range. Alternatively, you destroy fog in a + 30-foot cube within range. + + **At Higher Levels.** When you cast this spell using a spell slot + of 2nd level or higher, you create or destroy 10 additional + gallons of water, or the size of the cube increases by 5 feet, for + each slot level above 1st. + + """ + level = 1 + name = "Create or Destroy Water" + casting_time = "1 action" + casting_range = "30 ft (30 ft cube)" + components = ("V", "S", "M") + materials = "a drop of water if creating water or a few grains of sand if destroying it" + duration = "instantaneous" + magic_school = "Transmutation" + classes = ('Cleric', 'Druid') + + class CreateUndead(Spell): """You can cast this spell only at night. Choose up to three corpses of Medium or Small humanoids within range. Each corpse becomes a @@ -1585,11 +1614,11 @@ class CreateUndead(Spell): class CureWounds(Spell): - """A creature you touch regains a number of hit points equal to 1d8 + - your spellcasting ability modifier. This spell has no effect on - undead or constructs. At Higher Levels. When you cast this spell - using a spell slot of 2nd level or higher, the healing increases - by 1d8 for each slot level above 1st. + """A creature you touch regains a number of hit points equal to + ``1d8`` + your spellcasting ability modifier. This spell has no + effect on undead or constructs. At Higher Levels. When you cast + this spell using a spell slot of 2nd level or higher, the healing + increases by ``1d8`` for each slot level above 1st. """ name = "Cure Wounds" @@ -1725,7 +1754,6 @@ class DetectMagic(Spell): classes = ('Bard', 'Cleric', 'Druid', 'Paladin', 'Ranger', 'Sorceror', 'Wizard', ) - class DimensionDoor(Spell): """You teleport yourself from your current location to any other spot within range. You arrive at exactly the spot desired. It can be a @@ -2061,6 +2089,31 @@ class ElementalWeapon(Spell): classes = ('Paladin', ) +class Entangle(Spell): + """Grasping weeds and vines sprout from the ground in a 20-foot square + starting from a point within range. For the duration, these plants + turn the ground in the area into difficult terrain. + + A creature in the area when you cast the spell must succeed on a + Strength saving throw or be restrained by the entangling plants + until the spell ends. A creature restrained by the plants can use + its action to make a Strength check against your spell save DC. On + a success, it frees itself. + + When the spell ends, the conjured plants wilt away. + + """ + level = 1 + name = "Entangle" + casting_time = "1 action" + casting_range = "90 ft (20 ft area)" + components = ("V", "S") + concentration = True + duration = "instantaneous" + magic_school = "Conjuration" + classes = ('Druid') + + class Etherealness(Spell): """You step into the border regions of the Ethereal Plane, in the area where it overlaps with your current plane. You remain in the @@ -3529,9 +3582,10 @@ class PhantasmalForce(Spell): class PoisonSpray(Spell): """You extend your hand toward a creature you can see within range and project a puff of noxious gas from your palm. The creature must - succeed on a Constitution saving throw or take 1d12 poison - damage. This spell’s damage increases by 1d12 when you reach 5th - level (2d12), 11th level (3d12), and 17th level (4d12). + succeed on a Constitution saving throw or take ``1d12`` poison + damage. This spell’s damage increases by ``1d12`` when you reach + 5th level (``2d12``), 11th level (``3d12``), and 17th level + (``4d12``). """ name = "Poison Spray" @@ -3888,6 +3942,29 @@ class Sanctuary(Spell): classes = () +class Shillelagh(Spell): + """The wood of a club or quarterstaff you are holding is imbued with + nature's power. For the duration, you can use your spellcasting + ability instead of Strength for the attack and damage rolls of + melee attacks using that weapon, and the weapon's damage die + becomes a ``d8``. The weapon also becomes magical, if it isn't + already. The spell ends if you cast it again or if you let go of + the weapon. + + """ + level = 0 + name = "Shillelagh" + casting_time = "1 bonus action" + casting_range = "Touch" + components = ("V", "S", "M") + materials = "mistletoe, a shamrock leaf, and a club or quarterstaff" + duration = "1 minute" + concentration = False + ritual = False + magic_school = "Transmutation" + classes = ('Druid') + + class Shatter(Spell): """A sudden loud ringing noise, painfully intense, erupts from a point of your choice within range. Each creature in a 10-foot-radius @@ -4064,6 +4141,27 @@ class SpareTheDying(Spell): classes = () +class SpeakWithAnimals(Spell): + """You gain the ability to comprehend and verbally communicate with + beasts for the duration. The knowledge and awareness of many + beasts is limited by their intelligence, but at minimum, beasts + can give you information about nearby locations and monsters, + including whatever they can perceive or have perceived within the + past day. You might be able to persuade a beast to perform a small + favor for you, at the GM's discretion. + + """ + level = 1 + name = "Speak with Animals" + casting_time = "1 action" + casting_range = "Self" + components = ("V", "S") + duration = "10 minutes" + ritual = True + magic_school = "Divination" + classes = ('Bard', 'Druid', 'Ranger') + + class SpeakWithDead(Spell): """You grant the semblance of life and intelligence to a corpse of your choice within range, allowing it to answer the questions you diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index 6769763..01ca0cb 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -57,7 +57,7 @@ class Ability(): modifier = math.floor((score - 10) / 2) # Check for proficiency saving_throw = modifier - if self.ability_name is not None: + if self.ability_name is not None and hasattr(character, 'saving_throw_proficiencies'): is_proficient = (self.ability_name in character.saving_throw_proficiencies) if is_proficient: saving_throw += character.proficiency_bonus diff --git a/examples/druid.pdf b/examples/druid.pdf new file mode 100644 index 0000000..cd76775 Binary files /dev/null and b/examples/druid.pdf differ diff --git a/examples/druid.py b/examples/druid.py new file mode 100644 index 0000000..f3dbb12 --- /dev/null +++ b/examples/druid.py @@ -0,0 +1,72 @@ +"""This file describes the heroic adventurer Dain Torunn. + +Modify this file as you level up and then re-generate the character +sheet by running ``makesheets`` from the command line. + +""" + +dungeonsheets_version = "0.5.0" + +name = 'Dain Torunn' +character_class = 'Druid' +player_name = 'Emily' +background = "Sailor" +race = "Hill Dwarf" +level = 2 +alignment = "Neutral good" +xp = 1176 +hp_max = 18 + +# Ability Scores +strength = 10 +dexterity = 14 +constitution = 15 +intelligence = 11 +wisdom = 16 +charisma = 13 +skill_proficiencies = ('nature', 'insight', 'athletics', 'perception') + +# Proficiencies and languages +languages = "Common, Dwarvish" + +# Inventory +# TODO: Get yourself some money +cp = 0 +sp = 0 +ep = 0 +gp = 0 +pp = 0 + +# TODO: Put your equipped weapons and armor here +weapons = () # Example: ('shortsword', 'longsword') +armor = "" # Eg "light leather armor" +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"] + +# 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 = () + +# Backstory +# TODO: Describe your backstory here +personality_traits = """I am a leaf on the wind, +watch how I... +""" + +ideals = """ +""" + +bonds = """ +""" + +flaws = """ +""" + +features_and_traits = """ +""" diff --git a/examples/rogue.pdf b/examples/rogue.pdf index 0e0407c..893949a 100644 Binary files a/examples/rogue.pdf and b/examples/rogue.pdf differ diff --git a/examples/rogue.py b/examples/rogue.py index a7b4784..c3b8d1f 100644 --- a/examples/rogue.py +++ b/examples/rogue.py @@ -36,7 +36,7 @@ ep = 50 gp = 120 pp = 0 weapons = ('shortsword', 'shortbow') -armor = 'light leather armor' +armor = 'leather armor' shield = 'shield' equipment = ( """Shortsword, shortbow, 20 arrows, leather armor, thieves’ tools, diff --git a/examples/warlock.pdf b/examples/warlock.pdf index 3806c67..305ad65 100644 Binary files a/examples/warlock.pdf and b/examples/warlock.pdf differ diff --git a/examples/wizard.pdf b/examples/wizard.pdf index 74c3271..fed0b7d 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 357f60b..8a43f90 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -2,10 +2,10 @@ from unittest import TestCase -from dungeonsheets import race -from dungeonsheets.character import Character, Wizard +from dungeonsheets import race, monsters, exceptions +from dungeonsheets.character import Character, Wizard, Druid from dungeonsheets.weapons import Weapon, Shortsword -from dungeonsheets.armor import Armor, LightLeatherArmor, Shield +from dungeonsheets.armor import Armor, LeatherArmor, Shield class TestCharacter(TestCase): @@ -30,7 +30,7 @@ class TestCharacter(TestCase): self.assertEqual(len(char.weapons), 1) self.assertTrue(isinstance(char.weapons[0], Shortsword)) # Check that armor and shield gets set_attrs - char.set_attrs(armor='light leather armor', shield='shield') + char.set_attrs(armor='leather armor', shield='shield') self.assertFalse(isinstance(char.armor, str)) self.assertFalse(isinstance(char.shield, str)) # Check that race gets set to an object @@ -131,14 +131,72 @@ class TestCharacter(TestCase): self.assertEqual(char.spell_slots(spell_level=1), 3) self.assertEqual(char.spell_slots(spell_level=2), 0) + def test_wild_shapes(self): + char = Druid() + # Druid level 2 + char.level = 2 + # Set reasonable wild shapes + char.wild_shapes = ['Wolf'] + self.assertIsInstance(char.wild_shapes[0], monsters.Wolf) + # Check what happens if a non-existent wild_shape is added + with self.assertRaises(exceptions.MonsterError): + char.wild_shapes = ['Wolf', 'Hyperion Loader'] + # Check what happens if a valid monster class is directly added + char.wild_shapes = [monsters.Wolf(), ] + self.assertIsInstance(char.wild_shapes[0], monsters.Wolf) + # Check that invalid monsters aren't accepted + char.wild_shapes = ['Wolf', 'giant eagle'] + self.assertEqual(len(char.wild_shapes), 1) + self.assertIsInstance(char.wild_shapes[0], monsters.Wolf) + + def test_can_assume_shape(self): + class Beast(monsters.Monster): + description = 'beast' + new_druid = Druid(level=1) + low_druid = Druid(level=2) + mid_druid = Druid(level=4) + high_druid = Druid(level=8) + beast = Beast() + # Check that level 1 druid automatically fails + self.assertFalse(new_druid.can_assume_shape(beast)) + # Check if a basic beast can be transformed + self.assertTrue(low_druid.can_assume_shape(beast)) + # Check that challenge rating is checked + hard_beast = Beast() + hard_beast.challenge_rating = 1/2 + really_hard_beast = Beast() + really_hard_beast.challenge_rating = 1 + self.assertFalse(low_druid.can_assume_shape(hard_beast)) + self.assertFalse(low_druid.can_assume_shape(really_hard_beast)) + self.assertTrue(mid_druid.can_assume_shape(hard_beast)) + self.assertFalse(mid_druid.can_assume_shape(really_hard_beast)) + self.assertTrue(high_druid.can_assume_shape(hard_beast)) + self.assertTrue(high_druid.can_assume_shape(really_hard_beast)) + # Check that swim speed is enforced + swim_beast = Beast() + swim_beast.swim_speed = 15 + self.assertFalse(low_druid.can_assume_shape(swim_beast)) + self.assertTrue(mid_druid.can_assume_shape(swim_beast)) + self.assertTrue(high_druid.can_assume_shape(swim_beast)) + # Check that fly speed is enforced + fly_beast = Beast() + fly_beast.fly_speed = 15 + self.assertFalse(low_druid.can_assume_shape(fly_beast)) + self.assertFalse(mid_druid.can_assume_shape(fly_beast)) + self.assertTrue(high_druid.can_assume_shape(fly_beast)) + # Check that non-beasts are not allowed + 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('light leather armor') + 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(LightLeatherArmor) + char.wear_armor(LeatherArmor()) self.assertEqual(char.armor_class, 14) # Test equipped armor with max dexterity mod_str char.armor.dexterity_mod_max = 1 diff --git a/tests/test_monsters.py b/tests/test_monsters.py new file mode 100644 index 0000000..deb1b3c --- /dev/null +++ b/tests/test_monsters.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python + +from unittest import TestCase + +from dungeonsheets import monsters, exceptions + + +class MonsterTestCase(TestCase): + def test_ability_scores(self): + wolf = monsters.Wolf() + self.assertEqual(wolf.strength.value, 12) + self.assertEqual(wolf.strength.modifier, 1) + self.assertEqual(wolf.strength.saving_throw, 1)