diff --git a/dungeonsheets/__init__.py b/dungeonsheets/__init__.py index ba5dab3..c781aa9 100644 --- a/dungeonsheets/__init__.py +++ b/dungeonsheets/__init__.py @@ -1,7 +1,7 @@ __all__ = ('__version__', 'Character', 'weapons', 'features', 'character', 'race', 'background', 'spells') -from dungeonsheets import background, features, race, spells, weapons +from dungeonsheets import background, features, race, spells, weapons, mechanics from dungeonsheets.character import Character import os diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 1896b2d..00d3048 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -7,6 +7,7 @@ import os import re import warnings import math +from types import ModuleType import jinja2 @@ -58,6 +59,73 @@ multiclass_spellslots_by_level = { } +def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None): + """Take a raw entry in a character sheet and turn it into a usable object. + + Eg: spells can be defined in many ways. This function accepts all + of those options and returns an actual *Spell* class that can be + used by a character:: + + >>> from dungeonsheets import spells + >>> _resolve_mechanic("mage_hand", spells, None) + >>> class MySpell(spells.Spell): pass + >>> _resolve_mechanic(MySpell, None, spells.Spell) + >>> _resolve_mechanic("hocus pocus", spells, None) + + The acceptable entries for *mechanic*, in priority order, are: + 1. A subclass of *SuperClass* + 2. A string with the name of a defined spell in *module* + 3. The name of an unknown spell (creates generic object using *factory*) + + Parameters + ========== + mechanic : str, type + The thing to be resolved, either a string with the name of the + mechanic, or a subclass of *ParentClass* describing the + mechanic. + module : module + A python module in which to look for the defined string in *name*. + SuperClass : type + Class to determine whether *mechanic* should just be allowed + through as is. + error_message : str, optional + A string whose ``str.format()`` method (receiving one positional + argument *mechanic*) will be used for displaying a warning when an + unknown mechanic is resolved. If omitted, no warning will be + displayed. + + Returns + ======= + Mechanic + A class representing the resolved game mechanic. This will + likely be a subclass of *SuperClass* if the other parameters are + well behaved, but this is not enforced. + + """ + is_already_resolved = isinstance(mechanic, type) and issubclass(mechanic, SuperClass) + if is_already_resolved: + Mechanic = mechanic + else: + try: + # Retrieve pre-defined mechanic + Mechanic = findattr(module, mechanic) + except AttributeError: + # No pre-defined mechanic available + if warning_message is not None: + # Emit the warning + msg = warning_message.format(mechanic) + warnings.warn(msg) + else: + # Create a generic message so we can make a docstring later. + msg = f'Mechanic "{mechanic}" not defined. Please add it.' + # Create generic mechanic from the factory + class_name = "".join([s.title() for s in mechanic.split("_")]) + mechanic_name = mechanic.replace("_", " ").title() + attrs = {"name": mechanic_name, "__doc__": msg, "source": "Unknown"} + Mechanic = type(class_name, (SuperClass,), attrs) + return Mechanic + + class Character(): """A generic player character. @@ -163,7 +231,7 @@ class Character(): # parse all other attributes self.set_attrs(**attrs) self.__set_max_hp(attrs.get('hp_max', None)) - + def clear(self): # reset class-definied items self.class_list = list() @@ -179,13 +247,13 @@ class Character(): self.infusions = list() self.custom_features = list() self.feature_choices = list() - + def __str__(self): return self.name - + def __repr__(self): return f"<{self.class_name}: {self.name}>" - + def add_class(self, cls: (classes.CharClass, type, str), level: (int, str), subclass=None, feature_choices=[]): if isinstance(cls, str): @@ -200,7 +268,7 @@ class Character(): self.class_list.append(cls(level, owner=self, subclass=subclass, feature_choices=feature_choices)) - + def add_classes(self, classes_list=[], levels=[], subclasses=[], feature_choices=[]): if isinstance(classes_list, str): @@ -499,15 +567,17 @@ class Character(): if isinstance(val, str): val = [val] for mitem in val: - try: - self.magic_items.append(findattr(magic_items, mitem)(owner=self)) - except (AttributeError): - msg = (f'Magic Item "{mitem}" not defined. ' - f'Please add it to ``magic_items.py``') - warnings.warn(msg) + msg = (f'Magic Item "{mitem}" not defined. ' + f'Please add it to ``magic_items.py``') + ThisMagicItem = _resolve_mechanic(mechanic=mitem, + module=magic_items, + SuperClass=magic_items.MagicItem, + warning_message=msg) + self.magic_items.append(ThisMagicItem(owner=self)) elif attr == 'weapon_proficiencies': self.other_weapon_proficiencies = () - wps = set([findattr(weapons, w) for w in val]) + msg = 'Magic Item "{}" not defined. Please add it to ``weapons.py``' + wps = set([_resolve_mechanic(w, weapons, weapons.Weapon, msg) for w in val]) wps -= set(self.weapon_proficiencies) self.other_weapon_proficiencies = list(wps) elif attr == 'armor': @@ -522,30 +592,23 @@ class Character(): val = [val] _features = [] for f in val: - try: - _features.append(findattr(features, f)) - except AttributeError: - msg = (f'Feature "{f}" not defined. ' - f'Please add it to ``features.py``') - # create temporary feature - _features.append(features.create_feature( - name=f, source='Unknown', - __doc__="""Unknown Feature. Add to features.py""")) - warnings.warn(msg) + msg = 'Feature "{}" not defined. Please add it to ``features.py``' + ThisFeature = _resolve_mechanic(mechanic=f, + module=features, + SuperClass=features.Feature, + warning_message=msg) + _features.append(ThisFeature) self.custom_features += tuple(F(owner=self) for F in _features) elif (attr == 'spells') or (attr == 'spells_prepared'): # Create a list of actual spell objects _spells = [] for spell_name in val: - try: - _spells.append(findattr(spells, spell_name)) - except AttributeError: - 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)) - # raise AttributeError(msg) + msg = 'Spell "{}" not defined. Please add it to ``spells.py``' + ThisSpell = _resolve_mechanic(mechanic=spell_name, + module=spells, + SuperClass=spells.Spell, + warning_message=msg) + _spells.append(ThisSpell) # Sort by name _spells.sort(key=lambda spell: spell.name) # Save list of spells to character atribute @@ -559,25 +622,27 @@ class Character(): if hasattr(self, 'Artificer'): _infusions = [] for infusion_name in val: - try: - _infusions.append(findattr(infusions, infusion_name)) - except AttributeError: - msg = (f'Infusion "{infusion_name}" not defined. ' - f'Please add it to ``infusions.py``') - warnings.warn(msg) + msg = 'Infusion "{}" not defined. Please add it to ``infusions.py``' + ThisInfusion = _resolve_mechanic(mechanic=infusion_name, + module=infusions, + SuperClass=infusions.Infusion, + warning_message=msg) + _infusions.append(ThisInfusion) _infusions.sort(key=lambda infusion: infusion.name) self.infusions = tuple(i() for i in _infusions) - else: - if not hasattr(self, attr): + elif type(val) not in (type, ModuleType): + # Some other generic attribute + is_unknown = not hasattr(self, attr) and not attr.startswith("_") + if is_unknown: warnings.warn(f"Setting unknown character attribute {attr}", RuntimeWarning) # Lookup general attributes setattr(self, attr, val) - + def spell_save_dc(self, class_type): ability_mod = getattr(self, class_type.spellcasting_ability).modifier return (8 + self.proficiency_bonus + ability_mod) - + def spell_attack_bonus(self, class_type): ability_mod = getattr(self, class_type.spellcasting_ability).modifier return (self.proficiency_bonus + ability_mod) @@ -659,7 +724,11 @@ class Character(): if isinstance(new_armor, armor.Armor): new_armor = new_armor else: - NewArmor = findattr(armor, new_armor) + msg = 'Unnown armor "{}". Please add it to ``armor.py``.' + NewArmor = _resolve_mechanic(mechanic=new_armor, + module=armor, + SuperClass=armor.Armor, + warning_message=msg) new_armor = NewArmor() self.armor = new_armor @@ -683,31 +752,24 @@ class Character(): def wield_weapon(self, weapon): """Accepts a string and adds it to the list of wielded weapons. - + Parameters ---------- weapon : str Case-insensitive string with a name of the weapon. - + """ # Retrieve the weapon class from the weapons module if isinstance(weapon, weapons.Weapon): - weapon_ = type(weapon)(wielder=self) - elif isinstance(weapon, str): - try: - NewWeapon = findattr(weapons, weapon) - except AttributeError: - warnings.warn(f"Unknown weapon '{weapon}'. Please add it to ``weapons.py`` " - "or submit an issue: https://github.com/canismarko/dungeon-sheets/issues", - RuntimeWarning) - return - weapon_ = NewWeapon(wielder=self) - elif issubclass(weapon, weapons.Weapon): - weapon_ = weapon(wielder=self) + ThisWeapon = type(weapon) else: - raise AttributeError(f'Weapon "{weapon}" is not defined') + msg = 'Unknown weapon "{}". Please add it to ``weapons.py``.' + ThisWeapon = _resolve_mechanic(mechanic=weapon, + module=weapons, + SuperClass=weapons.Weapon, + warning_message=msg) # Save it to the array - self.weapons.append(weapon_) + self.weapons.append(ThisWeapon(wielder=self)) @property def hit_dice(self): diff --git a/dungeonsheets/features/features.py b/dungeonsheets/features/features.py index f68d4bf..897eb87 100644 --- a/dungeonsheets/features/features.py +++ b/dungeonsheets/features/features.py @@ -27,7 +27,7 @@ class Feature(): """ name = "Generic Feature" owner = None - source = '' # race, class, background, etc. + source = 'Unknown' # race, class, background, etc. spells_known = () spells_prepared = () needs_implementation = False # Set to True if need to find way to compute stats diff --git a/dungeonsheets/forms/spellbook_template.tex b/dungeonsheets/forms/spellbook_template.tex index 8b83c88..98d7c93 100644 --- a/dungeonsheets/forms/spellbook_template.tex +++ b/dungeonsheets/forms/spellbook_template.tex @@ -43,7 +43,7 @@ \item [Casting Time:] [[ spl.casting_time ]] \\ \item [Duration:] [[ spl.duration ]] \\ \item [Range:] [[ spl.casting_range ]] \\ - \item [Components:] [[ spl.component_string ]] \\ + \item [Components:] [[ spl.component_string ]] \end{description} \vspace{\zerosep} diff --git a/dungeonsheets/mechanics.py b/dungeonsheets/mechanics.py new file mode 100644 index 0000000..686457f --- /dev/null +++ b/dungeonsheets/mechanics.py @@ -0,0 +1,9 @@ +"""Convenience module holding base classes for the various kinds of +game mechanics.""" + +from dungeonsheets.spells import Spell +from dungeonsheets.features import Feature +from dungeonsheets.infusions import Infusion +from dungeonsheets.weapons import Weapon +from dungeonsheets.armor import Armor, Shield +from dungeonsheets.magic_items import MagicItem diff --git a/dungeonsheets/spells/spells_m.py b/dungeonsheets/spells/spells_m.py index 72c5714..8a145df 100644 --- a/dungeonsheets/spells/spells_m.py +++ b/dungeonsheets/spells/spells_m.py @@ -184,14 +184,16 @@ class MagicJar(Spell): class MagicMissile(Spell): - """You create three glowing darts of magical force. Each dart hits a creature of - your choice that you can see within range. A dart deals 1d4 + 1 force damage to - its target. The darts all strike simultaneously and you can direct them to hit - one creature or several. - - At Higher Levels: When you cast this spell using a - spell slot of 2nd level or higher, the spell creates one more dart for each slot - level above 1st. + """You create three glowing darts of magical force. Each dart hits a + creature of your choice that you can see within range. A dart + deals 1d4 + 1 force damage to its target. The darts all strike + simultaneously and you can direct them to hit one creature or + several. + + At Higher Levels: When you cast this spell using a spell slot of + 2nd level or higher, the spell creates one more dart for each slot + level above 1st. + """ name = "Magic Missile" level = 1 diff --git a/examples/homebrewelda.py b/examples/homebrewelda.py new file mode 100644 index 0000000..80cb6d0 --- /dev/null +++ b/examples/homebrewelda.py @@ -0,0 +1,166 @@ +"""This file describes the heroic adventurer Homebrewelda. + +This example demonstrates how to add homebrew spells into the game. + +Modify this file as you level up and then re-generate the character +sheet by running ``makesheets`` from the command line. + +""" +from dungeonsheets import mechanics + +dungeonsheets_version = "0.9.4" + +name = "Homebrewelda" +player_name = "Clara" + +# Be sure to list Primary class first +classes = ['Wizard'] # ex: ['Wizard'] or ['Rogue', 'Fighter'] +levels = [20] # ex: [10] or [3, 2] +subclasses = ["School of Transmutation"] # ex: ['Necromacy'] or ['Thief', None] +background = "Hermit" +race = "Air Genasi" +alignment = "Chaotic neutral" + +xp = 0 +hp_max = 105 +inspiration = 0 # integer inspiration value + +# Ability Scores +strength = 8 +dexterity = 11 +constitution = 14 +intelligence = 15 +wisdom = 13 +charisma = 14 + +# Select what skills you're proficient with +# ex: skill_proficiencies = ('athletics', 'acrobatics', 'arcana') +skill_proficiencies = ('arcana', 'history', 'medicine', 'religion') + +# Any skills you have "expertise" (Bard/Rogue) in +skill_expertise = () + +# Named features / feats that aren't part of your classes, race, or background. +# Also include Eldritch Invocations and features you make multiple selection of +# (like Maneuvers for Fighter, Metamagic for Sorcerors, Trick Shots for +# Gunslinger, etc.) +# Example: +# features = ('Tavern Brawler',) # take the optional Feat from PHB +class Juggler(mechanics.Feature): + """You can juggle like a pro!""" + name = "Juggler" +features = (Juggler, "master_of_ceremonies") + +# If selecting among multiple feature options: ex Fighting Style +# Example (Fighting Style): +# feature_choices = ('Archery',) +feature_choices = () + + +class DullSword(mechanics.Weapon): + """Bonk things with it.""" + name = "Dullsword" + +# Weapons/other proficiencies not given by class/race/background +weapon_proficiencies = (DullSword,) # ex: ('shortsword', 'quarterstaff') +_proficiencies_text = () # ex: ("thieves' tools",) + +# Proficiencies and languages +languages = """[choose one], Common, Primoridal""" + +# Inventory +# TODO: Get yourself some money +cp = 0 +sp = 0 +ep = 0 +gp = 0 +pp = 0 + +# Put your equipped weapons and armor here + +class RobeOfBreadSummoning(mechanics.MagicItem): + """Shamefully stolen from the "D&D minus" podcast.""" + name = "Robe of Bread Summoning" + + +class PlasticArmor(mechanics.Armor): + name = "Plastic armor" + base_armor_class = 23 + + +class LegoShield(mechanics.Shield): + name = "Lego shield" + base_armor_class = 114 + + +weapons = (DullSword, "rusty_shiv") # Example: ('shortsword', 'longsword') +magic_items = (RobeOfBreadSummoning, "staff_of_the_arbor_abode") +armor = PlasticArmor # Eg "leather armor" +shield = LegoShield # Eg "shield" + +equipment = """TODO: list the equipment and magic items your character carries""" + +attacks_and_spellcasting = """TODO: Describe how your character usually attacks +or uses spells.""" + +class MagicFlask(mechanics.Spell): + """A spectral, floating hand appears at a point you choose within + range holding a flask of finely distilled spirits. + + The flask lasts for the duration or until you dismiss it as an + action. The flask vanishes if it is ever more than 30 feet away + from you or if you cast this spell again. + + You can use your action to take a sip of the flask or provide a + sip to a willing target. You can move the hand up to 30 feet each + time you use it. + + """ + name = "Magic Flask" + level = 0 + casting_time = "1 action" + casting_range = "30 feet" + components = ('V', 'S') + materials = """""" + duration = "1 minute" + ritual = False + magic_school = "Conjuration" + classes = ('Bard', 'Warlock', 'Wizard') + + +# List of known spells +# Example: spells_prepared = ('magic missile', 'mage armor') +spells_prepared = ('acid splash', 'animate_objects', 'ray of frost', 'light', 'friends', + 'disguise self', 'identify', 'jump', + 'blur', 'knock', 'shatter', + 'blink', 'fly', 'slow', + 'blight', 'ice storm', + 'cone of cold', 'magic jar', + 'teleport', 'maze', 'wish', + # Home brew stuff: + MagicFlask, 'summon_corgis') + +# Which spells have not been prepared +__spells_unprepared = () + +# all spells known +spells = spells_prepared + __spells_unprepared + +# Wild shapes for Druid +wild_shapes = () # Ex: ('ape', 'wolf', 'ankylosaurus') + +# Backstory +# Describe your backstory here +personality_traits = """TODO: How does your character behave? See the PHB for +examples of all the sections below""" + +ideals = """TODO: What does your character believe in?""" + +bonds = """TODO: Describe what debts your character has to pay, +and other commitments or ongoing quests they have.""" + +flaws = """TODO: Describe your characters interesting flaws. +""" + +features_and_traits = """TODO: Describe other features and abilities your +character has.""" diff --git a/tests/test_character.py b/tests/test_character.py index 8c08393..410a5a1 100755 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -4,8 +4,8 @@ from unittest import TestCase from pathlib import Path import warnings -from dungeonsheets import race, monsters, exceptions, spells -from dungeonsheets.character import Character, Wizard, Druid, read_character_file +from dungeonsheets import race, monsters, exceptions, spells, infusions +from dungeonsheets.character import Character, Wizard, Druid, read_character_file, _resolve_mechanic from dungeonsheets.weapons import Weapon, Shortsword from dungeonsheets.armor import Armor, LeatherArmor, Shield @@ -48,6 +48,42 @@ class TestCharacter(TestCase): char.set_attrs(inspiration=False) self.assertFalse(char.inspiration) + def test_homebrew_spells(self): + char = Character() + class MySpell(spells.Spell): + name="my spell!" + char.set_attrs(spells=(MySpell,)) + self.assertIsInstance(char.spells[0], spells.Spell) + self.assertEqual(char.spells[0].name, "my spell!") + + def test_homebrew_infusions(self): + char = Character(classes="artificer") + class MyInfusion(infusions.Infusion): + name="my infusion!" + # Pass an already created infusion class + char.set_attrs(infusions=(MyInfusion,)) + self.assertIsInstance(char.infusions[0], infusions.Infusion) + self.assertEqual(char.infusions[0].name, "my infusion!") + # Pass a previously undefined infusion + char = Character(classes="artificer") + char.set_attrs(infusions=("spam_infusion",)) + self.assertIsInstance(char.infusions[0], infusions.Infusion) + self.assertEqual(char.infusions[0].name, "Spam Infusion") + + def test_resolve_mechanic(self): + # Test a well defined mechanic + NewSpell = _resolve_mechanic("mage_hand", spells, None) + self.assertTrue(issubclass(NewSpell, spells.Spell)) + # Test an unknown mechanic + def new_spell(**params): + return spells.Spell + NewSpell = _resolve_mechanic("hocus_pocus", spells, spells.Spell) + self.assertTrue(issubclass(NewSpell, spells.Spell)) + # Test direct resolution of a proper subclass + class MySpell(spells.Spell): + pass + NewSpell = _resolve_mechanic(MySpell, spells, spells.Spell) + def test_wield_weapon(self): char = Character() char.strength = 14