mirror of
https://github.com/Threnklyn/dungeon-sheets.git
synced 2026-06-07 13:15:53 +02:00
Added a way to include homebrew in a character file.
This commit is contained in:
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user