mirror of
https://github.com/Threnklyn/dungeon-sheets.git
synced 2026-06-07 13:15:53 +02:00
Fixed broken test and added ability to put session summary into GM notes.
This commit is contained in:
@@ -7,7 +7,7 @@ __all__ = (
|
||||
"race",
|
||||
"background",
|
||||
"spells",
|
||||
"import_homebrew"
|
||||
"import_homebrew",
|
||||
)
|
||||
|
||||
from dungeonsheets import background, features, race, spells, weapons, mechanics
|
||||
|
||||
@@ -110,6 +110,7 @@ class RivalIntern(Background):
|
||||
company, ready to put your skills lo use.
|
||||
|
||||
"""
|
||||
|
||||
name = "Rival Intern"
|
||||
skill_proficiencies = ("history", "investigation")
|
||||
proficiencies_text = ("One type of artisan's tools",)
|
||||
|
||||
+22
-13
@@ -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)
|
||||
|
||||
@@ -3,10 +3,20 @@ 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):
|
||||
@@ -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:
|
||||
|
||||
@@ -344,6 +344,7 @@ class InsideInformant(Feature):
|
||||
with your contacts, gaining information at the DM's discretion.
|
||||
|
||||
"""
|
||||
|
||||
name = "Inside Informant"
|
||||
source = "Background (Rival Intern)"
|
||||
|
||||
|
||||
@@ -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 "
|
||||
warnings.warn(
|
||||
f"{character.name} knows more spells than the number of "
|
||||
"lines available in spell sheet. Limited to prepared "
|
||||
"spells only.")
|
||||
"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,2 +1 @@
|
||||
"""Tools useful for defining homebrew content."""
|
||||
|
||||
|
||||
@@ -94,10 +94,14 @@ def create_monsters_tex(
|
||||
|
||||
def create_party_summary_tex(
|
||||
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,9 +212,11 @@ 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:
|
||||
summary = gm_props.get("summary", "")
|
||||
tex.append(
|
||||
create_party_summary_tex(party, use_dnd_decorations=fancy_decorations)
|
||||
create_party_summary_tex(
|
||||
party, summary_rst=summary, use_dnd_decorations=fancy_decorations
|
||||
)
|
||||
)
|
||||
# Add the monsters
|
||||
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:
|
||||
|
||||
@@ -92,6 +92,7 @@ class Acolyte(Monster):
|
||||
(1d4) bludgeoning damage.
|
||||
|
||||
"""
|
||||
|
||||
name = "Acolyte"
|
||||
description = "Medium humanoid, any alignment"
|
||||
challenge_rating = 0.25
|
||||
|
||||
@@ -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
|
||||
@@ -553,6 +555,7 @@ class BlackPudding(Monster):
|
||||
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"
|
||||
|
||||
@@ -40,6 +40,7 @@ class Dao(Monster):
|
||||
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.
|
||||
|
||||
@@ -50,6 +50,7 @@ class EarthElemental(Monster):
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ dungeonsheets_version = "0.15.0"
|
||||
|
||||
sheet_type = "gm"
|
||||
|
||||
summary = """The party is about the enter the dungeon of *eternal tortuosity*."""
|
||||
|
||||
session_title = "Objects in Space - Session 1"
|
||||
|
||||
parent_sheets = ["gm-campaign-notes.py"]
|
||||
|
||||
@@ -174,6 +174,14 @@ class TexCreatorTestCase(unittest.TestCase):
|
||||
|
||||
def test_create_party_summary_tex(self):
|
||||
char = self.new_character()
|
||||
tex = make_sheets.create_party_summary_tex(party=[char])
|
||||
tex = make_sheets.create_party_summary_tex(party=[char], summary_rst="")
|
||||
self.assertIn(r"\section*{Party}", tex)
|
||||
self.assertIn(char.name, tex)
|
||||
|
||||
def test_create_summary_tex(self):
|
||||
rst = "The party's create *adventure*."
|
||||
tex = make_sheets.create_party_summary_tex(party=[], summary_rst=rst)
|
||||
self.assertIn(r"\section*{Summary}", tex)
|
||||
# Check that the RST is parsed
|
||||
self.assertIn(r"\emph{adventure}", tex)
|
||||
|
||||
|
||||
+10
-10
@@ -274,16 +274,16 @@ class AutoGeneratedMonsters(TestCase):
|
||||
monsters.StoneGolem,
|
||||
monsters.StormGiant,
|
||||
monsters.SuccubusIncubus,
|
||||
monsters.SwarmofBats,
|
||||
monsters.SwarmofBeetles,
|
||||
monsters.SwarmofCentipedes,
|
||||
monsters.SwarmofInsects,
|
||||
monsters.SwarmofPoisonousSnakes,
|
||||
monsters.SwarmofQuippers,
|
||||
monsters.SwarmofRats,
|
||||
monsters.SwarmofRavens,
|
||||
monsters.SwarmofSpiders,
|
||||
monsters.SwarmofWasps,
|
||||
monsters.SwarmOfBats,
|
||||
monsters.SwarmOfBeetles,
|
||||
monsters.SwarmOfCentipedes,
|
||||
monsters.SwarmOfInsects,
|
||||
monsters.SwarmOfPoisonousSnakes,
|
||||
monsters.SwarmOfQuippers,
|
||||
monsters.SwarmOfRats,
|
||||
monsters.SwarmOfRavens,
|
||||
monsters.SwarmOfSpiders,
|
||||
monsters.SwarmOfWasps,
|
||||
monsters.Tarrasque,
|
||||
monsters.Thug,
|
||||
monsters.Tiger,
|
||||
|
||||
Reference in New Issue
Block a user