diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 3b14de3..d752bc8 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -69,83 +69,6 @@ multiclass_spellslots_by_level = { } -def _resolve_mechanic(mechanic, 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:: - - >>> _resolve_mechanic("mage_hand", SuperClass=spells.Spell) - - >>> _resolve_mechanic("mage_hand", SuperClass=None) - - >>> from dungeonsheets import spells - >>> class MySpell(spells.Spell): pass - >>> _resolve_mechanic(MySpell, SuperClass=spells.Spell) - - >>> _resolve_mechanic("hocus pocus", SuperClass=spells.Spell) - - The acceptable entries for *mechanic*, in priority order, are: - 1. A subclass of *SuperClass* - 2. A string with the name of defined content - 3. The name of an unknown spell (creates generic object using *factory*) - - *SuperClass* can be ``None`` to match any class, however this will - raise an exception if more than one content type has this - name. For example, "shield" can refer to both the armor or the - spell, so ``_resolve_mechanic("shield")`` will raise an exception. - - 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. - 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 - valid_classes = [SuperClass] if SuperClass is not None else [] - Mechanic = find_content(mechanic, valid_classes=valid_classes) - 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(Entity): """A generic player character.""" @@ -628,7 +551,7 @@ class Character(Entity): f'Magic Item "{mitem}" not defined. ' "Please add it to ``magic_items.py``" ) - ThisMagicItem = _resolve_mechanic( + ThisMagicItem = self._resolve_mechanic( mechanic=mitem, SuperClass=magic_items.MagicItem, warning_message=msg, @@ -639,7 +562,7 @@ class Character(Entity): msg = 'Magic Item "{}" not defined. Please add it to ``weapons.py``' wps = set( [ - _resolve_mechanic( + self._resolve_mechanic( w, SuperClass=weapons.Weapon, warning_message=msg ) for w in val @@ -660,7 +583,7 @@ class Character(Entity): _features = [] for f in val: msg = 'Feature "{}" not defined. Please add it to ``features.py``' - ThisFeature = _resolve_mechanic( + ThisFeature = self._resolve_mechanic( mechanic=f, SuperClass=features.Feature, warning_message=msg, @@ -672,7 +595,7 @@ class Character(Entity): _spells = [] for spell_name in val: msg = 'Spell "{}" not defined. Please add it to ``spells.py``' - ThisSpell = _resolve_mechanic( + ThisSpell = self._resolve_mechanic( mechanic=spell_name, SuperClass=spells.Spell, warning_message=msg, @@ -695,7 +618,7 @@ class Character(Entity): "Infusion '{}' not defined. Please add it to" " ``infusions.py``" ) - ThisInfusion = _resolve_mechanic( + ThisInfusion = self._resolve_mechanic( mechanic=infusion_name, SuperClass=infusions.Infusion, warning_message=msg, @@ -767,6 +690,14 @@ class Character(Entity): final_text += "." return final_text + @proficiencies_text.setter + def proficiencies_text(self, val): + try: + profs = profiencies_text.split(",") + except AttributeError: + profs = proficiencies_text + self._proficiencies_text = profs + @property def features_text(self): s = "\n\n--".join( @@ -804,7 +735,7 @@ class Character(Entity): new_armor = new_armor else: msg = 'Unnown armor "{}". Please add it to ``armor.py``.' - NewArmor = _resolve_mechanic( + NewArmor = self._resolve_mechanic( mechanic=new_armor, SuperClass=armor.Armor, warning_message=msg, @@ -823,11 +754,8 @@ class Character(Entity): """ if shield not in ("", "None", None): - try: - NewShield = find_content(shield, valid_classes=[armor.Shield]) - except AttributeError: - # Not a string, so just treat it as Armor - NewShield = shield + msg = 'Unknown shield "{}". Please ad it to ``shields.py``.' + NewShield = self._resolve_mechanic(shield, SuperClass=armor.Shield, warning_message=msg) self.shield = NewShield() def wield_weapon(self, weapon): @@ -840,15 +768,12 @@ class Character(Entity): """ # Retrieve the weapon class from the weapons module - if isinstance(weapon, weapons.Weapon): - ThisWeapon = type(weapon) - else: - msg = 'Unknown weapon "{}". Please add it to ``weapons.py``.' - ThisWeapon = _resolve_mechanic( - mechanic=weapon, - SuperClass=weapons.Weapon, - warning_message=msg, - ) + msg = 'Unknown weapon "{}". Please add it to ``weapons.py``.' + ThisWeapon = self._resolve_mechanic( + mechanic=weapon, + SuperClass=weapons.Weapon, + warning_message=msg, + ) # Save it to the array self._weapons.append(ThisWeapon(wielder=self)) diff --git a/dungeonsheets/classes/druid.py b/dungeonsheets/classes/druid.py index 0a6fb93..3482bec 100644 --- a/dungeonsheets/classes/druid.py +++ b/dungeonsheets/classes/druid.py @@ -274,7 +274,7 @@ class Druid(CharClass): try: NewMonster = find_content(shape, valid_classes=[monsters.Monster]) new_shape = NewMonster() - except AttributeError: + except exceptions.ContentNotFound: msg = ( f"Wild shape '{shape}' not found. Please add it to" " ``monsters.py``" diff --git a/dungeonsheets/content_registry.py b/dungeonsheets/content_registry.py index 0751029..0c55969 100644 --- a/dungeonsheets/content_registry.py +++ b/dungeonsheets/content_registry.py @@ -13,6 +13,7 @@ from dungeonsheets import ( infusions, magic_items, features, + exceptions, ) @@ -38,7 +39,11 @@ class ContentRegistry: """ # Come up with several options - name = name.strip() + try: + name = name.strip() + except AttributeError as e: + # Probably not a string + raise ValueError('content "%s" is not a valid identifier string.' % name) # check for +X weapons, armor, shields bonus = 0 for i in range(1, 11): @@ -72,9 +77,9 @@ class ContentRegistry: found_attrs = [attr for attr, v in zip(found_attrs, is_valid) if v] # Check that we found a valid, unique attribute if len(found_attrs) == 0: - raise AttributeError(f"Modules {self.modules} have no attribute {name}") + raise exceptions.ContentNotFound(f"Modules {self.modules} have no attribute {name}") elif len(found_attrs) > 1: - raise RuntimeError(f"Found multiple content entries for {name}") + raise exceptions.AmbiguousContent(f"Found multiple content entries for {name}") else: attr = found_attrs[0] # Apply weapon/etc. bonuses @@ -100,7 +105,7 @@ default_content_registry.add_module(magic_items) default_content_registry.add_module(features) -def find_content(name: str, valid_classes: Optional[List]): +def find_content(name: str, valid_classes: Optional[List] = None): """Find content from a previously registered module. Parameters diff --git a/dungeonsheets/entity.py b/dungeonsheets/entity.py index a33b6da..763c8e0 100644 --- a/dungeonsheets/entity.py +++ b/dungeonsheets/entity.py @@ -1,6 +1,9 @@ from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill from abc import ABC import os +import warnings + +from dungeonsheets.content_registry import find_content def read(fname): @@ -109,4 +112,83 @@ class Entity(ABC): self.medicine, self.nature, self.perception, self.performance, self.persuasion, self.religion, self.sleight_of_hand, self.stealth, self.survival,] - + + @staticmethod + def _resolve_mechanic(mechanic, 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:: + + >>> _resolve_mechanic("mage_hand", SuperClass=spells.Spell) + + >>> _resolve_mechanic("mage_hand", SuperClass=None) + + >>> from dungeonsheets import spells + >>> class MySpell(spells.Spell): pass + >>> _resolve_mechanic(MySpell, SuperClass=spells.Spell) + + >>> _resolve_mechanic("hocus pocus", SuperClass=spells.Spell) + + The acceptable entries for *mechanic*, in priority order, are: + 1. A subclass of *SuperClass* + 2. A string with the name of defined content + 3. The name of an unknown spell (creates generic object using *factory*) + + *SuperClass* can be ``None`` to match any class, however this will + raise an exception if more than one content type has this + name. For example, "shield" can refer to both the armor or the + spell, so ``_resolve_mechanic("shield")`` will raise an exception. + + 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. + 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 + elif SuperClass is not None and isinstance(mechanic, SuperClass): + # Has been instantiated for some reason + Mechanic = type(Mechanic) + else: + try: + # Retrieve pre-defined mechanic + valid_classes = [SuperClass] if SuperClass is not None else [] + Mechanic = find_content(mechanic, valid_classes=valid_classes) + except ValueError: + # 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 diff --git a/dungeonsheets/exceptions.py b/dungeonsheets/exceptions.py index a8263ac..4273f93 100644 --- a/dungeonsheets/exceptions.py +++ b/dungeonsheets/exceptions.py @@ -28,3 +28,10 @@ class UnknownFileType(RuntimeError): class UnknownOutputFormat(RuntimeError): """The output format requested is not one of the known outputs.""" + + +class ContentNotFound(ValueError): + """The requested content could not be resolved.""" + +class AmbiguousContent(ValueError): + """Multiple valid content entries were found.""" diff --git a/dungeonsheets/forms/spellbook_template.html b/dungeonsheets/forms/spellbook_template.html index 46e2a46..f2daf31 100644 --- a/dungeonsheets/forms/spellbook_template.html +++ b/dungeonsheets/forms/spellbook_template.html @@ -96,8 +96,8 @@
[[ spl.component_string ]]
- +
[[ spl.__doc__ | rst_to_html(top_heading_level=1) ]] - +
[% endfor %] diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index db9e8d9..0d99941 100755 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -232,7 +232,7 @@ def make_gm_sheet( else: try: MyMonster = find_content(monster, valid_classes=[monsters.Monster]) - except AttributeError: + except exceptions.ContentNotFound: msg = f"Monster '{monster}' not found. Please add it to ``monsters.py``" warnings.warn(msg) continue diff --git a/dungeonsheets/monsters/monsters.py b/dungeonsheets/monsters/monsters.py index 0765e01..5de3bdc 100644 --- a/dungeonsheets/monsters/monsters.py +++ b/dungeonsheets/monsters/monsters.py @@ -3,11 +3,32 @@ shape forms. """ +from abc import ABCMeta + from dungeonsheets.entity import Entity +from dungeonsheets.spells import Spell +from dungeonsheets.content_registry import find_content -class Monster(Entity): +class SpellFactory(ABCMeta): + """Meta class to resolve spell strings into the ``spells.Spell``. + + For classes using this metaclass, the *spell* attribute, if + present, should be a list of spells that the entity knows. For + each entry on that list, anything that is not already a spell + class (so probably a string) will be resolved into the + corresponding ``spells.Spell`` class. + + """ + def __init__(self, name, bases, attrs): + for idx, spell in enumerate(self.spells): + TheSpell = self._resolve_mechanic(spell, SuperClass=Spell) + self.spells[idx] = TheSpell + + +class Monster(Entity, metaclass=SpellFactory): + """A monster that may be encountered when adventuring.""" name = "Generic Monster" @@ -28,6 +49,7 @@ class Monster(Entity): burrow_speed = 0 hp_max = 10 hit_dice = "1d6" + spells = [] def __init__(self): super(Monster, self).__init__() diff --git a/dungeonsheets/monsters/monsters_p.py b/dungeonsheets/monsters/monsters_p.py index ae7daca..dbbb429 100644 --- a/dungeonsheets/monsters/monsters_p.py +++ b/dungeonsheets/monsters/monsters_p.py @@ -362,25 +362,28 @@ class Pony(Monster): class Priest(Monster): + """Divine Eminence + As a bonus action, the priest can expend a spell slot to cause + its melee weapon attacks to magically deal an extra 10 (3d6) + radiant damage to a target on a hit. This benefit lasts until + the end of the turn. If the priest expends a spell slot of 2nd + level or higher, the extra damage increases by 1d6 for each + level above 1st. + Spellcasting + The priest is a 5th-level spellcaster. Its spellcasting ability + is Wisdom (spell save DC 13, +5 to hit with spell attacks). The + priest has the following cleric spells prepared: + + - Cantrips (at will): light, sacred flame, thaumaturgy + - 1st level (4 slots): cure wounds, guiding bolt, sanctuary + - 2nd level (3 slots): lesser restoration, spiritual weapon + - 3rd level (2 slots): dispel magic, spirit guardians + + Mace + Melee Weapon Attack: +2 to hit, reach 5 ft., one target. Hit: 3 + (1d6) bludgeoning damage. + """ - Divine Eminence. - - As a bonus action, the priest can expend a spell slot to cause its melee weapon attacks to magically deal an extra 10 (3d6) radiant damage to a target on a hit. This benefit lasts until the end of the turn. If the priest expends a spell slot of 2nd level or higher, the extra damage increases by 1d6 for each level above 1st. - - Spellcasting. - - The priest is a 5th-level spellcaster. Its spellcasting ability is Wisdom (spell save DC 13, +5 to hit with spell attacks). The priest has the following cleric spells prepared: - - - Cantrips (at will): light, sacred flame, thaumaturgy - - 1st level (4 slots): cure wounds, guiding bolt, sanctuary - - 2nd level (3 slots): lesser restoration, spiritual weapon - - 3rd level (2 slots): dispel magic, spirit guardians - - Mace. - - Melee Weapon Attack: +2 to hit, reach 5 ft., one target. Hit: 3 (1d6) bludgeoning damage. - """ - name = "Priest" description = "Medium humanoid, any alignment" challenge_rating = 2 diff --git a/dungeonsheets/readers.py b/dungeonsheets/readers.py index 4e1b228..0d402e9 100644 --- a/dungeonsheets/readers.py +++ b/dungeonsheets/readers.py @@ -74,7 +74,6 @@ class BaseCharacterReader: The path to the file that will be imported. """ - print(filename) self.filename = filename def __call__(self, filename): @@ -285,7 +284,7 @@ class Roll20CharacterReader(JSONCharacterReader): match = prof_re.match(obj["name"]) if match: tool_profs.append(self.get_attrib(match.group(0))) - char_props["_proficiencies_text"] = tool_profs + char_props["proficiencies_text"] = tool_profs # Combat stats char_props["hp_max"] = self.as_int(self.get_attrib("hp", which="max")) # Equipment @@ -531,7 +530,7 @@ class FoundryCharacterReader(JSONCharacterReader): tool_profs = [tool_labels[prof] for prof in tool_profs] custom_tool_profs = json_data["data"]["traits"]["toolProf"]["custom"] tool_profs.extend([s.strip() for s in custom_tool_profs.split(";")]) - char_props["_proficiencies_text"] = tool_profs + char_props["proficiencies_text"] = tool_profs # Combat stats char_props["hp_max"] = self.as_int(json_data["data"]["attributes"]["hp"]["max"]) # Equipment diff --git a/tests/test_character.py b/tests/test_character.py index 3a7ef8b..9cd4fd4 100755 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -8,7 +8,6 @@ from dungeonsheets.character import ( Character, Wizard, Druid, - _resolve_mechanic, ) from dungeonsheets.weapons import Weapon, Shortsword from dungeonsheets.armor import Armor, LeatherArmor, Shield @@ -81,21 +80,21 @@ class TestCharacter(TestCase): def test_resolve_mechanic(self): # Test a well defined mechanic - NewSpell = _resolve_mechanic("mage_hand", None) + NewSpell = Character._resolve_mechanic("mage_hand", None) self.assertTrue(issubclass(NewSpell, spells.Spell)) # Test an unknown mechanic def new_spell(**params): return spells.Spell - NewSpell = _resolve_mechanic("hocus_pocus", spells.Spell) + NewSpell = Character._resolve_mechanic("hocus_pocus", 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.Spell) + NewSpell = Character._resolve_mechanic(MySpell, spells.Spell) def test_wield_weapon(self): char = Character() @@ -110,13 +109,13 @@ class TestCharacter(TestCase): self.assertEqual(sword.attack_modifier, 4) # str + prof self.assertEqual(sword.damage, "1d6+2") # str # Check if dexterity is used if it's higher (Finesse weapon) - char.weapons = [] + char._weapons = [] char.dexterity = 16 char.wield_weapon("shortsword") sword = char.weapons[0] self.assertEqual(sword.attack_modifier, 5) # dex + prof # Check if race weapon proficiencies are considered - char.weapons = [] + char._weapons = [] char.weapon_proficiencies = [] char.race = race.HighElf() char.wield_weapon("shortsword") diff --git a/tests/test_make_sheets.py b/tests/test_make_sheets.py index 73ff1ca..4855df4 100644 --- a/tests/test_make_sheets.py +++ b/tests/test_make_sheets.py @@ -133,6 +133,7 @@ class VashtaNerada(monsters.Monster): saving_throws = "Dex +8" damage_immunities = "Bludgeoning" damage_resistances = "Lightning" + damage_vulnerabilities = "Wood-based" challenge_rating = 93 diff --git a/tests/test_monsters.py b/tests/test_monsters.py index e3fe4fd..13e2b47 100644 --- a/tests/test_monsters.py +++ b/tests/test_monsters.py @@ -1,6 +1,6 @@ from unittest import TestCase -from dungeonsheets import monsters +from dungeonsheets import monsters, spells class AutoGeneratedMonsters(TestCase): @@ -351,3 +351,14 @@ class AutoGeneratedMonsters(TestCase): self.assertEqual(wolf.strength.value, 12) self.assertEqual(wolf.strength.modifier, 1) self.assertEqual(wolf.strength.saving_throw, 1) + +class MonsterSpellcastingTests(TestCase): + def test_spells(self): + # Check that monster spells can be set, and then resolved to real spell objects + class MyMonster(monsters.Monster): + spells = ["cure wounds", spells.Bane] + + self.assertIsInstance(MyMonster.spells[0], type, + msg="Monster spell is not a class") + self.assertTrue(issubclass(MyMonster.spells[0], spells.Spell)) + diff --git a/tests/test_readers.py b/tests/test_readers.py index aa91c33..c0d2801 100644 --- a/tests/test_readers.py +++ b/tests/test_readers.py @@ -93,7 +93,7 @@ class Roll20ReaderTests(unittest.TestCase): "warhammer", "unarmed strike", ], - _proficiencies_text=[ + proficiencies_text=[ "Brewer's Supplies", ], languages="common, dwarvish", @@ -204,7 +204,7 @@ class FoundryReaderTests(unittest.TestCase): "crossbow", "knives", ], - _proficiencies_text=[ + proficiencies_text=[ "artisan's tools", "disguise kit", "forger's kit",