diff --git a/dungeonsheets/encounter.py b/dungeonsheets/encounter.py
new file mode 100644
index 0000000..27dcc65
--- /dev/null
+++ b/dungeonsheets/encounter.py
@@ -0,0 +1,42 @@
+from collections import namedtuple
+
+XPThreshold = namedtuple("XPThreshold", ("easy", "medium", "hard", "deadly"))
+
+
+xp_thresholds_by_character_level = {
+ 1: XPThreshold(25, 50, 75, 100),
+ 2: XPThreshold(50, 100, 150, 200),
+ 3: XPThreshold(75, 150, 225, 400),
+ 4: XPThreshold(125, 250, 375, 500),
+ 5: XPThreshold(250, 500, 750, 1100),
+ 6: XPThreshold(300, 600, 900, 1400),
+ 7: XPThreshold(350, 750, 1100, 1700),
+ 8: XPThreshold(450, 900, 1400, 2100),
+ 9: XPThreshold(550, 1100, 1600, 2400),
+ 10: XPThreshold(600, 1200, 1900, 2800),
+ 11: XPThreshold(800, 1600, 2400, 3600),
+ 12: XPThreshold(1000, 2000, 3000, 4500),
+ 13: XPThreshold(1100, 2200, 3400, 5100),
+ 14: XPThreshold(1250, 2500, 3800, 5700),
+ 15: XPThreshold(1400, 2800, 4300, 6400),
+ 16: XPThreshold(1600, 3200, 4800, 7200),
+ 17: XPThreshold(2000, 3900, 5900, 8800),
+ 18: XPThreshold(2100, 4200, 6300, 9500),
+ 19: XPThreshold(2400, 4900, 7300, 10900),
+ 20: XPThreshold(2800, 5700, 8500, 12700),
+}
+
+
+def xp_thresholds(party):
+ thresholds = []
+ for member in party:
+ xp_th = xp_thresholds_by_character_level.get(
+ getattr(member, 'level', 0), XPThreshold(0, 0, 0, 0))
+ thresholds.append(xp_th)
+ final_thresholds = XPThreshold(
+ easy=sum(th.easy for th in thresholds),
+ medium=sum(th.medium for th in thresholds),
+ hard=sum(th.hard for th in thresholds),
+ deadly=sum(th.deadly for th in thresholds),
+ )
+ return final_thresholds
diff --git a/dungeonsheets/forms.py b/dungeonsheets/forms.py
index c867b89..8f89b43 100644
--- a/dungeonsheets/forms.py
+++ b/dungeonsheets/forms.py
@@ -4,6 +4,7 @@ from jinja2 import Environment, PackageLoader
from dungeonsheets.stats import mod_str, ability_mod_str, stat_abbreviation
+from dungeonsheets.encounter import xp_thresholds
from dungeonsheets.monsters import challenge_rating_to_xp
@@ -35,4 +36,5 @@ def jinja_environment():
jinja_env.filters["ability_mod_str"] = ability_mod_str
jinja_env.filters["stat_abbreviation"] = stat_abbreviation
jinja_env.filters["challenge_rating_to_xp"] = challenge_rating_to_xp
+ jinja_env.filters["xp_thresholds"] = xp_thresholds
return jinja_env
diff --git a/dungeonsheets/forms/character_sheet_template.html b/dungeonsheets/forms/character_sheet_template.html
index 97935ff..d327390 100644
--- a/dungeonsheets/forms/character_sheet_template.html
+++ b/dungeonsheets/forms/character_sheet_template.html
@@ -84,7 +84,7 @@
[[ ability.name | capitalize ]] |
[[ ability.modifier | mod_str ]] ([[ ability.value ]]) |
[% if ability.name in character.saving_throw_proficiencies %]✓[% endif %] |
- [[ character.strength.saving_throw | mod_str ]] |
+ [[ ability.saving_throw | mod_str ]] |
[% endfor %]
diff --git a/dungeonsheets/forms/party_summary_template.html b/dungeonsheets/forms/party_summary_template.html
index 3f8ff24..c457b78 100644
--- a/dungeonsheets/forms/party_summary_template.html
+++ b/dungeonsheets/forms/party_summary_template.html
@@ -53,4 +53,28 @@
[% endfor %]
+
+
+
+ | |
+ Easy |
+ Medium |
+ Hard |
+ Deadly |
+
+
+ | XP Threshold |
+ [% for threshold in party | xp_thresholds %]
+ [[ "{:,}".format(threshold) ]] |
+ [% endfor %]
+
+
+
+
+[% for member in party %]
+- [[ member.name ]]
+- [[ member.languages ]]
+[% endfor %]
+
+
[% endif %]
diff --git a/dungeonsheets/forms/party_summary_template.tex b/dungeonsheets/forms/party_summary_template.tex
index fa25c81..84d32f6 100644
--- a/dungeonsheets/forms/party_summary_template.tex
+++ b/dungeonsheets/forms/party_summary_template.tex
@@ -24,17 +24,26 @@
\\
[% endfor %]
\end{DndTable}
- \begin{DndTable}{r c c c}
- & AC & Pas.\ Per. & Spl.\ DC \\
+ \begin{DndTable}{r c c c c}
+ & AC & Pas.\ Per. & Max HP & Spl.\ DC \\
[% for member in party %]
[[ member.name[:28] ]]
& [[ member.armor_class ]]
& [[ member.perception.modifier + 10 ]]
+ & [[ member.max_hp ]]
& [% for class in member.class_list %]
[% if class.spellcasting_ability %] [[ member.spell_save_dc(class) ]], [% endif %]
[% endfor %]
\\
- [% endfor %]
+ [% endfor %]
+ \end{DndTable}
+ %% XP thresholds for the party
+ \begin{DndTable}{r c c c c}
+ & Easy & Medium & Hard & Deadly \\
+ \textbf{XP Threshold} &
+ [% for threshold in party | xp_thresholds %]
+ [[ "{:,}".format(threshold) ]] [% if not loop.last %]&[% endif %]
+ [% endfor %]
\end{DndTable}
[% else %]
\begin{tabular}{r | c c c c c c}
@@ -64,6 +73,21 @@
\\
[% endfor %]
\end{tabular}
+ %% XP thresholds for the party
+ \begin{tabular}{r c c c c}
+ & Easy & Medium & Hard & Deadly \\
+ \textbf{XP Threshold} &
+ [% for threshold in party | xp_thresholds %]
+ [[ "{:,}".format(threshold) ]] [% if not loop.last %]&[% endif %]
+ [% endfor %]
+ \end{tabular}
[% endif %]
+
+
+[% for member in party %]
+\textbf{[[ member.name ]]}: [[ member.languages ]]
+[% endfor %]
+
[% endif %]
+
diff --git a/dungeonsheets/forms/random_tables_template.html b/dungeonsheets/forms/random_tables_template.html
index 0f64fb7..6e74ced 100644
--- a/dungeonsheets/forms/random_tables_template.html
+++ b/dungeonsheets/forms/random_tables_template.html
@@ -1,4 +1,6 @@
+[% if tables|length > 0 %]
Random Tables
+[% endif %]
[% for table in tables %]
[[ table.name ]]
diff --git a/dungeonsheets/forms/random_tables_template.tex b/dungeonsheets/forms/random_tables_template.tex
index ec8bde3..98247e5 100644
--- a/dungeonsheets/forms/random_tables_template.tex
+++ b/dungeonsheets/forms/random_tables_template.tex
@@ -1,5 +1,7 @@
+[% if tables|length > 0 %]
\pdfbookmark[0]{Random Tables}{Random Tables}
\section*{Random Tables}
+[% endif %]
[% for table in tables %]
\pdfbookmark[0]{[[ table.name ]]}{Random Table - [[ table.name ]]}
diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py
index b7e7115..242cf70 100644
--- a/dungeonsheets/make_sheets.py
+++ b/dungeonsheets/make_sheets.py
@@ -223,15 +223,22 @@ def make_gm_sheet(
# Add the party stats table and session summary
party = []
for char_file in gm_props.pop("party", []):
- # Resolve the file path
- char_file = Path(char_file)
- if not char_file.is_absolute():
- char_file = gm_file.parent / char_file
- char_file = char_file.resolve()
- # Load the character file
- log.debug(f"Loading party member: {char_file}")
- character_props = readers.read_sheet_file(char_file)
- member = _char.Character.load(character_props)
+ # Check if it's already resolved
+ if isinstance(char_file, Creature):
+ member = char_file
+ elif isinstance(char_file, type) and issubclass(char_file, Creature):
+ # Needs to be instantiated
+ member = char_file()
+ else:
+ # Resolve the file path
+ char_file = Path(char_file)
+ if not char_file.is_absolute():
+ char_file = gm_file.parent / char_file
+ char_file = char_file.resolve()
+ # Load the character file
+ log.debug(f"Loading party member: {char_file}")
+ character_props = readers.read_sheet_file(char_file)
+ member = _char.Character.load(character_props)
party.append(member)
summary = gm_props.pop("summary", "")
content.append(
diff --git a/dungeonsheets/mechanics.py b/dungeonsheets/mechanics.py
index 04cc982..0d574cf 100644
--- a/dungeonsheets/mechanics.py
+++ b/dungeonsheets/mechanics.py
@@ -1,7 +1,7 @@
"""Convenience module holding base classes for the various kinds of
game mechanics."""
-from dungeonsheets.content import Content
+from dungeonsheets.content import Content, Creature
from dungeonsheets.spells import Spell
from dungeonsheets.features import Feature
from dungeonsheets.infusions import Infusion
@@ -14,5 +14,7 @@ from dungeonsheets.weapons import (
)
from dungeonsheets.armor import Armor, Shield
from dungeonsheets.magic_items import MagicItem
-from dungeonsheets.monsters import Monster
from dungeonsheets.stats import Ability
+
+from dungeonsheets.character import Character
+from dungeonsheets.monsters import Monster
diff --git a/dungeonsheets/monsters/monsters.py b/dungeonsheets/monsters/monsters.py
index a6f864e..a02e7c0 100644
--- a/dungeonsheets/monsters/monsters.py
+++ b/dungeonsheets/monsters/monsters.py
@@ -104,3 +104,6 @@ class Monster(Creature, metaclass=SpellFactory):
def is_beast(self):
is_beast = "beast" in self.description.lower()
return is_beast
+
+ def has_feature(self, *args, **kwargs):
+ return False
diff --git a/dungeonsheets/monsters/monsters_s.py b/dungeonsheets/monsters/monsters_s.py
index aeca5ed..59f8c27 100644
--- a/dungeonsheets/monsters/monsters_s.py
+++ b/dungeonsheets/monsters/monsters_s.py
@@ -1080,7 +1080,7 @@ class Stonemelder(Monster):
attacks. It knows the following sorcerer spells (an asterisked
spell is from *Princes of the Apocalypse* appendix B):
- - Cantrips (at will): acid splash, blade ward, light mending, mold earth*
+ - Cantrips (at will): acid splash, blade ward, light, mending, mold earth*
- 1st level (4 slots): expeditious retreat, false life, shield
- 2nd level (3 slots): Maximilian's earthen grasp,* shatter
- 3rd level (3 slots): erupting earth,* meld into stone
@@ -1123,9 +1123,9 @@ class Stonemelder(Monster):
climb_speed = 0
hp_max = 75
hit_dice = "10d8 + 30"
- spells = ["acid splash", "blade ward", "light mending", "mold earth*",
+ spells = ["acid splash", "blade ward", "light", "mending", "mold earth",
"expeditious retreat", "false life", "shield",
- "Maximilian's earthen grasp*", "shatter" "erupting earth*",
+ "maximilian's earthen grasp", "shatter", "erupting earth",
"meld into stone", "stoneskin"]
diff --git a/examples/gm-campaign-notes.py b/examples/gm-campaign-notes.py
index 1655fd1..9ef6470 100644
--- a/examples/gm-campaign-notes.py
+++ b/examples/gm-campaign-notes.py
@@ -5,12 +5,19 @@ monsters, etc.
"""
+from dungeonsheets import mechanics, monsters
+
dungeonsheets_version = "0.14.0"
sheet_type = "gm"
session_title = "Objects in Space"
-party = ["rogue1.py", "paladin2.py"]
+# Simple character definition
+haryk_omanie = mechanics.Character(
+ name="Haryk Omanie",
+)
+
+party = ["rogue1.py", "paladin2.py", haryk_omanie, monsters.Veteran]
random_tables = ["conjure animals"]
diff --git a/examples/gm-session-notes.py b/examples/gm-session-notes.py
index 7e0dbac..33ea707 100644
--- a/examples/gm-session-notes.py
+++ b/examples/gm-session-notes.py
@@ -10,7 +10,7 @@ from dungeonsheets import mechanics
# This line (or one like it) is required in order for dungeonsheets to
# recognize the file.
-dungeonsheets_version = "0.15.0"
+dungeonsheets_version = "0.17.0"
# Specifying ``sheet_type = "gm"`` gives us GM notes instead of a
# player character sheet.
diff --git a/tests/test_encounter.py b/tests/test_encounter.py
new file mode 100644
index 0000000..0674beb
--- /dev/null
+++ b/tests/test_encounter.py
@@ -0,0 +1,17 @@
+from unittest import TestCase
+
+from dungeonsheets import stats, character, encounter
+
+
+class TestStats(TestCase):
+ def new_character(self, level=1):
+ return character.Character(classes=['cleric'], levels=[level])
+
+ def test_xp_thresholds(self):
+ # One level 1 character
+ xp_th = encounter.xp_thresholds([self.new_character(1)])
+ self.assertEqual(xp_th, (25, 50, 75, 100))
+ # Three mixed-level characters
+ party = [self.new_character(9), self.new_character(8), self.new_character(6)]
+ xp_th = encounter.xp_thresholds(party)
+ self.assertEqual(xp_th, (1300, 2600, 3900, 5900))