Fixed broken test and added ability to put session summary into GM notes.

This commit is contained in:
Mark Wolfman
2021-06-16 10:03:35 -05:00
parent a9c893a4e3
commit c5672f950f
20 changed files with 156 additions and 80 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ __all__ = (
"race",
"background",
"spells",
"import_homebrew"
"import_homebrew",
)
from dungeonsheets import background, features, race, spells, weapons, mechanics
+2 -1
View File
@@ -108,8 +108,9 @@ class RivalIntern(Background):
the knowledge you gained there for an advantage at Acquisitions
Incorporated. Either way, you're now bringing your talents to the
company, ready to put your skills lo use.
"""
name = "Rival Intern"
skill_proficiencies = ("history", "investigation")
proficiencies_text = ("One type of artisan's tools",)
+25 -16
View File
@@ -77,13 +77,13 @@ def _resolve_mechanic(mechanic, SuperClass, warning_message=None):
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:
@@ -175,18 +175,18 @@ class Character(Entity):
# Appearance
# portrait = placeholder not sure how to implement
age = 0
height = ''
weight = ''
eyes = ''
skin = ''
hair = ''
height = ""
weight = ""
eyes = ""
skin = ""
hair = ""
# Background
allies = ''
faction_name = ''
allies = ""
faction_name = ""
# faction_symbol = placeholder not sure how to implement
backstory = ''
other_feats_traits = ''
treasure = ''
backstory = ""
other_feats_traits = ""
treasure = ""
def __init__(
self,
@@ -344,7 +344,9 @@ class Character(Entity):
self._race = newrace(owner=self)
elif isinstance(newrace, str):
try:
self._race = find_content(newrace, valid_classes=[race.Race])(owner=self)
self._race = find_content(newrace, valid_classes=[race.Race])(
owner=self
)
except AttributeError:
msg = f'Race "{newrace}" not defined. Please add it to ``race.py``'
self._race = race.Race(owner=self)
@@ -365,7 +367,9 @@ class Character(Entity):
self._background = bg(owner=self)
elif isinstance(bg, str):
try:
self._background = find_content(bg, valid_classes=[background.Background])(owner=self)
self._background = find_content(
bg, valid_classes=[background.Background]
)(owner=self)
except AttributeError:
msg = (
f'Background "{bg}" not defined. Please add it to ``background.py``'
@@ -634,7 +638,12 @@ class Character(Entity):
self.other_weapon_proficiencies = ()
msg = 'Magic Item "{}" not defined. Please add it to ``weapons.py``'
wps = set(
[_resolve_mechanic(w, SuperClass=weapons.Weapon, warning_message=msg) for w in val]
[
_resolve_mechanic(
w, SuperClass=weapons.Weapon, warning_message=msg
)
for w in val
]
)
wps -= set(self.weapon_proficiencies)
self.other_weapon_proficiencies = list(wps)
+26 -9
View File
@@ -3,29 +3,39 @@ from functools import lru_cache
import importlib.util
from typing import Union, List, Optional
from dungeonsheets import weapons, monsters, race, background, armor, spells, infusions, magic_items, features
from dungeonsheets import (
weapons,
monsters,
race,
background,
armor,
spells,
infusions,
magic_items,
features,
)
class ContentRegistry():
class ContentRegistry:
modules = None
def __init__(self):
self.modules = []
def add_module(self, new_module):
if new_module not in self.modules:
self.modules.append(new_module)
def findattr(self, name, valid_classes=[]):
"""Resolve the name of a piece of content to the corresponding Class.
Similar to builtin getattr(obj, name) but more forgiving to
whitespace and capitalization.
valid_classes
If given, only subclasses of classes in this list will be
returned.
"""
# Come up with several options
name = name.strip()
@@ -36,7 +46,9 @@ class ContentRegistry():
bonus = i
name = name.replace(f"+{i}", "").replace(f"+ {i}", "")
break
py_name = name.replace("-", "_").replace(" ", "_").replace("'", "").replace("/", "")
py_name = (
name.replace("-", "_").replace(" ", "_").replace("'", "").replace("/", "")
)
camel_case = "".join([s.capitalize() for s in py_name.split("_")])
# Check each module in the registry
found_attrs = []
@@ -51,7 +63,12 @@ class ContentRegistry():
if len(valid_classes) > 0:
is_valid = [False for attr in found_attrs]
for cls in valid_classes:
is_valid = [v or isinstance(attr, cls) or (isinstance(attr, type) and issubclass(attr, cls)) for v, attr in zip(is_valid, found_attrs)]
is_valid = [
v
or isinstance(attr, cls)
or (isinstance(attr, type) and issubclass(attr, cls))
for v, attr in zip(is_valid, found_attrs)
]
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:
@@ -94,7 +111,7 @@ def find_content(name: str, valid_classes: Optional[List]):
valid_classes
A list of parent classes to look for. If ``None`` or ``[]``, all
classes will be considered valid.
"""
if valid_classes is None:
valid_classes = []
@@ -112,7 +129,7 @@ def import_homebrew(filepath: Union[str, Path]):
==========
filepath
The location of the python file containing the homebrew content.
Returns
=======
mod
+2 -1
View File
@@ -342,8 +342,9 @@ class InsideInformant(Feature):
"""You have connections to your previous employer or other groups you
dealt with during your previous employment. You can communicate
with your contacts, gaining information at the DM's discretion.
"""
name = "Inside Informant"
source = "Background (Rival Intern)"
+14 -8
View File
@@ -87,7 +87,9 @@ def create_character_pdf_template(character, basename, flatten=False):
# Hit points
"HDTotal": character.hit_dice,
"HPMax": str(character.hp_max),
"HPCurrent": str(character.hp_current) if character.hp_current is not None else "",
"HPCurrent": str(character.hp_current)
if character.hp_current is not None
else "",
"HPTemp": str(character.hp_temp) if character.hp_temp > 0 else "",
# Personality traits and other features
"PersonalityTraits ": text_box(character.personality_traits),
@@ -148,7 +150,7 @@ def create_character_pdf_template(character, basename, flatten=False):
("Wpn Name 2", "Wpn2 AtkBonus ", "Wpn2 Damage "),
("Wpn Name 3", "Wpn3 AtkBonus ", "Wpn3 Damage "),
]
if len(character.weapons) == 0 or hasattr(character, 'Monk'):
if len(character.weapons) == 0 or hasattr(character, "Monk"):
character.wield_weapon("unarmed")
for _fields, weapon in zip(weapon_fields, character.weapons):
name_field, atk_field, dmg_field = _fields
@@ -156,8 +158,10 @@ def create_character_pdf_template(character, basename, flatten=False):
fields[atk_field] = "{:+d}".format(weapon.attack_modifier)
fields[dmg_field] = f"{weapon.damage}/{weapon.damage_type}"
# Additional attacks beyond 3
attack = [f"{w.name}: Atk {w.attack_modifier:+d}, Dam {w.damage}/{w.damage_type}"
for w in character.weapons[len(weapon_fields):]]
attack = [
f"{w.name}: Atk {w.attack_modifier:+d}, Dam {w.damage}/{w.damage_type}"
for w in character.weapons[len(weapon_fields):]
]
# Other attack information
if character.armor:
attack.append(f"Armor: {character.armor}")
@@ -192,7 +196,7 @@ def create_personality_pdf_template(character, basename, flatten=False):
"FactionName": character.faction_name,
"Backstory": text_box(character.backstory),
"Feat+Traits": text_box(character.other_feats_traits),
"Treasure": text_box(character.treasure)
"Treasure": text_box(character.treasure),
}
# Prepare the actual PDF
dirname = os.path.join(os.path.dirname(os.path.abspath(__file__)), "forms/")
@@ -464,9 +468,11 @@ def create_spells_pdf_template(character, basename, flatten=False):
# Determine if we should omit un-prepared spells to save space
if len(spells) > len(field_numbers[level]):
spells = [s for s in spells if s in character.spells_prepared]
warnings.warn(f"{character.name} knows more spells than the number of "
"lines available in spell sheet. Limited to prepared "
"spells only.")
warnings.warn(
f"{character.name} knows more spells than the number of "
"lines available in spell sheet. Limited to prepared "
"spells only."
)
# Build the list of PDF controls to set/toggle
field_names = [f"Spells {i}" for i in field_numbers[level]]
prep_names = tuple(f"Check Box {i}" for i in prep_numbers[level])
@@ -1,3 +1,13 @@
[% if summary %]
\section*{Summary}
[[ summary | rst_to_latex ]]
[% endif %]
[% if party %]
\section*{Party}
[% if use_dnd_decorations %]
@@ -55,3 +65,5 @@
[% endfor %]
\end{tabular}
[% endif %]
[% endif %]
-1
View File
@@ -1,2 +1 @@
"""Tools useful for defining homebrew content."""
+14 -11
View File
@@ -93,11 +93,15 @@ def create_monsters_tex(
def create_party_summary_tex(
party: Sequence[Entity],
use_dnd_decorations: bool = False,
party: Sequence[Entity],
summary_rst: str,
use_dnd_decorations: bool = False,
) -> str:
log.debug("Preparing summary table for party: %s", party)
template = jinja_env.get_template("party_summary_template.tex")
return template.render(party=party, use_dnd_decorations=use_dnd_decorations)
return template.render(
party=party, summary=summary_rst, use_dnd_decorations=use_dnd_decorations
)
def create_spellbook_tex(
@@ -196,7 +200,7 @@ def make_gm_sheet(
title=gm_props["session_title"],
)
]
# Add the party stats table
# Add the party stats table and session summary
party = []
for char_file in gm_props.get("party", []):
# Resolve the file path
@@ -208,10 +212,12 @@ def make_gm_sheet(
character_props = readers.read_sheet_file(char_file)
member = _char.Character.load(character_props)
party.append(member)
if len(party) > 0:
tex.append(
create_party_summary_tex(party, use_dnd_decorations=fancy_decorations)
summary = gm_props.get("summary", "")
tex.append(
create_party_summary_tex(
party, summary_rst=summary, use_dnd_decorations=fancy_decorations
)
)
# Add the monsters
monsters_ = []
for monster in gm_props.get("monsters", []):
@@ -222,10 +228,7 @@ def make_gm_sheet(
try:
MyMonster = find_content(monster, valid_classes=[monsters.Monster])
except AttributeError:
msg = (
f"Monster '{monster}' not found. Please add it to"
" ``monsters.py``"
)
msg = f"Monster '{monster}' not found. Please add it to ``monsters.py``"
warnings.warn(msg)
continue
else:
+1 -1
View File
@@ -21,7 +21,7 @@ class Monster(Entity):
saving_throws = ""
# TODO: Consider refactoring stats.Speed to consider all of these
# just like we do stats.Ability
swim_speed = 0
fly_speed = 0
climb_speed = 0
+3 -2
View File
@@ -48,7 +48,7 @@ class Aboleth(Monster):
charmed target is under the aboleth's control and can't take
reactions, and the aboleth and the target can communicate
telepathically with each other over any distance.
Whenever the charmed target takes damage, the target can repeat
the saving throw. On a success, the effect ends. No more than
once every 24 hours, the target can also repeat the saving throw
@@ -79,7 +79,7 @@ class Aboleth(Monster):
class Acolyte(Monster):
"""Spellcasting
The acolyte is a 1st-level spellcaster. Its spellcasting ability
is Wisdom (spell save DC 12, +4 to hit with spell attacks). The
acolyte has following cleric spells prepared:
@@ -92,6 +92,7 @@ class Acolyte(Monster):
(1d4) bludgeoning damage.
"""
name = "Acolyte"
description = "Medium humanoid, any alignment"
challenge_rating = 0.25
+7 -1
View File
@@ -450,6 +450,7 @@ class BlackEarthGuard(Monster):
burrowsharks.
"""
name = "Black Earth Guard"
description = "Medium humanoid (human), neutral evil"
challenge_rating = 2
@@ -507,6 +508,7 @@ class BlackEarthPriest(Monster):
over the rest of Ogrémoch's followers.
"""
name = "Black Earth Priest"
description = "Medium humanoid (human), neutral evil"
challenge_rating = 3
@@ -551,8 +553,9 @@ class BlackPudding(Monster):
dissolved and takes a permanent and cumulative -1 penalty to the
AC it offers. The armor is destroyed if the penalty reduces its
AC to 10.
"""
name = "Black Pudding"
description = "Large ooze, unaligned"
challenge_rating = 4
@@ -885,6 +888,7 @@ class Burrowshark(Monster):
neither moved nor knocked prone.
"""
name = "Burrowshark"
description = "Medium humanoid (human), neutral evil"
challenge_rating = 4
@@ -927,6 +931,7 @@ class Bulette(Monster):
prone in the bulette's space.
"""
name = "Bulette"
description = "Large monstrosity, unaligned"
challenge_rating = 5
@@ -944,5 +949,6 @@ class Bulette(Monster):
swim_speed = 0
fly_speed = 0
climb_speed = 0
burrow_speed = 40
hp_max = 94
hit_dice = "9d10+45"
+3 -3
View File
@@ -20,7 +20,7 @@ class Dao(Monster):
The dao's innate spellcasting ability is Charisma (spell save DC
14, +6 to hit with spell attacks). It can innately cast the
following spells, requiring no material components:
At will: detect evil and good, detect magic, stone shape. 3/day
each: passwall, move earth, tongues. 1/day each: conjure
elemental (earth elemental only), gaseous form, invisibility,
@@ -38,8 +38,9 @@ class Dao(Monster):
target. *Hit:* 20 (4d6 + 6) bludgeoning damage. If the target is
a Huge or smaller creature, it must succeed on a DC 18 Strength
check or be knocked prone.
"""
name = "Dao"
description = "Large elemental, neutral evil"
challenge_rating = 11
@@ -64,7 +65,6 @@ class Dao(Monster):
hit_dice = "15d10 + 105"
class Darkmantle(Monster):
"""
**Echolocation**: The darkmantle can't use its blindsight while deafened.
+3 -1
View File
@@ -48,8 +48,9 @@ class EarthElemental(Monster):
Slam.
Melee Weapon Attack: +8 to hit, reach 10 ft., one target. Hit:
14 (2d8 + 5) bludgeoning damage.
"""
name = "Earth Elemental"
description = "Large elemental, neutral"
challenge_rating = 5
@@ -92,6 +93,7 @@ class EarthElementalMyrmidon(Monster):
creators.
"""
name = "Earth Elemental Myrmidon"
description = "Medium elemental, neutral"
challenge_rating = 7
+10 -3
View File
@@ -316,6 +316,7 @@ class ShadowDemon(Monster):
creature. *Hit:* 10 (2d6+3) psychic damage or, if the demon
had advantage on the attack roll, 17 (4d6+3) psychic damage.
"""
name = "Shadow Demon"
description = "Medium fiend (demon), chaotic evil"
challenge_rating = 4
@@ -323,9 +324,14 @@ class ShadowDemon(Monster):
skills = "Stealth +7"
saving_throws = "Dex +5, Cha +4"
damage_vulnerabilities = "radiant"
damage_resistances = "acid, fire, necrotic, thunder; bludgeoning, piercing, and slashing from nonmagical attacks"
damage_resistances = (
"acid, fire, necrotic, thunder; bludgeoning, piercing, and slashing from"
" nonmagical attacks"
)
damage_immunities = "cold, lightning, poison"
condition_immunities = "exhaustion, grappled, paralyzed, petrified, poisoned, prone, restrained"
condition_immunities = (
"exhaustion, grappled, paralyzed, petrified, poisoned, prone, restrained"
)
senses = "Darkvision 120 ft., Passive Perception 11"
languages = "Abyssal, telepathy 120 ft."
strength = Ability(1)
@@ -812,6 +818,7 @@ class StoneGolem(Monster):
**Slow**: The golem targets one or more creatures it can see within 10 ft. of it. Each target must make a DC 17 Wisdom saving throw against this magic. On a failed save, a target can't use reactions, its speed is halved, and it can't make more than one attack on its turn. In addition, the target can take either an action or a bonus action on its turn, not both. These effects last for 1 minute. A target can repeat the saving throw at the end of each of its turns, ending the effect on itself on a success.
"""
name = "Stone Golem"
description = "Large construct, unaligned"
challenge_rating = 10
@@ -874,6 +881,7 @@ class Stonemelder(Monster):
it serves as a conduit for Ogrémoch's wrath.
"""
name = "Stonemelder"
description = "Medium humanoid (human), neutral evil"
challenge_rating = 4
@@ -895,7 +903,6 @@ class Stonemelder(Monster):
hit_dice = "10d8 + 30"
class StormGiant(Monster):
"""
**Amphibious**: The giant can breathe air and water.
+6 -3
View File
@@ -45,7 +45,7 @@ def read_sheet_file(filename: Union[str, Path]) -> dict:
these_props = reader()
# Resolve parent_sheets
char_props = {}
parent_sheets = these_props.pop('parent_sheets', [])
parent_sheets = these_props.pop("parent_sheets", [])
for parent_sheet in parent_sheets:
parent_sheet = (filename.parent / parent_sheet).resolve()
if parent_sheet != filename:
@@ -322,7 +322,7 @@ class FoundryCharacterReader(JSONCharacterReader):
"unarmed strike (monk)",
"<no name>",
]
def _skill_proficiency_value(self, key: str) -> float:
proficiency_labels = {
"acrobatics": "acr",
@@ -386,7 +386,10 @@ class FoundryCharacterReader(JSONCharacterReader):
"""Iterator over the weapons the character is carrying in her inventory."""
items = self.json_data()["items"]
for item in items:
is_valid_weapon = (item["type"] == "weapon" and item["name"].lower() not in self._invalid_weapons)
is_valid_weapon = (
item["type"] == "weapon"
and item["name"].lower() not in self._invalid_weapons
)
if is_valid_weapon:
yield item["name"].lower()
+6 -7
View File
@@ -3,7 +3,7 @@ from collections import namedtuple
from math import ceil
import logging
from dungeonsheets.armor import Armor, HeavyArmor, NoArmor, NoShield, Shield
from dungeonsheets.armor import HeavyArmor, NoArmor, NoShield
from dungeonsheets.features import (
AmbushMaster,
Defense,
@@ -25,7 +25,6 @@ from dungeonsheets.features import (
UnarmoredDefenseMonk,
UnarmoredMovement,
)
from dungeonsheets.weapons import Weapon
log = logging.getLogger(__name__)
@@ -89,14 +88,15 @@ class Skill:
self.character = entity
def __get__(self, entity, owner):
log.debug("Getting skill '%s' for '%s'",
self.skill_name, entity.name)
log.debug("Getting skill '%s' for '%s'", self.skill_name, entity.name)
ability = getattr(entity, self.ability_name)
modifier = ability.modifier
# Check for proficiency
proficiencies = [p.replace("_", " ") for p in entity.skill_proficiencies]
is_proficient = self.skill_name in proficiencies
log.debug("%s is proficient in %s: %s", entity.name, self.skill_name, is_proficient)
log.debug(
"%s is proficient in %s: %s", entity.name, self.skill_name, is_proficient
)
if is_proficient:
modifier += entity.proficiency_bonus
elif entity.has_feature(JackOfAllTrades):
@@ -109,8 +109,7 @@ class Skill:
is_expert = self.skill_name in entity.skill_expertise
if is_expert:
modifier += entity.proficiency_bonus
log.debug("'%s' modifier for '%s': %d",
self.skill_name, entity.name, modifier)
log.info("'%s' modifier for '%s': %d", self.skill_name, entity.name, modifier)
return modifier