Added a way to include homebrew in a character file.

This commit is contained in:
Mark Wolfman
2021-02-09 22:26:23 -06:00
parent f5a1d84c60
commit 75c77aa750
8 changed files with 346 additions and 71 deletions
+1 -1
View File
@@ -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
+109 -47
View File
@@ -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.
@@ -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)
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,16 +622,18 @@ 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
@@ -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
@@ -692,22 +761,15 @@ class Character():
"""
# 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):
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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}
+9
View File
@@ -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
+8 -6
View File
@@ -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.
"""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
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
+166
View File
@@ -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."""
+38 -2
View File
@@ -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