Made a Content base class, and changed GM extra content interface.

This commit is contained in:
Mark Wolfman
2021-08-07 21:18:34 -05:00
parent a18b6df3bb
commit 92b301a8e0
12 changed files with 346 additions and 318 deletions
+1 -8
View File
@@ -13,12 +13,5 @@ __all__ = (
from dungeonsheets import background, features, race, spells, weapons, mechanics
from dungeonsheets.character import Character
from dungeonsheets.content_registry import import_homebrew
from dungeonsheets.content import __version__
import os
def read(fname):
return open(os.path.join(os.path.dirname(__file__), fname)).read()
__version__ = read("../VERSION")
+2 -2
View File
@@ -23,7 +23,7 @@ from dungeonsheets import (
)
from dungeonsheets.content_registry import find_content
from dungeonsheets.weapons import Weapon
from dungeonsheets.entity import Entity
from dungeonsheets.content import Creature
dice_re = re.compile(r"(\d+)d(\d+)")
@@ -70,7 +70,7 @@ multiclass_spellslots_by_level = {
}
class Character(Entity):
class Character(Creature):
"""A generic player character."""
# Character-specific
+201
View File
@@ -0,0 +1,201 @@
"""Base classes for the various D&D 5e content types."""
import warnings
from abc import ABC
from pathlib import Path
from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill
from dungeonsheets.content_registry import find_content
def read(fname):
return open((Path(__file__).parent / fname).resolve()).read()
__version__ = read("../VERSION").strip()
class Content(ABC):
"""A base class for all D&D 5e content types.
Every piece of content (e.g. class feature, spell, monster) should
have this base class in its inheritance tree.
"""
dungeonsheets_version = __version__
name = "Generic content"
class Creature(Content):
"""A thing with stats. Use Monster or Character, not this class
directly!
"""
# General attributes
alignment = "Neutral"
_race = None
name = "Generic creature"
# Hit points
hp_max = None
# Base stats (ability scores)
strength = Ability()
dexterity = Ability()
constitution = Ability()
intelligence = Ability()
wisdom = Ability()
charisma = Ability()
# Numerical things
armor_class = ArmorClass()
initiative = Initiative()
speed = Speed()
# Proficiencies and Languages
_saving_throw_proficiencies = tuple() # use to overwrite class proficiencies
other_weapon_proficiencies = tuple() # add to class/race proficiencies
skill_proficiencies = list()
skill_expertise = list()
languages = ""
senses = ""
# Skills
acrobatics = Skill(ability="dexterity")
animal_handling = Skill(ability="wisdom")
arcana = Skill(ability="intelligence")
athletics = Skill(ability="strength")
deception = Skill(ability="charisma")
history = Skill(ability="intelligence")
insight = Skill(ability="wisdom")
intimidation = Skill(ability="charisma")
investigation = Skill(ability="intelligence")
medicine = Skill(ability="wisdom")
nature = Skill(ability="intelligence")
perception = Skill(ability="wisdom")
performance = Skill(ability="charisma")
persuasion = Skill(ability="charisma")
religion = Skill(ability="intelligence")
sleight_of_hand = Skill(ability="dexterity")
stealth = Skill(ability="dexterity")
survival = Skill(ability="wisdom")
# Inventory
cp = 0
sp = 0
ep = 0
gp = 0
pp = 0
equipment = ""
_weapons = list()
magic_items = list()
armor = None
shield = None
# Magic
spellcasting_ability = None
_spells = list()
_spells_prepared = list()
infusions = list()
# Features IN MAJOR DEVELOPMENT
custom_features = list()
feature_choices = list()
def __init__(self):
pass
@property
def weapons(self):
return self._weapons.copy()
@property
def passive_wisdom(self):
return self.perception.modifier + 10
@property
def abilities(self):
return [self.strength, self.dexterity, self.constitution,
self.intelligence, self.wisdom, self.charisma]
@property
def skills(self):
return [self.acrobatics, self.animal_handling, self.arcana,
self.athletics, self.deception, self.history,
self.insight, self.intimidation, self.investigation,
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
-194
View File
@@ -1,194 +0,0 @@
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):
return open(os.path.join(os.path.dirname(__file__), fname)).read()
__version__ = read("../VERSION").strip()
class Entity(ABC):
"""A thing with stats. Use Monster or Character, not this class directly!"""
# General attributes
dungeonsheets_version = __version__
name = ""
alignment = "Neutral"
_race = None
# Hit points
hp_max = None
# Base stats (ability scores)
strength = Ability()
dexterity = Ability()
constitution = Ability()
intelligence = Ability()
wisdom = Ability()
charisma = Ability()
# Numerical things
armor_class = ArmorClass()
initiative = Initiative()
speed = Speed()
# Proficiencies and Languages
_saving_throw_proficiencies = tuple() # use to overwrite class proficiencies
other_weapon_proficiencies = tuple() # add to class/race proficiencies
skill_proficiencies = list()
skill_expertise = list()
languages = ""
senses = ""
# Skills
acrobatics = Skill(ability="dexterity")
animal_handling = Skill(ability="wisdom")
arcana = Skill(ability="intelligence")
athletics = Skill(ability="strength")
deception = Skill(ability="charisma")
history = Skill(ability="intelligence")
insight = Skill(ability="wisdom")
intimidation = Skill(ability="charisma")
investigation = Skill(ability="intelligence")
medicine = Skill(ability="wisdom")
nature = Skill(ability="intelligence")
perception = Skill(ability="wisdom")
performance = Skill(ability="charisma")
persuasion = Skill(ability="charisma")
religion = Skill(ability="intelligence")
sleight_of_hand = Skill(ability="dexterity")
stealth = Skill(ability="dexterity")
survival = Skill(ability="wisdom")
# Inventory
cp = 0
sp = 0
ep = 0
gp = 0
pp = 0
equipment = ""
_weapons = list()
magic_items = list()
armor = None
shield = None
# Magic
spellcasting_ability = None
_spells = list()
_spells_prepared = list()
infusions = list()
# Features IN MAJOR DEVELOPMENT
custom_features = list()
feature_choices = list()
def __init__(self):
pass
@property
def weapons(self):
return self._weapons.copy()
@property
def passive_wisdom(self):
return self.perception.modifier + 10
@property
def abilities(self):
return [self.strength, self.dexterity, self.constitution,
self.intelligence, self.wisdom, self.charisma]
@property
def skills(self):
return [self.acrobatics, self.animal_handling, self.arcana,
self.athletics, self.deception, self.history,
self.insight, self.intimidation, self.investigation,
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
+6 -2
View File
@@ -1,3 +1,7 @@
<h1 id="[[ title | to_heading_id ]]">[[ title ]]</h1>
[% for section in sections %]
[[ rst | rst_to_html ]]
<h1 id="extra-[[ section.name | to_heading_id ]]">[[ section.name ]]</h1>
[[ section.__doc__ | rst_to_html ]]
[% endfor %]
+7 -3
View File
@@ -1,4 +1,8 @@
\pdfbookmark[0]{[[ title ]]}{[[ title ]]}
\section*{[[ title ]]}
[% for section in sections %]
[[ rst | rst_to_latex ]]
\pdfbookmark[0]{[[ section.name ]]}{[[ section.name ]]}
\section*{[[ section.name ]]}
[[ section.__doc__ | rst_to_latex ]]
[% endfor %]
+21 -18
View File
@@ -28,7 +28,7 @@ from dungeonsheets.fill_pdf_template import (
create_spells_pdf_template,
)
from dungeonsheets.character import Character
from dungeonsheets.entity import Entity
from dungeonsheets.content import Creature
"""Program to take character definitions and build a PDF of the
character sheet."""
@@ -92,7 +92,7 @@ def create_monsters_content(
def create_party_summary_content(
party: Sequence[Entity],
party: Sequence[Creature],
summary_rst: str,
suffix: str,
use_dnd_decorations: bool = False,
@@ -104,8 +104,6 @@ def create_party_summary_content(
)
def create_random_tables_content(
conjure_animals: bool,
suffix: str,
@@ -117,12 +115,19 @@ def create_random_tables_content(
)
def create_extra_gm_content(rst: str, title: str, suffix: str, use_dnd_decorations: bool=False):
"""Create content for arbitrary additional text provided in a GM sheet."""
def create_extra_gm_content(sections: Sequence, suffix: str, use_dnd_decorations: bool=False):
"""Create content for arbitrary additional text provided in a GM sheet.
Parameters
==========
sections
Subclasses of Content that will each be included as new sections
in the output document.
"""
template = jinja_env.get_template(f"extra_gm_content.{suffix}")
full_title = title.replace("_", " ").title()
return template.render(
rst=rst, title=full_title, use_dnd_decorations=use_dnd_decorations
sections=sections, use_dnd_decorations=use_dnd_decorations
)
@@ -267,16 +272,12 @@ def make_gm_sheet(
use_dnd_decorations=fancy_decorations,
)
)
# Parse any extra homebrew attributes, etc.
gm_props.pop("dungeonsheets_version")
gm_props.pop("sheet_type")
extra_gm_attrs = []
for attr, text in gm_props.items():
if isinstance(text, str):
extra_gm_attrs.append(attr)
content.append(create_extra_gm_content(rst=text, title=attr, suffix=content_suffix, use_dnd_decorations=fancy_decorations))
for attr in extra_gm_attrs:
gm_props.pop(attr)
# Parse any extra homebrew sections, etc.
content.append(
create_extra_gm_content(sections=gm_props.pop("extra_sections", []),
suffix=content_suffix,
use_dnd_decorations=fancy_decorations)
)
# Add the closing TeX
content.append(
jinja_env.get_template(f"postamble.{format_suffixes[output_format]}").render(
@@ -284,6 +285,8 @@ def make_gm_sheet(
)
)
# Warn about any unhandled sheet properties
gm_props.pop("dungeonsheets_version")
gm_props.pop("sheet_type")
if len(gm_props.keys()) > 0:
msg = f"Unhandled attributes in '{str(gm_file)}': {','.join(gm_props.keys())}"
log.warning(msg)
+3 -4
View File
@@ -6,9 +6,8 @@ shape forms.
from abc import ABCMeta
from dungeonsheets.entity import Entity
from dungeonsheets.content import Creature
from dungeonsheets.spells import Spell
from dungeonsheets.content_registry import find_content
class SpellFactory(ABCMeta):
@@ -19,7 +18,7 @@ class SpellFactory(ABCMeta):
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):
@@ -27,7 +26,7 @@ class SpellFactory(ABCMeta):
self.spells[idx] = TheSpell
class Monster(Entity, metaclass=SpellFactory):
class Monster(Creature, metaclass=SpellFactory):
"""A monster that may be encountered when adventuring."""
+10 -10
View File
@@ -1239,7 +1239,7 @@ class SuccubusIncubus(Monster):
spells = []
class SwarmofBats(Monster):
class SwarmOfBats(Monster):
"""
Echolocation.
The swarm can't use its blindsight while deafened.
@@ -1314,7 +1314,7 @@ class SwarmOfBats(Monster):
hit_dice = "5d8"
class SwarmofBeetles(Monster):
class SwarmOfBeetles(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
@@ -1347,7 +1347,7 @@ class SwarmofBeetles(Monster):
spells = []
class SwarmofCentipedes(Monster):
class SwarmOfCentipedes(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
@@ -1384,7 +1384,7 @@ class SwarmofCentipedes(Monster):
spells = []
class SwarmofInsects(Monster):
class SwarmOfInsects(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
@@ -1417,7 +1417,7 @@ class SwarmofInsects(Monster):
spells = []
class SwarmofPoisonousSnakes(Monster):
class SwarmOfPoisonousSnakes(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
@@ -1452,7 +1452,7 @@ class SwarmofPoisonousSnakes(Monster):
spells = []
class SwarmofQuippers(Monster):
class SwarmOfQuippers(Monster):
"""
Blood Frenzy.
The swarm has advantage on melee attack rolls against any creature
@@ -1490,7 +1490,7 @@ class SwarmofQuippers(Monster):
spells = []
class SwarmofRats(Monster):
class SwarmOfRats(Monster):
"""
Keen Smell.
The swarm has advantage on Wisdom (Perception) checks that rely on
@@ -1526,7 +1526,7 @@ class SwarmofRats(Monster):
spells = []
class SwarmofRavens(Monster):
class SwarmOfRavens(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
@@ -1559,7 +1559,7 @@ class SwarmofRavens(Monster):
spells = []
class SwarmofSpiders(Monster):
class SwarmOfSpiders(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
@@ -1600,7 +1600,7 @@ class SwarmofSpiders(Monster):
spells = []
class SwarmofWasps(Monster):
class SwarmOfWasps(Monster):
"""
Swarm.
The swarm can occupy another creature's space and vice versa, and the
+60 -60
View File
@@ -64,28 +64,28 @@ class Ability:
# ability score dictionary exists but doesn't have this ability
obj._ability_scores[self.ability_name] = self.default_value
def __get__(self, entity, Entity):
self._check_dict(entity)
score = entity._ability_scores[self.ability_name]
def __get__(self, actor, Actor):
self._check_dict(actor)
score = actor._ability_scores[self.ability_name]
modifier = math.floor((score - 10) / 2)
# Check for proficiency
saving_throw = modifier
if self.ability_name is not None and hasattr(
entity, "saving_throw_proficiencies"
actor, "saving_throw_proficiencies"
):
is_proficient = self.ability_name in entity.saving_throw_proficiencies
is_proficient = self.ability_name in actor.saving_throw_proficiencies
if is_proficient:
saving_throw += entity.proficiency_bonus
saving_throw += actor.proficiency_bonus
# Check for bonuses to saving throws from magic items
for mitem in entity.magic_items:
for mitem in actor.magic_items:
saving_throw += getattr(mitem, "st_bonus", 0)
# Create the named tuple
value = AbilityScore(modifier=modifier, value=score, saving_throw=saving_throw, name=self.ability_name)
return value
def __set__(self, entity, val):
self._check_dict(entity)
entity._ability_scores[self.ability_name] = val
def __set__(self, actor, val):
self._check_dict(actor)
actor._ability_scores[self.ability_name] = val
self.value = val
@@ -121,7 +121,7 @@ class Skill:
ability_name = ""
skill_name = ""
entity = None
actor = None
def __init__(self, ability):
self.ability_name = ability
@@ -129,8 +129,8 @@ class Skill:
def __set_name__(self, owner, name):
self.skill_name = name.lower().replace("_", " ")
def __get__(self, entity, owner):
self.entity = entity
def __get__(self, actor, owner):
self.actor = actor
return self
def __str__(self):
@@ -139,7 +139,7 @@ class Skill:
@property
def is_remarkable_athlete(self):
already_proficient = (self.is_proficient or self.is_expertise)
if self.entity.has_feature(RemarkableAthlete) and not already_proficient:
if self.actor.has_feature(RemarkableAthlete) and not already_proficient:
return True
else:
return False
@@ -147,7 +147,7 @@ class Skill:
@property
def is_jack_of_all_trades(self):
already_proficient = (self.is_proficient or self.is_expertise or self.is_remarkable_athlete)
if self.entity.has_feature(JackOfAllTrades) and not already_proficient:
if self.actor.has_feature(JackOfAllTrades) and not already_proficient:
return True
else:
return False
@@ -155,33 +155,33 @@ class Skill:
@property
def is_proficient(self):
# Check for proficiency
proficiencies = [p.replace("_", " ") for p in self.entity.skill_proficiencies]
proficiencies = [p.replace("_", " ") for p in self.actor.skill_proficiencies]
is_proficient = self.skill_name in proficiencies
return is_proficient
@property
def is_expertise(self):
return self.skill_name in self.entity.skill_expertise
return self.skill_name in self.actor.skill_expertise
@property
def proficiency_modifier(self):
modifier = 0
if self.is_proficient:
modifier += self.entity.proficiency_bonus
modifier += self.actor.proficiency_bonus
if self.is_remarkable_athlete:
modifier += ceil(self.entity.proficiency_bonus / 2.0)
modifier += ceil(self.actor.proficiency_bonus / 2.0)
if self.is_jack_of_all_trades:
modifier += self.entity.proficiency_bonus // 2
modifier += self.actor.proficiency_bonus // 2
# Check for expertise
if self.is_expertise:
modifier += self.entity.proficiency_bonus
modifier += self.actor.proficiency_bonus
return modifier
@property
def modifier(self):
ability = getattr(self.entity, self.ability_name)
ability = getattr(self.actor, self.ability_name)
modifier = ability.modifier + self.proficiency_modifier
log.info("%s modifier for '%s': %d", self, self.entity.name, modifier)
log.info("%s modifier for '%s': %d", self, self.actor.name, modifier)
return modifier
@@ -190,36 +190,36 @@ class ArmorClass:
The Armor Class of a character
"""
def __get__(self, entity, Entity):
armor = entity.armor or NoArmor()
def __get__(self, actor, Actor):
armor = actor.armor or NoArmor()
ac = armor.base_armor_class
# calculate and apply modifiers
if armor.dexterity_mod_max is None:
ac += entity.dexterity.modifier
ac += actor.dexterity.modifier
else:
ac += min(entity.dexterity.modifier, armor.dexterity_mod_max)
if entity.has_feature(NaturalArmor):
ac = max(ac, 13 + entity.dexterity.modifier)
shield = entity.shield or NoShield()
ac += min(actor.dexterity.modifier, armor.dexterity_mod_max)
if actor.has_feature(NaturalArmor):
ac = max(ac, 13 + actor.dexterity.modifier)
shield = actor.shield or NoShield()
ac += shield.base_armor_class
# Compute feature-specific additions
if entity.has_feature(UnarmoredDefenseMonk):
if actor.has_feature(UnarmoredDefenseMonk):
if isinstance(armor, NoArmor) and isinstance(shield, NoShield):
ac += entity.wisdom.modifier
if entity.has_feature(UnarmoredDefenseBarbarian):
ac += actor.wisdom.modifier
if actor.has_feature(UnarmoredDefenseBarbarian):
if isinstance(armor, NoArmor):
ac += entity.constitution.modifier
if entity.has_feature(DraconicResilience):
ac += actor.constitution.modifier
if actor.has_feature(DraconicResilience):
if isinstance(armor, NoArmor):
ac += 3
if entity.has_feature(Defense):
if actor.has_feature(Defense):
if not isinstance(armor, NoArmor):
ac += 1
if entity.has_feature(SoulOfTheForge):
if actor.has_feature(SoulOfTheForge):
if isinstance(armor, HeavyArmor):
ac += 1
# Check if any magic items add to AC
for mitem in entity.magic_items:
for mitem in actor.magic_items:
if hasattr(mitem, "ac_bonus"):
ac += mitem.ac_bonus
return ac
@@ -230,25 +230,25 @@ class Speed:
The speed of a character
"""
def __get__(self, entity, Entity):
speed = entity.race.speed
def __get__(self, actor, Actor):
speed = actor.race.speed
other_speed = ""
if isinstance(speed, str):
other_speed = speed[2:]
speed = int(speed[:2]) # ignore other speeds, like fly
if entity.has_feature(FastMovement):
if not isinstance(entity.armor, HeavyArmor):
if actor.has_feature(FastMovement):
if not isinstance(actor.armor, HeavyArmor):
speed += 10
if entity.has_feature(SuperiorMobility):
if actor.has_feature(SuperiorMobility):
speed += 10
if isinstance(entity.armor, NoArmor) or (entity.armor is None):
for f in entity.features:
if isinstance(actor.armor, NoArmor) or (actor.armor is None):
for f in actor.features:
if isinstance(f, UnarmoredMovement):
speed += f.speed_bonus
if entity.has_feature(GiftOfTheDepths):
if actor.has_feature(GiftOfTheDepths):
if "swim" not in other_speed:
other_speed += " ({:d} swim)".format(speed)
if entity.has_feature(SeaSoul):
if actor.has_feature(SeaSoul):
if "swim" not in other_speed:
other_speed += " (30 swim)"
return "{:d}{:s}".format(speed, other_speed)
@@ -257,19 +257,19 @@ class Speed:
class NumericalInitiative:
"""A numerical representation of initiative"""
def __get__(self, entity, Entity):
ini = entity.dexterity.modifier
if entity.has_feature(QuickDraw):
ini += entity.proficiency_bonus
if entity.has_feature(DreadAmbusher):
ini += entity.wisdom.modifier
if entity.has_feature(RakishAudacity):
ini += entity.charisma.modifier
def __get__(self, actor, Actor):
ini = actor.dexterity.modifier
if actor.has_feature(QuickDraw):
ini += actor.proficiency_bonus
if actor.has_feature(DreadAmbusher):
ini += actor.wisdom.modifier
if actor.has_feature(RakishAudacity):
ini += actor.charisma.modifier
has_advantage = (
entity.has_feature(NaturalExplorerRevised)
or entity.has_feature(FeralInstinct)
or entity.has_feature(AmbushMaster)
actor.has_feature(NaturalExplorerRevised)
or actor.has_feature(FeralInstinct)
or actor.has_feature(AmbushMaster)
)
return ini, has_advantage
@@ -277,8 +277,8 @@ class NumericalInitiative:
class Initiative(NumericalInitiative):
"""A character's initiative"""
def __get__(self, entity, Entity):
ini, has_advantage = super(Initiative, self).__get__(entity, Entity)
def __get__(self, actor, Actor):
ini, has_advantage = super(Initiative, self).__get__(actor, Actor)
ini = "{:+d}".format(ini)
if has_advantage:
ini += "(A)"
+23 -12
View File
@@ -29,16 +29,27 @@ parent_sheets = ["gm-campaign-notes.py"]
# the output
monsters = ["aboleth", "wolf", "giant eagle", "Vashta Nerada", "priest"]
# Arbitrary sections can be added to the GM notes. Any attribute that
# is a string and whose name doesn't start with an underscore ("_")
# will be included as a separate section
BBEG_motivation = (
"""Hans Gruber is after the $640 in bearer bonds stored in *Nakatomi
plaza*."""
)
# Arbitrary sections can be added to the GM notes. The
# ``extra_sections`` attribute should be a sequence of subclasses of
# the *Content* base class. For each entry in the sequence, the *name*
# attribute will be used for the section title, and the docstring will
# make up the body
the_bar_fight = (
"If the characters decide to go to the *Alliance Friendly Bar*, "
"they will probably have to fight their way out against 5 enemies "
"(3 Veteran, 2 Soldier)."
)
class BBEGMotivation():
"""Hans Gruber is after the $640 in bearer bonds stored in *Nakatomi
plaza*.
"""
name = "Big-Bad-Evil-Guy Motivation"
class BarFight():
"""If the characters decide to go to the *Alliance Friendly Bar*,
they will probably have to fight their way out against 5 enemies
(3 Veteran, 2 Soldier).
"""
name = "The Bar Fight"
extra_sections = [BBEGMotivation, BarFight]
+12 -5
View File
@@ -22,7 +22,7 @@ class MakeSheetsTestCase(unittest.TestCase):
def test_main(self):
make_sheets.main(args=[str(CHARFILE), "--debug"])
def test_make_sheets(self):
# Character PDF
make_sheets.make_sheet(sheet_file=CHARFILE)
@@ -167,10 +167,17 @@ class HtmlCreatorTestCase(unittest.TestCase):
self.assertIn(r"Wish", html)
# Check fancy extended properties
html = make_sheets.create_monsters_content(monsters=monsters_,
suffix="html",
use_dnd_decorations=True)
suffix="html",
use_dnd_decorations=True)
def test_create_extra_gm_content(self):
class MySection():
name = "My D&D Homebrew Content"
html = make_sheets.create_extra_gm_content(sections=[MySection], suffix="html")
self.assertIn('<h1 id="extra-My-DD-Homebrew-Content">', html)
tex = make_sheets.create_extra_gm_content(sections=[MySection], suffix="tex")
self.assertIn(r'\section*{My D&D Homebrew Content', tex)
class TexCreatorTestCase(unittest.TestCase):