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 %] + + + + + + + + + + + + [% for threshold in party | xp_thresholds %] + + [% endfor %] + +
 EasyMediumHardDeadly
XP Threshold[[ "{:,}".format(threshold) ]]
+ +
+[% 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))