Monsters now have a spells attribute that will eventually hold a list of spells.

This commit is contained in:
Mark Wolfman
2021-07-13 00:13:04 -05:00
parent d110ef6cda
commit f4f101e39c
14 changed files with 191 additions and 137 deletions
+22 -97
View File
@@ -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))
+1 -1
View File
@@ -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``"
+9 -4
View File
@@ -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
+83 -1
View File
@@ -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
+7
View File
@@ -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."""
+2 -2
View File
@@ -96,8 +96,8 @@
<dd>[[ spl.component_string ]]</dd>
</dl>
<block class="spell-description">
<div class="spell-description">
[[ spl.__doc__ | rst_to_html(top_heading_level=1) ]]
</block>
</div>
[% endfor %]
+1 -1
View File
@@ -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
+23 -1
View File
@@ -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__()
+21 -18
View File
@@ -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
+2 -3
View File
@@ -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