From 610d75582ba9666538953cb838f607c5d05c22f2 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sat, 22 May 2021 20:04:47 -0400 Subject: [PATCH 01/16] [WIP] Creating an Encounter --- dungeonsheets/agent.py | 43 ++++++++++++++++++ dungeonsheets/character.py | 3 +- dungeonsheets/encounter/__init__.py | 0 dungeonsheets/encounter/encounter.py | 19 ++++++++ dungeonsheets/monsters.py | 9 ++-- dungeonsheets/race.py | 4 +- tests/test_encounter.py | 66 ++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 dungeonsheets/agent.py create mode 100644 dungeonsheets/encounter/__init__.py create mode 100644 dungeonsheets/encounter/encounter.py create mode 100644 tests/test_encounter.py diff --git a/dungeonsheets/agent.py b/dungeonsheets/agent.py new file mode 100644 index 0000000..061287d --- /dev/null +++ b/dungeonsheets/agent.py @@ -0,0 +1,43 @@ +class Agent: + """An actor in an encounter""" + + strategies = ("Random", "Greedy", "KillWeakest") + strategy = "Greedy" + + def __init__(self): + pass + + @property + def actions(self): + """All the things I can do in a turn""" + raise NotImplementedError() + + @property + def free_actions(self): + """Stuff I can do as much as I want in a turn""" + raise NotImplementedError() + + @property + def movement(self): + """Where I can go in a turn""" + raise NotImplementedError() + + @property + def bonus_actions(self): + """Things I can do once in addition to an action""" + raise NotImplementedError() + + @property + def reactions(self): + """Things I can do in response to an action""" + raise NotImplementedError() + + @property + def lair_actions(self): + """Things I can do at initiative count 20""" + raise NotImplementedError() + + @property + def legendary_actions(self): + """Things I can do so many times in a turn after another agent acts""" + raise NotImplementedError() \ No newline at end of file diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 1f2956c..8a2364c 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -23,6 +23,7 @@ from dungeonsheets import ( from dungeonsheets.stats import Ability, ArmorClass, Initiative, Skill, Speed, findattr from dungeonsheets.weapons import Weapon from dungeonsheets.readers import read_character_file +from dungeonsheets.agent import Agent def read(fname): @@ -145,7 +146,7 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None): return Mechanic -class Character: +class Character(Actor): """A generic player character.""" # General attirubtes diff --git a/dungeonsheets/encounter/__init__.py b/dungeonsheets/encounter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py new file mode 100644 index 0000000..70bb079 --- /dev/null +++ b/dungeonsheets/encounter/encounter.py @@ -0,0 +1,19 @@ +from .strategy import Strategy + +class Encounter: + """A combat encounter between two parties -- good guys and bad guys""" + + def __init__(self, good_guys, bad_guys): + self.good_guys = good_guys + self.bad_guys = bad_guys + + def rating(self): + raise NotImplementedError() # Deadly for Python :/ + + def simulate(self): + """Who will win?""" + raise NotImplementedError() # Apparently the mind flayers win for now + + def analyze(self): + """So, really... how deadly *is* it?""" + raise NotImplementedError() # TODO: Run a Monte-Carlo simulation diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py index d5a8efc..0ff11e1 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -3,9 +3,10 @@ shape forms.""" from dungeonsheets.stats import Ability +from dungeonsheets.agent import Agent -class Monster: +class Monster(Agent): """A monster that may be encountered when adventuring.""" name = "Generic Monster" @@ -209,8 +210,8 @@ class GiantFrog(Monster): description = "Medium beast, unaligned" challenge_rating = 1 / 4 armor_class = 11 - skills = "Pe rce ption +2, Stealth +3" - senses = "darkvi sion 30ft., passive Perception 12" + skills = "Perception +2, Stealth +3" + senses = "darkvision 30ft., passive Perception 12" languages = "" strength = Ability(12) dexterity = Ability(13) @@ -242,7 +243,7 @@ class GiantRat(Monster): challenge_rating = 1 / 8 armor_class = 12 skills = "" - senses = "Darkvision 60 ft., Passive perception 10" + senses = "Darkvision 60 ft., passive Perception 10" languages = "" strength = Ability(7) dexterity = Ability(15) diff --git a/dungeonsheets/race.py b/dungeonsheets/race.py index c039999..020f384 100644 --- a/dungeonsheets/race.py +++ b/dungeonsheets/race.py @@ -256,7 +256,7 @@ class HalfOrc(Race): features = (feats.Darkvision, feats.RelentlessEndurance, feats.SavageAttacks) -# Tielflings +# Tieflings class Tiefling(Race): name = "Tiefling" size = "medium" @@ -267,7 +267,7 @@ class Tiefling(Race): features = (feats.Darkvision, feats.HellishResistance, feats.InfernalLegacy) -# Aassimar +# Aasimar class _Aasimar(Race): name = "Aasimar" size = "medium" diff --git a/tests/test_encounter.py b/tests/test_encounter.py new file mode 100644 index 0000000..817145e --- /dev/null +++ b/tests/test_encounter.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +from unittest import TestCase + +from dungeonsheets.character import Character + + +class TestEncounter(TestCase): + """Tests for features and feature-related activities.""" + + def test_simulation(): + """Can I run an encounter against Schlangdedrosa Magentawrath?""" + char = Character() + char.set_attrs(name="Stravajiaxen") + char.set_attrs(weapons=["shortsword"]) + char.set_attrs(armor="leather armor", shield="shield") + + # Check that race gets set to an object + char.set_attrs(race="half orc") + char.set_attrs(inspiration=False) + + class SchlangdedrosaMagentawrath(Monster): + """ + **Action Surge (Recharges on a Short or Long Rest).** On his turn, Langdedrosa + can take one additional action. + + **Improved Critical.** Langdedrosa's weapon attacks score a critical hit on a + roll of 19 or 20. + + **Multiattack:** Schlangdedrosa attacks twice, either with his greatsword or spear. + + **Greatsword.** Melee Weapon Attack: +6 to hit, reach 5 ft., one target. + Hit: 11 (2d6 + 4) slashing damage. + + **Spear.** Melee or Ranged Weapon Attack: +6 to hit, reach 5 ft. or + ranged 20/60 ft., one target. Hit: 7 (1d6 + 4) piercing damage. + + **Lightning Breath (Recharge 5-6)**. Schlangdedrosa breathes lightning in a + 30-foot line that is 5 feet wide. Each creature in the line must make a DC 13 + Dexterity saving throw, taking 22 (4d10) lightning damage on a failed save, or + half as much damage on a successful one. + + **Climbing speed:** 30 ft. + """ + + name = "Schlangdedrosa Magentawrath" + description = "Medium humanoid (half-dragon), lawful evil" + challenge_rating = 4 + armor_class = 17 + skills = "Athletics +6, Intimidation +3, Perception +4" + senses = "blindsight 10 ft., darkvision 60ft., passive Perception 14" + strength = Ability(19) + dexterity = Ability(13) + constitution = Ability(16) + intelligence = Ability(10) + wisdom = Ability(14) + charisma = Ability(12) + speed = 30 + swim_speed = 0 + fly_speed = 0 + hp_max = 57 + hit_dice = "6d12+18" + + schlang = SchlangdedrosaMagentawrath() + + battle = Encounter([]) \ No newline at end of file From a08a1273cb982567cadbce2d608bcf849dd99b10 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sat, 22 May 2021 20:21:44 -0400 Subject: [PATCH 02/16] [WIP] Add Agent stuff --- dungeonsheets/agent.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/dungeonsheets/agent.py b/dungeonsheets/agent.py index 061287d..0562cfd 100644 --- a/dungeonsheets/agent.py +++ b/dungeonsheets/agent.py @@ -4,6 +4,21 @@ class Agent: strategies = ("Random", "Greedy", "KillWeakest") strategy = "Greedy" + # Hit points + hp_max = None + # Base stats (ability scores) + strength = Ability() + dexterity = Ability() + constitution = Ability() + intelligence = Ability() + wisdom = Ability() + charisma = Ability() + armor_class = ArmorClass() + initiative = Initiative() + speed = Speed() + + languages = "" + def __init__(self): pass From db3cac000e4fc300adb17006c526e1a1cde53a58 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sat, 22 May 2021 21:22:57 -0400 Subject: [PATCH 03/16] WIP add more details in encounter --- .gitignore | 3 +++ dungeonsheets/agent.py | 3 +++ dungeonsheets/character.py | 2 +- dungeonsheets/encounter/encounter.py | 20 +++++++++++++++----- dungeonsheets/stats.py | 21 +++++++++++++++------ 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 4279610..60bc7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,9 @@ ENV/ # Rope project settings .ropeproject +# PyCharm project settings +.idea + # mkdocs documentation /site diff --git a/dungeonsheets/agent.py b/dungeonsheets/agent.py index 0562cfd..4f8e590 100644 --- a/dungeonsheets/agent.py +++ b/dungeonsheets/agent.py @@ -1,3 +1,6 @@ +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed + + class Agent: """An actor in an encounter""" diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 8a2364c..b999664 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -146,7 +146,7 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None): return Mechanic -class Character(Actor): +class Character(Agent): """A generic player character.""" # General attirubtes diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index 70bb079..8d46bbe 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -1,17 +1,27 @@ -from .strategy import Strategy - class Encounter: """A combat encounter between two parties -- good guys and bad guys""" - def __init__(self, good_guys, bad_guys): - self.good_guys = good_guys - self.bad_guys = bad_guys + def __init__(self, *parties): + if len(parties) < 2: + raise ValueError("You need at least 2 parties to hold an encounter") + if len(parties) is not 2: + raise NotImplementedError("Haven't implemented AI for 3+ groups") # TODO: Implement this + + self.all_agents = parties[0] + for party in parties[1:]: + self.all_agents += party def rating(self): raise NotImplementedError() # Deadly for Python :/ def simulate(self): """Who will win?""" + + # Initiative + for agent in self.all_agents: + agent.roll_initiative() + self.all_agents = sorted(self.all_agents, key=lambda a: a.initiative_roll) + raise NotImplementedError() # Apparently the mind flayers win for now def analyze(self): diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index c2d5f60..8555ff4 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -211,8 +211,8 @@ class Speed: return "{:d}{:s}".format(speed, other_speed) -class Initiative: - """A character's initiative""" +class NumericalInitiative: + """A numerical representation of initiative""" def __get__(self, char, Character): ini = char.dexterity.modifier @@ -222,12 +222,21 @@ class Initiative: ini += char.wisdom.modifier if char.has_feature(RakishAudacity): ini += char.charisma.modifier - ini = "{:+d}".format(ini) + has_advantage = ( - char.has_feature(NaturalExplorerRevised) - or char.has_feature(FeralInstinct) - or char.has_feature(AmbushMaster) + char.has_feature(NaturalExplorerRevised) + or char.has_feature(FeralInstinct) + or char.has_feature(AmbushMaster) ) + return ini, has_advantage + + +class Initiative(NumericalInitiative): + """A character's initiative""" + + def __get__(self, char, Character): + ini, has_advantage = super(Initiative, self).__get__(char, Character) + ini = "{:+d}".format(ini) if has_advantage: ini += "(A)" return ini From 947623c42d2b40c314512c34c0b1023c1790627e Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sat, 22 May 2021 23:16:33 -0400 Subject: [PATCH 04/16] Refactoring Agent, Character into Agent base class --- dungeonsheets/agent.py | 72 ++++++++++++++++++++++++---- dungeonsheets/character.py | 67 +++----------------------- dungeonsheets/encounter/__init__.py | 1 + dungeonsheets/encounter/encounter.py | 1 + dungeonsheets/stats.py | 20 ++++++++ dungeonsheets/utils.py | 6 +++ tests/test_encounter.py | 12 +++-- 7 files changed, 104 insertions(+), 75 deletions(-) create mode 100644 dungeonsheets/utils.py diff --git a/dungeonsheets/agent.py b/dungeonsheets/agent.py index 4f8e590..c5ee799 100644 --- a/dungeonsheets/agent.py +++ b/dungeonsheets/agent.py @@ -1,14 +1,16 @@ -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill class Agent: """An actor in an encounter""" - strategies = ("Random", "Greedy", "KillWeakest") - strategy = "Greedy" + # General attributes + name = "" + alignment = "Neutral" # Hit points hp_max = None + # Base stats (ability scores) strength = Ability() dexterity = Ability() @@ -16,46 +18,96 @@ class Agent: 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 = "" + # 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 actions(self): """All the things I can do in a turn""" - raise NotImplementedError() + return [] @property def free_actions(self): """Stuff I can do as much as I want in a turn""" - raise NotImplementedError() + return [] @property def movement(self): """Where I can go in a turn""" - raise NotImplementedError() + return [] @property def bonus_actions(self): """Things I can do once in addition to an action""" - raise NotImplementedError() + return [] @property def reactions(self): """Things I can do in response to an action""" - raise NotImplementedError() + return [] @property def lair_actions(self): """Things I can do at initiative count 20""" - raise NotImplementedError() + return [] @property def legendary_actions(self): """Things I can do so many times in a turn after another agent acts""" - raise NotImplementedError() \ No newline at end of file + return [] \ No newline at end of file diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index b999664..3c91f21 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -149,54 +149,17 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None): class Character(Agent): """A generic player character.""" - # General attirubtes - name = "" player_name = "" - alignment = "Neutral" + dungeonsheets_version = __version__ + xp = 0 + inspiration = False + attacks_and_spellcasting = "" class_list = list() _race = None _background = None - xp = 0 - # Hit points - hp_max = None - # Base stats (ability scores) - strength = Ability() - dexterity = Ability() - constitution = Ability() - intelligence = Ability() - wisdom = Ability() - charisma = Ability() - armor_class = ArmorClass() - initiative = Initiative() - speed = Speed() - inspiration = False - _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 = "" - # 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") + # Characteristics - attacks_and_spellcasting = "" personality_traits = ( "TODO: Describe how your character behaves, interacts with others" ) @@ -204,26 +167,8 @@ class Character(Agent): bonds = "TODO: Describe your character's commitments or ongoing quests." flaws = "TODO: Describe your character's interesting flaws." features_and_traits = "Describe any other features and abilities." - # Inventory - cp = 0 - sp = 0 - ep = 0 - gp = 0 - pp = 0 - equipment = "" - weapons = list() - magic_items = list() - armor = None - shield = None + _proficiencies_text = list() - # Magic - spellcasting_ability = None - _spells = list() - _spells_prepared = list() - infusions = list() - # Features IN MAJOR DEVELOPMENT - custom_features = list() - feature_choices = list() def __init__( self, diff --git a/dungeonsheets/encounter/__init__.py b/dungeonsheets/encounter/__init__.py index e69de29..2433ab5 100644 --- a/dungeonsheets/encounter/__init__.py +++ b/dungeonsheets/encounter/__init__.py @@ -0,0 +1 @@ +from dungeonsheets.encounter.encounter import Encounter \ No newline at end of file diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index 8d46bbe..e9ba29a 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -7,6 +7,7 @@ class Encounter: if len(parties) is not 2: raise NotImplementedError("Haven't implemented AI for 3+ groups") # TODO: Implement this + # Combine all parties into a single group self.all_agents = parties[0] for party in parties[1:]: self.all_agents += party diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index 8555ff4..7b570bb 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -240,3 +240,23 @@ class Initiative(NumericalInitiative): if has_advantage: ini += "(A)" return ini + + +########################### +# STATUS +########################### + +class CurrentHP: + """A Numerical Representation of Current HP""" + + def __get__(self, char, Character): + if not char.hp_max: + char.__set_hp_max() + return char.hp_max + + +class CurrentInitiative(NumericalInitiative): + """A Numerical Representation of a Character's Rolled Initiative""" + + def __get__(self, char, Character): + ini, has_advantage = super(CurrentInitiative, self).__get__(char, Character) \ No newline at end of file diff --git a/dungeonsheets/utils.py b/dungeonsheets/utils.py new file mode 100644 index 0000000..a8a73fb --- /dev/null +++ b/dungeonsheets/utils.py @@ -0,0 +1,6 @@ +import random + + +def roll(d, n=1): + """roll(6, 2) means roll 2d6""" + return sum([random.randint(1, d) for _ in range(n)]) \ No newline at end of file diff --git a/tests/test_encounter.py b/tests/test_encounter.py index 817145e..24fa6b9 100644 --- a/tests/test_encounter.py +++ b/tests/test_encounter.py @@ -3,17 +3,20 @@ from unittest import TestCase from dungeonsheets.character import Character +from dungeonsheets.encounter import Encounter +from dungeonsheets.monsters import Monster +from dungeonsheets.stats import Ability class TestEncounter(TestCase): """Tests for features and feature-related activities.""" - def test_simulation(): + def test_simulation(self): """Can I run an encounter against Schlangdedrosa Magentawrath?""" char = Character() char.set_attrs(name="Stravajiaxen") - char.set_attrs(weapons=["shortsword"]) - char.set_attrs(armor="leather armor", shield="shield") + char.set_attrs(weapons=["greataxe"]) + char.set_attrs(armor="split mail") # Check that race gets set to an object char.set_attrs(race="half orc") @@ -63,4 +66,5 @@ class TestEncounter(TestCase): schlang = SchlangdedrosaMagentawrath() - battle = Encounter([]) \ No newline at end of file + battle = Encounter([char], [schlang]) + results = battle.simulate() \ No newline at end of file From 285312c0a4dbd5cd3ee527f8802fd116acc7f4dc Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sat, 22 May 2021 23:54:19 -0400 Subject: [PATCH 05/16] Start Conditions --- dungeonsheets/agent.py | 21 ++++++++++++++++++--- dungeonsheets/conditions/__init__.py | 0 dungeonsheets/conditions/conditions.py | 9 +++++++++ dungeonsheets/encounter/encounter.py | 15 +++++---------- 4 files changed, 32 insertions(+), 13 deletions(-) create mode 100644 dungeonsheets/conditions/__init__.py create mode 100644 dungeonsheets/conditions/conditions.py diff --git a/dungeonsheets/agent.py b/dungeonsheets/agent.py index c5ee799..b4fd79e 100644 --- a/dungeonsheets/agent.py +++ b/dungeonsheets/agent.py @@ -1,8 +1,10 @@ -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill +from dungeonsheets.conditions.conditions import Blinded, Charmed +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, CurrentInitiative, CurrentHP +from abc import ABC -class Agent: - """An actor in an encounter""" +class Agent(ABC): + """An actor in an encounter. Use Monster or Character, not this class directly!""" # General attributes name = "" @@ -51,6 +53,10 @@ class Agent: stealth = Skill(ability="dexterity") survival = Skill(ability="wisdom") + # Conditions + blinded = Blinded() + charmed = Charmed() + # TODO finish me! # Inventory cp = 0 @@ -74,9 +80,18 @@ class Agent: custom_features = list() feature_choices = list() + # Current Status: + initiative_roll = CurrentInitiative() + current_hp = CurrentHP() + statuses = list() + + # TODO: Pull in the monster class-variables here too + def __init__(self): pass + # TODO: Perhaps these are better stored like the skills are as objects with a __get__? + @property def actions(self): """All the things I can do in a turn""" diff --git a/dungeonsheets/conditions/__init__.py b/dungeonsheets/conditions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dungeonsheets/conditions/conditions.py b/dungeonsheets/conditions/conditions.py new file mode 100644 index 0000000..af7bd68 --- /dev/null +++ b/dungeonsheets/conditions/conditions.py @@ -0,0 +1,9 @@ +class Condition: + """A condition that can be held by an agent""" + pass + +class Blinded(Condition): + pass + +class Charmed(Condition): + pass \ No newline at end of file diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index e9ba29a..74c7d68 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -1,16 +1,10 @@ class Encounter: """A combat encounter between two parties -- good guys and bad guys""" - def __init__(self, *parties): - if len(parties) < 2: - raise ValueError("You need at least 2 parties to hold an encounter") - if len(parties) is not 2: - raise NotImplementedError("Haven't implemented AI for 3+ groups") # TODO: Implement this - - # Combine all parties into a single group - self.all_agents = parties[0] - for party in parties[1:]: - self.all_agents += party + def __init__(self, group_a, group_b): + self.group_a = group_a + self.group_b = group_b + self.all_agents = group_a + group_b def rating(self): raise NotImplementedError() # Deadly for Python :/ @@ -21,6 +15,7 @@ class Encounter: # Initiative for agent in self.all_agents: agent.roll_initiative() + self.all_agents = sorted(self.all_agents, key=lambda a: a.initiative_roll) raise NotImplementedError() # Apparently the mind flayers win for now From 1243f54823e20e9222c27602ca0a89e9d89c459f Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sun, 23 May 2021 00:33:48 -0400 Subject: [PATCH 06/16] Create Actions, Executable objects --- dungeonsheets/character.py | 4 +- dungeonsheets/encounter/actions.py | 61 ++++++++++++++++++++++++++ dungeonsheets/{ => encounter}/agent.py | 29 ++++++++++-- dungeonsheets/encounter/encounter.py | 1 - dungeonsheets/monsters.py | 2 +- dungeonsheets/stats.py | 20 --------- tests/test_encounter.py | 10 ++--- 7 files changed, 95 insertions(+), 32 deletions(-) create mode 100644 dungeonsheets/encounter/actions.py rename dungeonsheets/{ => encounter}/agent.py (81%) diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 3c91f21..aca6027 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -20,10 +20,10 @@ from dungeonsheets import ( spells, weapons, ) -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Skill, Speed, findattr +from dungeonsheets.stats import findattr from dungeonsheets.weapons import Weapon from dungeonsheets.readers import read_character_file -from dungeonsheets.agent import Agent +from dungeonsheets.encounter.agent import Agent def read(fname): diff --git a/dungeonsheets/encounter/actions.py b/dungeonsheets/encounter/actions.py new file mode 100644 index 0000000..143bc53 --- /dev/null +++ b/dungeonsheets/encounter/actions.py @@ -0,0 +1,61 @@ +from abc import ABC, abstractmethod + +class Event: + """An event between one and possibly more entities""" + + subj = None + obj = None + + def __init__(self, action, subj, obj): + self.action = action + self.subj = subj + self.obj = obj + + +class Executable(ABC): + """Something (like an action) that can be executed. + + Executing an action results in an event that is stored + """ + + @abstractmethod + def execute(self, subj, obj=None): + return Event(self, subj, obj) + + +class Action(Executable): + pass + + +class BonusAction(Executable): + pass + + +class Reaction(Executable): + pass + + +class Movement(Executable): + pass + + +class LairAction(Executable): + pass + + +class LegendaryAction(Executable): + pass + + +class Attack(Action): + + def __init__(self, subj, obj): + self.subj = subj + self.obj = obj + + def execute(self): + # Subject makes an attack roll + # Compare attack roll to object's AC + # Store the results to look into the event later + + pass # TODO: Write how to do this diff --git a/dungeonsheets/agent.py b/dungeonsheets/encounter/agent.py similarity index 81% rename from dungeonsheets/agent.py rename to dungeonsheets/encounter/agent.py index b4fd79e..5f5192a 100644 --- a/dungeonsheets/agent.py +++ b/dungeonsheets/encounter/agent.py @@ -1,6 +1,8 @@ from dungeonsheets.conditions.conditions import Blinded, Charmed -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, CurrentInitiative, CurrentHP +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, CurrentInitiative, CurrentHP, \ + NumericalInitiative from abc import ABC +from dungeonsheets.utils import roll class Agent(ABC): @@ -81,8 +83,9 @@ class Agent(ABC): feature_choices = list() # Current Status: - initiative_roll = CurrentInitiative() - current_hp = CurrentHP() + numerical_initiative = NumericalInitiative() + _initiative_roll = False + _current_hp = None statuses = list() # TODO: Pull in the monster class-variables here too @@ -90,6 +93,26 @@ class Agent(ABC): def __init__(self): pass + def roll_initiative(self): + init_mod, adv = self.numerical_initiative + val = roll(20) + if adv: + val = max(val, roll(20)) + self._initiative_roll = val + init_mod + + @property + def current_hp(self): + if self._current_hp is None: + self._current_hp = self.hp_max + return self._current_hp + + @property + def initiative_roll(self): + if self._initiative_roll is False: + self.roll_initiative() + return self._initiative_roll + + # TODO: Perhaps these are better stored like the skills are as objects with a __get__? @property diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index 74c7d68..e54d559 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -18,7 +18,6 @@ class Encounter: self.all_agents = sorted(self.all_agents, key=lambda a: a.initiative_roll) - raise NotImplementedError() # Apparently the mind flayers win for now def analyze(self): """So, really... how deadly *is* it?""" diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py index 0ff11e1..6af52f0 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -3,7 +3,7 @@ shape forms.""" from dungeonsheets.stats import Ability -from dungeonsheets.agent import Agent +from dungeonsheets.encounter.agent import Agent class Monster(Agent): diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index 7b570bb..8555ff4 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -240,23 +240,3 @@ class Initiative(NumericalInitiative): if has_advantage: ini += "(A)" return ini - - -########################### -# STATUS -########################### - -class CurrentHP: - """A Numerical Representation of Current HP""" - - def __get__(self, char, Character): - if not char.hp_max: - char.__set_hp_max() - return char.hp_max - - -class CurrentInitiative(NumericalInitiative): - """A Numerical Representation of a Character's Rolled Initiative""" - - def __get__(self, char, Character): - ini, has_advantage = super(CurrentInitiative, self).__get__(char, Character) \ No newline at end of file diff --git a/tests/test_encounter.py b/tests/test_encounter.py index 24fa6b9..9e5c6fa 100644 --- a/tests/test_encounter.py +++ b/tests/test_encounter.py @@ -12,7 +12,7 @@ class TestEncounter(TestCase): """Tests for features and feature-related activities.""" def test_simulation(self): - """Can I run an encounter against Schlangdedrosa Magentawrath?""" + """Can I run an encounter against Langdedrosa Cyanwrath?""" char = Character() char.set_attrs(name="Stravajiaxen") char.set_attrs(weapons=["greataxe"]) @@ -22,7 +22,7 @@ class TestEncounter(TestCase): char.set_attrs(race="half orc") char.set_attrs(inspiration=False) - class SchlangdedrosaMagentawrath(Monster): + class LangdedrosaCyanwrath(Monster): """ **Action Surge (Recharges on a Short or Long Rest).** On his turn, Langdedrosa can take one additional action. @@ -46,7 +46,7 @@ class TestEncounter(TestCase): **Climbing speed:** 30 ft. """ - name = "Schlangdedrosa Magentawrath" + name = "Langdedrosa Cyanwrath" description = "Medium humanoid (half-dragon), lawful evil" challenge_rating = 4 armor_class = 17 @@ -64,7 +64,7 @@ class TestEncounter(TestCase): hp_max = 57 hit_dice = "6d12+18" - schlang = SchlangdedrosaMagentawrath() + lang = LangdedrosaCyanwrath() - battle = Encounter([char], [schlang]) + battle = Encounter([char], [lang]) results = battle.simulate() \ No newline at end of file From 4bc844a6bf174005bbdb513e48026eb96759b23b Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sun, 23 May 2021 12:29:06 -0400 Subject: [PATCH 07/16] Filling in skeleton for encounter --- dungeonsheets/encounter/agent.py | 86 ++++++++++++++++++++++++---- dungeonsheets/encounter/encounter.py | 53 +++++++++++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/dungeonsheets/encounter/agent.py b/dungeonsheets/encounter/agent.py index 5f5192a..6081497 100644 --- a/dungeonsheets/encounter/agent.py +++ b/dungeonsheets/encounter/agent.py @@ -1,5 +1,6 @@ from dungeonsheets.conditions.conditions import Blinded, Charmed -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, CurrentInitiative, CurrentHP, \ +from dungeonsheets.encounter.actions import Attack +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, \ NumericalInitiative from abc import ABC from dungeonsheets.utils import roll @@ -91,7 +92,7 @@ class Agent(ABC): # TODO: Pull in the monster class-variables here too def __init__(self): - pass + self.long_rest() def roll_initiative(self): init_mod, adv = self.numerical_initiative @@ -112,40 +113,101 @@ class Agent(ABC): self.roll_initiative() return self._initiative_roll + def make_actions(self, encounter): + """Return a series of actions""" - # TODO: Perhaps these are better stored like the skills are as objects with a __get__? + # TODO: Dramatically improve logic, consider healing, consider encounter state, etc. + best_opponent = encounter.opponents(self)[0] # TODO: Choose opponent cleverly + action = Attack(self, best_opponent) + event = action.execute() + return [event] # TODO: Also allow bonus actions, etc. + + def long_rest(self): + self.current_hp = self.hp_max + self._free_actions = self._default_free_actions + # TODO: Support spell slots + self.new_turn() + + def new_turn(self): + self._actions = self._default_actions + self._bonus_actions = self._default_bonus_actions + self._reactions = self._default_reactions + self._legendary_actions = self._default_legendary_actions + self._lair_actions = self._default_lair_actions + + # TODO: Consider having a single list of actions and gain or lose them each + # turn based on their sub-type instead. @property - def actions(self): + def default_actions(self): """All the things I can do in a turn""" return [] - + @property - def free_actions(self): + def default_free_actions(self): """Stuff I can do as much as I want in a turn""" return [] @property - def movement(self): + def default_movement(self): """Where I can go in a turn""" return [] @property - def bonus_actions(self): + def default_bonus_actions(self): """Things I can do once in addition to an action""" return [] @property - def reactions(self): + def default_reactions(self): """Things I can do in response to an action""" return [] @property - def lair_actions(self): + def default_lair_actions(self): """Things I can do at initiative count 20""" return [] + @property + def default_legendary_actions(self): + """Things I can do only so many times in a turn after another agent acts""" + return [] + + @property + def default_long_rest_actions(self): + """Actions I gain back if I've had a long rest""" + + @property + def actions(self): + """All the remaining things I can do in a turn""" + return self._actions + + @property + def free_actions(self): + """Stuff I can do as much as I want in a turn""" + return self._free_actions + + @property + def movement(self): + """The rest of where I can go in a turn""" + return self._movement + + @property + def bonus_actions(self): + """The rest of the things I can do once in addition to an action""" + return self._bonus_actions + + @property + def reactions(self): + """The remaining things I can do in response to an action""" + return self._reactions + + @property + def lair_actions(self): + """Remaining things I can do at initiative count 20""" + return self._lair_actions + @property def legendary_actions(self): - """Things I can do so many times in a turn after another agent acts""" - return [] \ No newline at end of file + """Remaining things I can do only so many times in a turn after another agent acts""" + return self._legendary_actions \ No newline at end of file diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index e54d559..4e7ecf9 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -6,6 +6,26 @@ class Encounter: self.group_b = group_b self.all_agents = group_a + group_b + self._events = [] + + def opponents(self, agent): + """Who opposes the given agent in an encounter?""" + if agent in self.group_a: + return self.group_b + else: + return self.group_a + + def allies(self, agent): + """Who sides with the given agent in an encounter?""" + if agent in self.group_a: + return list(set(self.group_a) - set(agent)) + else: + return list(set(self.group_b) - set(agent)) + + def reset(self): + self._events = [] + self.long_rest() + def rating(self): raise NotImplementedError() # Deadly for Python :/ @@ -18,6 +38,39 @@ class Encounter: self.all_agents = sorted(self.all_agents, key=lambda a: a.initiative_roll) + # TODO: Support Lair Actions, cleverer loop + while not self.is_encounter_over(): + self.new_turn() + for agent in self.all_agents: + agent.make_actions(self) + if self.is_encounter_over(): + return self.events + + return self.events # Should never get here -- self.is_encounter_over() will end it + + raise NotImplementedError() # TODO: Finish the encounter + + def is_encounter_over(self): + """If all members of one party are at HP <= 0, it's over""" + return ( + all([agent.current_hp <= 0 for agent in self.group_a]) or + all([agent.current_hp <= 0 for agent in self.group_b]) + ) + + def long_rest(self): + """Resets all agents to have full actions, abilities, etc.""" + for agent in self.all_agents: + agent.long_rest() + + def new_turn(self): + """Resets turn-based actions for all agents""" + for agent in self.all_agents: + agent.new_turn() + + @property + def events(self): + """What series of events went down in the encounter?""" + return self._events def analyze(self): """So, really... how deadly *is* it?""" From 89143c5cbce785ca6a2eee69100e82e9a43bb782 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sun, 23 May 2021 14:52:18 -0400 Subject: [PATCH 08/16] Finish encounter, test, dice unittest, attack actions --- dungeonsheets/character.py | 1 + dungeonsheets/dice.py | 9 ++ dungeonsheets/encounter/actions.py | 18 +-- dungeonsheets/encounter/agent.py | 81 +++++-------- dungeonsheets/encounter/encounter.py | 14 +-- dungeonsheets/encounter/events.py | 28 +++++ dungeonsheets/monsters.py | 3 + dungeonsheets/utils.py | 6 - tests/test_dice.py | 20 +++ tests/test_encounter.py | 175 ++++++++++++++++++++++++++- 10 files changed, 262 insertions(+), 93 deletions(-) create mode 100644 dungeonsheets/encounter/events.py delete mode 100644 dungeonsheets/utils.py diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index aca6027..55702ee 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -197,6 +197,7 @@ class Character(Agent): character. """ + super(Character, self).__init__() self.clear() # make sure class, race, background are set first my_classes = classes diff --git a/dungeonsheets/dice.py b/dungeonsheets/dice.py index 8f62fcc..7abd567 100644 --- a/dungeonsheets/dice.py +++ b/dungeonsheets/dice.py @@ -1,3 +1,4 @@ +import random import re from collections import namedtuple @@ -22,3 +23,11 @@ def read_dice_str(dice_str): raise DiceError(f"Cannot interpret dice string {dice_str}") dice = Dice(num=int(match.group(1)), faces=int(match.group(2))) return dice + + +def roll(a, b=None): + """roll(20) means roll 1d20, roll(2, 6) means roll 2d6""" + if b is None: + return random.randint(1, a) + else: + return sum([random.randint(1, b) for _ in range(a)]) \ No newline at end of file diff --git a/dungeonsheets/encounter/actions.py b/dungeonsheets/encounter/actions.py index 143bc53..a09e20f 100644 --- a/dungeonsheets/encounter/actions.py +++ b/dungeonsheets/encounter/actions.py @@ -1,15 +1,6 @@ from abc import ABC, abstractmethod -class Event: - """An event between one and possibly more entities""" - - subj = None - obj = None - - def __init__(self, action, subj, obj): - self.action = action - self.subj = subj - self.obj = obj +from dungeonsheets.encounter.events import Event class Executable(ABC): @@ -52,10 +43,3 @@ class Attack(Action): def __init__(self, subj, obj): self.subj = subj self.obj = obj - - def execute(self): - # Subject makes an attack roll - # Compare attack roll to object's AC - # Store the results to look into the event later - - pass # TODO: Write how to do this diff --git a/dungeonsheets/encounter/agent.py b/dungeonsheets/encounter/agent.py index 6081497..85b0491 100644 --- a/dungeonsheets/encounter/agent.py +++ b/dungeonsheets/encounter/agent.py @@ -3,7 +3,7 @@ from dungeonsheets.encounter.actions import Attack from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, \ NumericalInitiative from abc import ABC -from dungeonsheets.utils import roll +from dungeonsheets.dice import roll class Agent(ABC): @@ -92,6 +92,11 @@ class Agent(ABC): # TODO: Pull in the monster class-variables here too def __init__(self): + self.default_actions = list() + self.default_bonus_actions = list() + self.default_reactions = list() + self.default_legendary_actions = list() + self.default_lair_actions = list() self.long_rest() def roll_initiative(self): @@ -107,6 +112,13 @@ class Agent(ABC): self._current_hp = self.hp_max return self._current_hp + @current_hp.setter + def current_hp(self, val): + if val < 0: + self._current_hp = 0 + else: + self._current_hp = val + @property def initiative_roll(self): if self._initiative_roll is False: @@ -116,76 +128,37 @@ class Agent(ABC): def make_actions(self, encounter): """Return a series of actions""" - # TODO: Dramatically improve logic, consider healing, consider encounter state, etc. + # TODO: Dramatically improve logic, consider healing, + # consider encounter state, consider strategy etc. best_opponent = encounter.opponents(self)[0] # TODO: Choose opponent cleverly - action = Attack(self, best_opponent) + action = self.actions[0](self, best_opponent) event = action.execute() + encounter.events.append(event) return [event] # TODO: Also allow bonus actions, etc. def long_rest(self): self.current_hp = self.hp_max - self._free_actions = self._default_free_actions # TODO: Support spell slots self.new_turn() def new_turn(self): - self._actions = self._default_actions - self._bonus_actions = self._default_bonus_actions - self._reactions = self._default_reactions - self._legendary_actions = self._default_legendary_actions - self._lair_actions = self._default_lair_actions + self._actions = self.default_actions + self._bonus_actions = self.default_bonus_actions + self._reactions = self.default_reactions + self._legendary_actions = self.default_legendary_actions + self._lair_actions = self.default_lair_actions + + def has_feature(self, *args, **kwargs): + return False # TODO: Save list of monster features as a list to check # TODO: Consider having a single list of actions and gain or lose them each - # turn based on their sub-type instead. - - @property - def default_actions(self): - """All the things I can do in a turn""" - return [] - - @property - def default_free_actions(self): - """Stuff I can do as much as I want in a turn""" - return [] - - @property - def default_movement(self): - """Where I can go in a turn""" - return [] - - @property - def default_bonus_actions(self): - """Things I can do once in addition to an action""" - return [] - - @property - def default_reactions(self): - """Things I can do in response to an action""" - return [] - - @property - def default_lair_actions(self): - """Things I can do at initiative count 20""" - return [] - - @property - def default_legendary_actions(self): - """Things I can do only so many times in a turn after another agent acts""" - return [] - - @property - def default_long_rest_actions(self): - """Actions I gain back if I've had a long rest""" + # turn based on interrogating their sub-type instead, using isinstance or + # another method. @property def actions(self): """All the remaining things I can do in a turn""" return self._actions - - @property - def free_actions(self): - """Stuff I can do as much as I want in a turn""" - return self._free_actions @property def movement(self): diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index 4e7ecf9..548b07e 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -6,7 +6,7 @@ class Encounter: self.group_b = group_b self.all_agents = group_a + group_b - self._events = [] + self.events = [] # Should be private? def opponents(self, agent): """Who opposes the given agent in an encounter?""" @@ -23,7 +23,7 @@ class Encounter: return list(set(self.group_b) - set(agent)) def reset(self): - self._events = [] + self.events = [] self.long_rest() def rating(self): @@ -48,8 +48,6 @@ class Encounter: return self.events # Should never get here -- self.is_encounter_over() will end it - raise NotImplementedError() # TODO: Finish the encounter - def is_encounter_over(self): """If all members of one party are at HP <= 0, it's over""" return ( @@ -67,11 +65,3 @@ class Encounter: for agent in self.all_agents: agent.new_turn() - @property - def events(self): - """What series of events went down in the encounter?""" - return self._events - - def analyze(self): - """So, really... how deadly *is* it?""" - raise NotImplementedError() # TODO: Run a Monte-Carlo simulation diff --git a/dungeonsheets/encounter/events.py b/dungeonsheets/encounter/events.py new file mode 100644 index 0000000..61f4c46 --- /dev/null +++ b/dungeonsheets/encounter/events.py @@ -0,0 +1,28 @@ +class Event: + """An event between one and possibly more entities""" + + def __init__(self, action, *args, **kwargs): + self.action = action + self.subj_hp = action.subj.current_hp + + + +class AttackEvent(Event): + """An attack action completed""" + + def __init__(self, action, result, damage, is_hit): + super(AttackEvent, self).__init__(action) + if hasattr(self.action, "obj"): + self.obj_hp = self.action.obj.current_hp + self.result = result + self.damage = damage + self.is_hit = is_hit + + def __str__(self): + + if self.is_hit: + return f"{self.action.subj.name} Hit! with a {self.result} for {self.damage} damage, leaving {self.action.obj.name} with {self.obj_hp} hitpoints" + else: + return f"{self.action.subj.name} Missed! with a {self.result}. {self.action.obj.name} has {self.obj_hp} hp remaining." + +# TODO: Support more events diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py index 6af52f0..2afc254 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -28,6 +28,9 @@ class Monster(Agent): hp_max = 10 hit_dice = "1d6" + def __init__(self): + super(Monster, self).__init__() + @property def is_beast(self): is_beast = "beast" in self.description.lower() diff --git a/dungeonsheets/utils.py b/dungeonsheets/utils.py deleted file mode 100644 index a8a73fb..0000000 --- a/dungeonsheets/utils.py +++ /dev/null @@ -1,6 +0,0 @@ -import random - - -def roll(d, n=1): - """roll(6, 2) means roll 2d6""" - return sum([random.randint(1, d) for _ in range(n)]) \ No newline at end of file diff --git a/tests/test_dice.py b/tests/test_dice.py index fc3c606..1bfda05 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -1,10 +1,12 @@ from unittest import TestCase +from dungeonsheets.dice import roll from dungeonsheets.exceptions import DiceError from dungeonsheets import dice class TestDice(TestCase): + def test_read_dice_str(self): out = dice.read_dice_str("1d6") self.assertEqual(out.faces, 6) @@ -16,3 +18,21 @@ class TestDice(TestCase): # Check a bad value with self.assertRaises(DiceError): dice.read_dice_str("Ed15") + + def test_simple_rolling(self): + num_tests = 100 + for _ in range(num_tests): + result = roll(6) + self.assertGreaterEqual(result, 1) + self.assertLessEqual(result, 6) + + def test_multi_rolling(self): + num_tests = 100 + rolls = [] + for _ in range(num_tests): + result = roll(2, 4) # Roll 2d4 + self.assertGreaterEqual(result, 2) + self.assertLessEqual(result, 8) + rolls.append(result) + print(rolls) + self.assertGreaterEqual(max(rolls), 7) # Must sometimes get a 7 or 8 after rolling 2d4 100 times diff --git a/tests/test_encounter.py b/tests/test_encounter.py index 9e5c6fa..761b71b 100644 --- a/tests/test_encounter.py +++ b/tests/test_encounter.py @@ -1,17 +1,72 @@ #!/usr/bin/env python -from unittest import TestCase +from unittest import TestCase, skip +from dungeonsheets.armor import ChainShirt from dungeonsheets.character import Character from dungeonsheets.encounter import Encounter -from dungeonsheets.monsters import Monster +from dungeonsheets.encounter.actions import Attack +from dungeonsheets.encounter.events import AttackEvent +from dungeonsheets.monsters import Monster, GiantRat from dungeonsheets.stats import Ability +from dungeonsheets.dice import roll class TestEncounter(TestCase): """Tests for features and feature-related activities.""" - def test_simulation(self): + def test_simple_rat_fight(self): + """A fight against a giant rat""" + char = SimpleRanger() + + class StravajiaxenAttack(Attack): + # TODO: Get default player attacks as a result of + # creating a player character and classes + + def __init__(self, subj, obj): + self.subj = subj + self.obj = obj + + def execute(self): + result = roll(20) + self.subj.dexterity.modifier + self.subj.proficiency_bonus + damage = roll(1, 8) + self.subj.dexterity.modifier + is_hit = result >= self.obj.armor_class + + if is_hit: + self.obj.current_hp -= damage + + return AttackEvent(self, result, damage, is_hit) + + char.default_actions.append(StravajiaxenAttack) + + class GiantRatAttack(Attack): + + def __init__(self, subj, obj): + self.subj = subj + self.obj = obj + + def execute(self): + result = roll(20) + 4 + damage = roll(1, 4) + 2 + is_hit = result >= self.obj.armor_class + + if is_hit: + self.obj.current_hp -= damage + + return AttackEvent(self, result, damage, is_hit) + + enemy = GiantRat() + enemy.name = "Nameless Rat" # I don't want things to be personal... + enemy.default_actions.append(GiantRatAttack) + + battle = Encounter([char], [enemy]) + results = battle.simulate() + for event in results: + print(str(event)) + print(results) + + @skip('NotImplementedError') + def test_langdedrosa_fight(self): """Can I run an encounter against Langdedrosa Cyanwrath?""" char = Character() char.set_attrs(name="Stravajiaxen") @@ -67,4 +122,116 @@ class TestEncounter(TestCase): lang = LangdedrosaCyanwrath() battle = Encounter([char], [lang]) - results = battle.simulate() \ No newline at end of file + results = battle.simulate() + + +class SimpleRanger(Character): # Taken from ranger2.py + """This file describes the heroic adventurer Ranger2. + + It's used primarily for saving characters from create-character, + where there will be many missing sections. + + Modify this file as you level up and then re-generate the character + sheet by running ``makesheets`` from the command line. + + """ + + dungeonsheets_version = "0.9.4" + + name = "Stravajiaxen" + player_name = "Ben" + + # Be sure to list Primary class first + classes = ['Ranger'] # ex: ['Wizard'] or ['Rogue', 'Fighter'] + levels = [3] # ex: [10] or [3, 2] + subclasses = ["Horizon Walker"] # ex: ['Necromacy'] or ['Thief', None] + background = "Uthgardt Tribe Member" + race = "Lizardfolk" + alignment = "Neutral good" + + xp = 0 + hp_max = 24 + inspiration = 0 # integer inspiration value + + # Ability Scores + strength = Ability(13) + dexterity = Ability(15) + constitution = Ability(12) + intelligence = Ability(8) + wisdom = Ability(15) + charisma = Ability(12) + + # Select what skills you're proficient with + # ex: skill_proficiencies = ('athletics', 'acrobatics', 'arcana') + skill_proficiencies = ('athletics', 'insight', 'investigation') + + # Any skills you have "expertise" (Bard/Rogue) in + skill_expertise = () + + # Named features / feats that aren't part of your classes, race, or background. + # Also include Eldritch Invocations and features you make multiple selection of + # (like Maneuvers for Fighter, Metamagic for Sorcerors, Trick Shots for + # Gunslinger, etc.) + # Example: + # features = ('Tavern Brawler',) # take the optional Feat from PHB + features = () + + # If selecting among multiple feature options: ex Fighting Style + # Example (Fighting Style): + # feature_choices = ('Archery',) + feature_choices = ('dueling',) + + # Weapons/other proficiencies not given by class/race/background + weapon_proficiencies = () # ex: ('shortsword', 'quarterstaff') + _proficiencies_text = () # ex: ("thieves' tools",) + + # Proficiencies and languages + languages = """Dwarvish, Common, Draconic""" + + # Inventory + # TODO: Get yourself some money + cp = 0 + sp = 0 + ep = 0 + gp = 0 + pp = 0 + + # TODO: Put your equipped weapons and armor here + weapons = ('rapier', 'hand crossbow') # Example: ('shortsword', 'longsword') + magic_items = () # Example: ('ring of protection',) + armor = ChainShirt # Eg "leather armor" + shield = "" # Eg "shield" + + equipment = """TODO: list the equipment and magic items your character carries""" + + attacks_and_spellcasting = """TODO: Describe how your character usually attacks + or uses spells.""" + + # List of known spells + # Example: spells_prepared = ('magic missile', 'mage armor') + spells_prepared = () # Todo: Learn some spells + + # Which spells have not been prepared + __spells_unprepared = () + + # all spells known + spells = spells_prepared + __spells_unprepared + + # Wild shapes for Druid + wild_shapes = () # Ex: ('ape', 'wolf', 'ankylosaurus') + + # Backstory + # Describe your backstory here + personality_traits = """TODO: How does your character behave? See the PHB for + examples of all the sections below""" + + ideals = """TODO: What does your character believe in?""" + + bonds = """TODO: Describe what debts your character has to pay, + and other commitments or ongoing quests they have.""" + + flaws = """TODO: Describe your characters interesting flaws. + """ + + features_and_traits = """TODO: Describe other features and abilities your + character has.""" From 0c242681188099f9c20295cd50e0f3fbe5a52e64 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sun, 23 May 2021 15:52:50 -0400 Subject: [PATCH 09/16] Fix CI -- remove long rest on Agent initialization --- dungeonsheets/encounter/agent.py | 1 - tests/test_encounter.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/dungeonsheets/encounter/agent.py b/dungeonsheets/encounter/agent.py index 85b0491..cc6bc6a 100644 --- a/dungeonsheets/encounter/agent.py +++ b/dungeonsheets/encounter/agent.py @@ -97,7 +97,6 @@ class Agent(ABC): self.default_reactions = list() self.default_legendary_actions = list() self.default_lair_actions = list() - self.long_rest() def roll_initiative(self): init_mod, adv = self.numerical_initiative diff --git a/tests/test_encounter.py b/tests/test_encounter.py index 761b71b..eac26d9 100644 --- a/tests/test_encounter.py +++ b/tests/test_encounter.py @@ -60,6 +60,7 @@ class TestEncounter(TestCase): enemy.default_actions.append(GiantRatAttack) battle = Encounter([char], [enemy]) + battle.reset() results = battle.simulate() for event in results: print(str(event)) From f6baaba7ba4a87448966d236c68b34835e6e56de Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Sun, 23 May 2021 16:18:48 -0400 Subject: [PATCH 10/16] Address Coverage --- dungeonsheets/encounter/actions.py | 2 +- dungeonsheets/encounter/encounter.py | 11 +++++----- tests/test_encounter.py | 30 +++++++++++++++++++++------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/dungeonsheets/encounter/actions.py b/dungeonsheets/encounter/actions.py index a09e20f..a0da3a3 100644 --- a/dungeonsheets/encounter/actions.py +++ b/dungeonsheets/encounter/actions.py @@ -11,7 +11,7 @@ class Executable(ABC): @abstractmethod def execute(self, subj, obj=None): - return Event(self, subj, obj) + """Execute the given action""" class Action(Executable): diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py index 548b07e..142e059 100644 --- a/dungeonsheets/encounter/encounter.py +++ b/dungeonsheets/encounter/encounter.py @@ -18,17 +18,14 @@ class Encounter: def allies(self, agent): """Who sides with the given agent in an encounter?""" if agent in self.group_a: - return list(set(self.group_a) - set(agent)) + return list(set(self.group_a) - set([agent])) else: - return list(set(self.group_b) - set(agent)) + return list(set(self.group_b) - set([agent])) def reset(self): self.events = [] self.long_rest() - def rating(self): - raise NotImplementedError() # Deadly for Python :/ - def simulate(self): """Who will win?""" @@ -48,6 +45,10 @@ class Encounter: return self.events # Should never get here -- self.is_encounter_over() will end it + def rating(self): + """Encounter Rating""" + raise NotImplementedError() + def is_encounter_over(self): """If all members of one party are at HP <= 0, it's over""" return ( diff --git a/tests/test_encounter.py b/tests/test_encounter.py index eac26d9..b8d98ac 100644 --- a/tests/test_encounter.py +++ b/tests/test_encounter.py @@ -15,8 +15,8 @@ from dungeonsheets.dice import roll class TestEncounter(TestCase): """Tests for features and feature-related activities.""" - def test_simple_rat_fight(self): - """A fight against a giant rat""" + def setUp(self): + char = SimpleRanger() class StravajiaxenAttack(Attack): @@ -24,8 +24,7 @@ class TestEncounter(TestCase): # creating a player character and classes def __init__(self, subj, obj): - self.subj = subj - self.obj = obj + super(StravajiaxenAttack, self).__init__(subj, obj) def execute(self): result = roll(20) + self.subj.dexterity.modifier + self.subj.proficiency_bonus @@ -42,8 +41,7 @@ class TestEncounter(TestCase): class GiantRatAttack(Attack): def __init__(self, subj, obj): - self.subj = subj - self.obj = obj + super(GiantRatAttack, self).__init__(subj, obj) def execute(self): result = roll(20) + 4 @@ -58,8 +56,26 @@ class TestEncounter(TestCase): enemy = GiantRat() enemy.name = "Nameless Rat" # I don't want things to be personal... enemy.default_actions.append(GiantRatAttack) + self.default_player = char + self.default_enemy = enemy - battle = Encounter([char], [enemy]) + def test_encounter_rating(self): + battle = Encounter([self.default_player], [self.default_enemy]) + self.assertRaises(NotImplementedError, battle.rating) + + def test_opponents(self): + battle = Encounter([self.default_player], [self.default_enemy]) + self.assertEqual([self.default_enemy], battle.opponents(self.default_player)) + self.assertEqual([self.default_player], battle.opponents(self.default_enemy)) + + def test_allies(self): + battle = Encounter([self.default_player], [self.default_enemy]) + self.assertEqual(0, len(battle.allies(self.default_player))) + self.assertEqual(0, len(battle.allies(self.default_enemy))) + + def test_simple_rat_fight(self): + """A fight against a giant rat""" + battle = Encounter([self.default_player], [self.default_enemy]) battle.reset() results = battle.simulate() for event in results: From 601bd0c9aabff9be277c90e685f98a45172f4ae6 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Mon, 24 May 2021 10:34:43 -0400 Subject: [PATCH 11/16] Remove stochastic unittest D: --- tests/test_dice.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_dice.py b/tests/test_dice.py index 1bfda05..17f494e 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -28,11 +28,7 @@ class TestDice(TestCase): def test_multi_rolling(self): num_tests = 100 - rolls = [] for _ in range(num_tests): result = roll(2, 4) # Roll 2d4 self.assertGreaterEqual(result, 2) self.assertLessEqual(result, 8) - rolls.append(result) - print(rolls) - self.assertGreaterEqual(max(rolls), 7) # Must sometimes get a 7 or 8 after rolling 2d4 100 times From ddb9d44354b0d61671829cb52965cd27368fb92e Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Mon, 24 May 2021 20:38:50 -0400 Subject: [PATCH 12/16] Remove encounters, refactor Actor into Entity, move more core capabilities into Entity --- dungeonsheets/character.py | 23 +-- dungeonsheets/conditions/__init__.py | 0 dungeonsheets/conditions/conditions.py | 9 - dungeonsheets/encounter/__init__.py | 1 - dungeonsheets/encounter/actions.py | 45 ----- dungeonsheets/encounter/agent.py | 185 ------------------ dungeonsheets/encounter/encounter.py | 68 ------- dungeonsheets/encounter/events.py | 28 --- dungeonsheets/entity.py | 87 +++++++++ dungeonsheets/monsters.py | 16 +- dungeonsheets/stats.py | 120 ++++++------ tests/test_character.py | 6 +- tests/test_encounter.py | 254 ------------------------- tests/test_multiclass.py | 14 +- 14 files changed, 165 insertions(+), 691 deletions(-) delete mode 100644 dungeonsheets/conditions/__init__.py delete mode 100644 dungeonsheets/conditions/conditions.py delete mode 100644 dungeonsheets/encounter/__init__.py delete mode 100644 dungeonsheets/encounter/actions.py delete mode 100644 dungeonsheets/encounter/agent.py delete mode 100644 dungeonsheets/encounter/encounter.py delete mode 100644 dungeonsheets/encounter/events.py create mode 100644 dungeonsheets/entity.py delete mode 100644 tests/test_encounter.py diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index 55702ee..ef42477 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -23,14 +23,7 @@ from dungeonsheets import ( from dungeonsheets.stats import findattr from dungeonsheets.weapons import Weapon from dungeonsheets.readers import read_character_file -from dungeonsheets.encounter.agent import Agent - - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - - -__version__ = read("../VERSION").strip() +from dungeonsheets.entity import Entity dice_re = re.compile(r"(\d+)d(\d+)") @@ -146,17 +139,15 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None): return Mechanic -class Character(Agent): +class Character(Entity): """A generic player character.""" + # Character-specific player_name = "" - - dungeonsheets_version = __version__ xp = 0 inspiration = False attacks_and_spellcasting = "" class_list = list() - _race = None _background = None # Characteristics @@ -172,7 +163,7 @@ class Character(Agent): def __init__( self, - classes: Sequence = [], + class_list: Sequence = [], levels: Sequence[int] = [], subclasses: Sequence = [], **attrs, @@ -184,7 +175,7 @@ class Character(Agent): Parameters ========== - classes + class_list Strings with class names, or character class definitions representing the characters various D&D classes. levels @@ -200,7 +191,7 @@ class Character(Agent): super(Character, self).__init__() self.clear() # make sure class, race, background are set first - my_classes = classes + my_classes = class_list my_levels = levels my_subclasses = subclasses # backwards compatability @@ -931,7 +922,7 @@ class Character(Agent): make_sheet(filename, character=self, flatten=kwargs.get("flatten", True)) -# Add backwards compatability for tests +# Add backwards compatibility for tests class Artificer(Character): def __init__(self, level=1, **attrs): attrs["classes"] = ["Artificer"] diff --git a/dungeonsheets/conditions/__init__.py b/dungeonsheets/conditions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dungeonsheets/conditions/conditions.py b/dungeonsheets/conditions/conditions.py deleted file mode 100644 index af7bd68..0000000 --- a/dungeonsheets/conditions/conditions.py +++ /dev/null @@ -1,9 +0,0 @@ -class Condition: - """A condition that can be held by an agent""" - pass - -class Blinded(Condition): - pass - -class Charmed(Condition): - pass \ No newline at end of file diff --git a/dungeonsheets/encounter/__init__.py b/dungeonsheets/encounter/__init__.py deleted file mode 100644 index 2433ab5..0000000 --- a/dungeonsheets/encounter/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from dungeonsheets.encounter.encounter import Encounter \ No newline at end of file diff --git a/dungeonsheets/encounter/actions.py b/dungeonsheets/encounter/actions.py deleted file mode 100644 index a0da3a3..0000000 --- a/dungeonsheets/encounter/actions.py +++ /dev/null @@ -1,45 +0,0 @@ -from abc import ABC, abstractmethod - -from dungeonsheets.encounter.events import Event - - -class Executable(ABC): - """Something (like an action) that can be executed. - - Executing an action results in an event that is stored - """ - - @abstractmethod - def execute(self, subj, obj=None): - """Execute the given action""" - - -class Action(Executable): - pass - - -class BonusAction(Executable): - pass - - -class Reaction(Executable): - pass - - -class Movement(Executable): - pass - - -class LairAction(Executable): - pass - - -class LegendaryAction(Executable): - pass - - -class Attack(Action): - - def __init__(self, subj, obj): - self.subj = subj - self.obj = obj diff --git a/dungeonsheets/encounter/agent.py b/dungeonsheets/encounter/agent.py deleted file mode 100644 index cc6bc6a..0000000 --- a/dungeonsheets/encounter/agent.py +++ /dev/null @@ -1,185 +0,0 @@ -from dungeonsheets.conditions.conditions import Blinded, Charmed -from dungeonsheets.encounter.actions import Attack -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, \ - NumericalInitiative -from abc import ABC -from dungeonsheets.dice import roll - - -class Agent(ABC): - """An actor in an encounter. Use Monster or Character, not this class directly!""" - - # General attributes - name = "" - alignment = "Neutral" - - # 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 = "" - - # 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") - - # Conditions - blinded = Blinded() - charmed = Charmed() - # TODO finish me! - - # 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() - - # Current Status: - numerical_initiative = NumericalInitiative() - _initiative_roll = False - _current_hp = None - statuses = list() - - # TODO: Pull in the monster class-variables here too - - def __init__(self): - self.default_actions = list() - self.default_bonus_actions = list() - self.default_reactions = list() - self.default_legendary_actions = list() - self.default_lair_actions = list() - - def roll_initiative(self): - init_mod, adv = self.numerical_initiative - val = roll(20) - if adv: - val = max(val, roll(20)) - self._initiative_roll = val + init_mod - - @property - def current_hp(self): - if self._current_hp is None: - self._current_hp = self.hp_max - return self._current_hp - - @current_hp.setter - def current_hp(self, val): - if val < 0: - self._current_hp = 0 - else: - self._current_hp = val - - @property - def initiative_roll(self): - if self._initiative_roll is False: - self.roll_initiative() - return self._initiative_roll - - def make_actions(self, encounter): - """Return a series of actions""" - - # TODO: Dramatically improve logic, consider healing, - # consider encounter state, consider strategy etc. - best_opponent = encounter.opponents(self)[0] # TODO: Choose opponent cleverly - action = self.actions[0](self, best_opponent) - event = action.execute() - encounter.events.append(event) - return [event] # TODO: Also allow bonus actions, etc. - - def long_rest(self): - self.current_hp = self.hp_max - # TODO: Support spell slots - self.new_turn() - - def new_turn(self): - self._actions = self.default_actions - self._bonus_actions = self.default_bonus_actions - self._reactions = self.default_reactions - self._legendary_actions = self.default_legendary_actions - self._lair_actions = self.default_lair_actions - - def has_feature(self, *args, **kwargs): - return False # TODO: Save list of monster features as a list to check - - # TODO: Consider having a single list of actions and gain or lose them each - # turn based on interrogating their sub-type instead, using isinstance or - # another method. - - @property - def actions(self): - """All the remaining things I can do in a turn""" - return self._actions - - @property - def movement(self): - """The rest of where I can go in a turn""" - return self._movement - - @property - def bonus_actions(self): - """The rest of the things I can do once in addition to an action""" - return self._bonus_actions - - @property - def reactions(self): - """The remaining things I can do in response to an action""" - return self._reactions - - @property - def lair_actions(self): - """Remaining things I can do at initiative count 20""" - return self._lair_actions - - @property - def legendary_actions(self): - """Remaining things I can do only so many times in a turn after another agent acts""" - return self._legendary_actions \ No newline at end of file diff --git a/dungeonsheets/encounter/encounter.py b/dungeonsheets/encounter/encounter.py deleted file mode 100644 index 142e059..0000000 --- a/dungeonsheets/encounter/encounter.py +++ /dev/null @@ -1,68 +0,0 @@ -class Encounter: - """A combat encounter between two parties -- good guys and bad guys""" - - def __init__(self, group_a, group_b): - self.group_a = group_a - self.group_b = group_b - self.all_agents = group_a + group_b - - self.events = [] # Should be private? - - def opponents(self, agent): - """Who opposes the given agent in an encounter?""" - if agent in self.group_a: - return self.group_b - else: - return self.group_a - - def allies(self, agent): - """Who sides with the given agent in an encounter?""" - if agent in self.group_a: - return list(set(self.group_a) - set([agent])) - else: - return list(set(self.group_b) - set([agent])) - - def reset(self): - self.events = [] - self.long_rest() - - def simulate(self): - """Who will win?""" - - # Initiative - for agent in self.all_agents: - agent.roll_initiative() - - self.all_agents = sorted(self.all_agents, key=lambda a: a.initiative_roll) - - # TODO: Support Lair Actions, cleverer loop - while not self.is_encounter_over(): - self.new_turn() - for agent in self.all_agents: - agent.make_actions(self) - if self.is_encounter_over(): - return self.events - - return self.events # Should never get here -- self.is_encounter_over() will end it - - def rating(self): - """Encounter Rating""" - raise NotImplementedError() - - def is_encounter_over(self): - """If all members of one party are at HP <= 0, it's over""" - return ( - all([agent.current_hp <= 0 for agent in self.group_a]) or - all([agent.current_hp <= 0 for agent in self.group_b]) - ) - - def long_rest(self): - """Resets all agents to have full actions, abilities, etc.""" - for agent in self.all_agents: - agent.long_rest() - - def new_turn(self): - """Resets turn-based actions for all agents""" - for agent in self.all_agents: - agent.new_turn() - diff --git a/dungeonsheets/encounter/events.py b/dungeonsheets/encounter/events.py deleted file mode 100644 index 61f4c46..0000000 --- a/dungeonsheets/encounter/events.py +++ /dev/null @@ -1,28 +0,0 @@ -class Event: - """An event between one and possibly more entities""" - - def __init__(self, action, *args, **kwargs): - self.action = action - self.subj_hp = action.subj.current_hp - - - -class AttackEvent(Event): - """An attack action completed""" - - def __init__(self, action, result, damage, is_hit): - super(AttackEvent, self).__init__(action) - if hasattr(self.action, "obj"): - self.obj_hp = self.action.obj.current_hp - self.result = result - self.damage = damage - self.is_hit = is_hit - - def __str__(self): - - if self.is_hit: - return f"{self.action.subj.name} Hit! with a {self.result} for {self.damage} damage, leaving {self.action.obj.name} with {self.obj_hp} hitpoints" - else: - return f"{self.action.subj.name} Missed! with a {self.result}. {self.action.obj.name} has {self.obj_hp} hp remaining." - -# TODO: Support more events diff --git a/dungeonsheets/entity.py b/dungeonsheets/entity.py new file mode 100644 index 0000000..3d270f8 --- /dev/null +++ b/dungeonsheets/entity.py @@ -0,0 +1,87 @@ +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, +from abc import ABC +import os + +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 diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py index 2afc254..594b422 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -3,27 +3,17 @@ shape forms.""" from dungeonsheets.stats import Ability -from dungeonsheets.encounter.agent import Agent +from dungeonsheets.entity import Entity -class Monster(Agent): +class Monster(Entity): """A monster that may be encountered when adventuring.""" name = "Generic Monster" description = "" challenge_rating = 0 - armor_class = 0 skills = "Perception +3, Stealth +4" - senses = "" - languages = "" - strength = Ability() - dexterity = Ability() - constitution = Ability() - intelligence = Ability() - wisdom = Ability() - charisma = Ability() - speed = 30 - swim_speed = 0 + swim_speed = 0 # TODO: Consider refactoring stats.Speed to consider all of these just like we do stats.Ability fly_speed = 0 hp_max = 10 hit_dice = "1d6" diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index 8555ff4..498d330 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -64,10 +64,6 @@ def findattr(obj, name): def mod_str(modifier): """Converts a modifier to a string, eg 2 -> '+2'.""" return "{:+d}".format(modifier) - if modifier == 0: - return str(modifier) - else: - return "{:+}".format(modifier) AbilityScore = namedtuple("AbilityScore", ("value", "modifier", "saving_throw")) @@ -90,25 +86,25 @@ class Ability: # ability score dictionary exists but doesn't have this ability obj._ability_scores[self.ability_name] = self.default_value - def __get__(self, character, Character): - self._check_dict(character) - score = character._ability_scores[self.ability_name] + def __get__(self, entity, Entity): + self._check_dict(entity) + score = entity._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( - character, "saving_throw_proficiencies" + entity, "saving_throw_proficiencies" ): - is_proficient = self.ability_name in character.saving_throw_proficiencies + is_proficient = self.ability_name in entity.saving_throw_proficiencies if is_proficient: - saving_throw += character.proficiency_bonus + saving_throw += entity.proficiency_bonus # Create the named tuple value = AbilityScore(modifier=modifier, value=score, saving_throw=saving_throw) return value - def __set__(self, character, val): - self._check_dict(character) - character._ability_scores[self.ability_name] = val + def __set__(self, entity, val): + self._check_dict(entity) + entity._ability_scores[self.ability_name] = val self.value = val @@ -118,27 +114,27 @@ class Skill: def __init__(self, ability): self.ability_name = ability - def __set_name__(self, character, name): + def __set_name__(self, entity, name): self.skill_name = name.lower().replace("_", " ") - self.character = character + self.character = entity - def __get__(self, character, owner): - ability = getattr(character, self.ability_name) + def __get__(self, entity, owner): + ability = getattr(entity, self.ability_name) modifier = ability.modifier # Check for proficiency - is_proficient = self.skill_name in character.skill_proficiencies + is_proficient = self.skill_name in entity.skill_proficiencies if is_proficient: - modifier += character.proficiency_bonus - elif character.has_feature(JackOfAllTrades): - modifier += character.proficiency_bonus // 2 - elif character.has_feature(RemarkableAthelete): + modifier += entity.proficiency_bonus + elif entity.has_feature(JackOfAllTrades): + modifier += entity.proficiency_bonus // 2 + elif entity.has_feature(RemarkableAthelete): if self.ability_name.lower() in ("strength", "dexterity", "constitution"): - modifier += ceil(character.proficienc_bonus / 2.0) + modifier += ceil(entity.proficienc_bonus / 2.0) # Check for expertise - is_expert = self.skill_name in character.skill_expertise + is_expert = self.skill_name in entity.skill_expertise if is_expert: - modifier += character.proficiency_bonus + modifier += entity.proficiency_bonus return modifier @@ -147,36 +143,36 @@ class ArmorClass: The Armor Class of a character """ - def __get__(self, char, Character): - armor = char.armor or NoArmor() + def __get__(self, entity, Entity): + armor = entity.armor or NoArmor() ac = armor.base_armor_class # calculate and apply modifiers if armor.dexterity_mod_max is None: - ac += char.dexterity.modifier + ac += entity.dexterity.modifier else: - ac += min(char.dexterity.modifier, armor.dexterity_mod_max) - if char.has_feature(NaturalArmor): - ac = max(ac, 13 + char.dexterity.modifier) - shield = char.shield or NoShield() + 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 += shield.base_armor_class # Compute feature-specific additions - if char.has_feature(UnarmoredDefenseMonk): + if entity.has_feature(UnarmoredDefenseMonk): if isinstance(armor, NoArmor) and isinstance(shield, NoShield): - ac += char.wisdom.modifier - if char.has_feature(UnarmoredDefenseBarbarian): + ac += entity.wisdom.modifier + if entity.has_feature(UnarmoredDefenseBarbarian): if isinstance(armor, NoArmor): - ac += char.constitution.modifier - if char.has_feature(DraconicResilience): + ac += entity.constitution.modifier + if entity.has_feature(DraconicResilience): if isinstance(armor, NoArmor): ac += 3 - if char.has_feature(Defense): + if entity.has_feature(Defense): if not isinstance(armor, NoArmor): ac += 1 - if char.has_feature(SoulOfTheForge): + if entity.has_feature(SoulOfTheForge): if isinstance(armor, HeavyArmor): ac += 1 # Check if any magic items add to AC - for mitem in char.magic_items: + for mitem in entity.magic_items: if hasattr(mitem, "ac_bonus"): ac += mitem.ac_bonus return ac @@ -187,25 +183,25 @@ class Speed: The speed of a character """ - def __get__(self, char, Character): - speed = char.race.speed + def __get__(self, entity, Entity): + speed = entity.race.speed other_speed = "" if isinstance(speed, str): other_speed = speed[2:] speed = int(speed[:2]) # ignore other speeds, like fly - if char.has_feature(FastMovement): - if not isinstance(char.armor, HeavyArmor): + if entity.has_feature(FastMovement): + if not isinstance(entity.armor, HeavyArmor): speed += 10 - if char.has_feature(SuperiorMobility): + if entity.has_feature(SuperiorMobility): speed += 10 - if isinstance(char.armor, NoArmor) or (char.armor is None): - for f in char.features: + if isinstance(entity.armor, NoArmor) or (entity.armor is None): + for f in entity.features: if isinstance(f, UnarmoredMovement): speed += f.speed_bonus - if char.has_feature(GiftOfTheDepths): + if entity.has_feature(GiftOfTheDepths): if "swim" not in other_speed: other_speed += " ({:d} swim)".format(speed) - if char.has_feature(SeaSoul): + if entity.has_feature(SeaSoul): if "swim" not in other_speed: other_speed += " (30 swim)" return "{:d}{:s}".format(speed, other_speed) @@ -214,19 +210,19 @@ class Speed: class NumericalInitiative: """A numerical representation of initiative""" - def __get__(self, char, Character): - ini = char.dexterity.modifier - if char.has_feature(QuickDraw): - ini += char.proficiency_bonus - if char.has_feature(DreadAmbusher): - ini += char.wisdom.modifier - if char.has_feature(RakishAudacity): - ini += char.charisma.modifier + 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 has_advantage = ( - char.has_feature(NaturalExplorerRevised) - or char.has_feature(FeralInstinct) - or char.has_feature(AmbushMaster) + entity.has_feature(NaturalExplorerRevised) + or entity.has_feature(FeralInstinct) + or entity.has_feature(AmbushMaster) ) return ini, has_advantage @@ -234,8 +230,8 @@ class NumericalInitiative: class Initiative(NumericalInitiative): """A character's initiative""" - def __get__(self, char, Character): - ini, has_advantage = super(Initiative, self).__get__(char, Character) + def __get__(self, entity, Entity): + ini, has_advantage = super(Initiative, self).__get__(entity, Entity) ini = "{:+d}".format(ini) if has_advantage: ini += "(A)" diff --git a/tests/test_character.py b/tests/test_character.py index 1dc4440..8f42a47 100755 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -64,7 +64,7 @@ class TestCharacter(TestCase): self.assertEqual(char.spells[0].name, "my spell!") def test_homebrew_infusions(self): - char = Character(classes="artificer") + char = Character(class_list="artificer") class MyInfusion(infusions.Infusion): name = "my infusion!" @@ -74,7 +74,7 @@ class TestCharacter(TestCase): self.assertIsInstance(char.infusions[0], infusions.Infusion) self.assertEqual(char.infusions[0].name, "my infusion!") # Pass a previously undefined infusion - char = Character(classes="artificer") + char = Character(class_list="artificer") char.set_attrs(infusions=("spam_infusion",)) self.assertIsInstance(char.infusions[0], infusions.Infusion) self.assertEqual(char.infusions[0].name, "Spam Infusion") @@ -129,7 +129,7 @@ class TestCharacter(TestCase): self.assertEqual(repr(char), "") def test_is_proficient(self): - char = Character(classes=["Wizard"]) + char = Character(class_list=["Wizard"]) char.weapon_proficiencies sword = Shortsword() # Check for not-proficient weapon diff --git a/tests/test_encounter.py b/tests/test_encounter.py deleted file mode 100644 index b8d98ac..0000000 --- a/tests/test_encounter.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python - -from unittest import TestCase, skip - -from dungeonsheets.armor import ChainShirt -from dungeonsheets.character import Character -from dungeonsheets.encounter import Encounter -from dungeonsheets.encounter.actions import Attack -from dungeonsheets.encounter.events import AttackEvent -from dungeonsheets.monsters import Monster, GiantRat -from dungeonsheets.stats import Ability -from dungeonsheets.dice import roll - - -class TestEncounter(TestCase): - """Tests for features and feature-related activities.""" - - def setUp(self): - - char = SimpleRanger() - - class StravajiaxenAttack(Attack): - # TODO: Get default player attacks as a result of - # creating a player character and classes - - def __init__(self, subj, obj): - super(StravajiaxenAttack, self).__init__(subj, obj) - - def execute(self): - result = roll(20) + self.subj.dexterity.modifier + self.subj.proficiency_bonus - damage = roll(1, 8) + self.subj.dexterity.modifier - is_hit = result >= self.obj.armor_class - - if is_hit: - self.obj.current_hp -= damage - - return AttackEvent(self, result, damage, is_hit) - - char.default_actions.append(StravajiaxenAttack) - - class GiantRatAttack(Attack): - - def __init__(self, subj, obj): - super(GiantRatAttack, self).__init__(subj, obj) - - def execute(self): - result = roll(20) + 4 - damage = roll(1, 4) + 2 - is_hit = result >= self.obj.armor_class - - if is_hit: - self.obj.current_hp -= damage - - return AttackEvent(self, result, damage, is_hit) - - enemy = GiantRat() - enemy.name = "Nameless Rat" # I don't want things to be personal... - enemy.default_actions.append(GiantRatAttack) - self.default_player = char - self.default_enemy = enemy - - def test_encounter_rating(self): - battle = Encounter([self.default_player], [self.default_enemy]) - self.assertRaises(NotImplementedError, battle.rating) - - def test_opponents(self): - battle = Encounter([self.default_player], [self.default_enemy]) - self.assertEqual([self.default_enemy], battle.opponents(self.default_player)) - self.assertEqual([self.default_player], battle.opponents(self.default_enemy)) - - def test_allies(self): - battle = Encounter([self.default_player], [self.default_enemy]) - self.assertEqual(0, len(battle.allies(self.default_player))) - self.assertEqual(0, len(battle.allies(self.default_enemy))) - - def test_simple_rat_fight(self): - """A fight against a giant rat""" - battle = Encounter([self.default_player], [self.default_enemy]) - battle.reset() - results = battle.simulate() - for event in results: - print(str(event)) - print(results) - - @skip('NotImplementedError') - def test_langdedrosa_fight(self): - """Can I run an encounter against Langdedrosa Cyanwrath?""" - char = Character() - char.set_attrs(name="Stravajiaxen") - char.set_attrs(weapons=["greataxe"]) - char.set_attrs(armor="split mail") - - # Check that race gets set to an object - char.set_attrs(race="half orc") - char.set_attrs(inspiration=False) - - class LangdedrosaCyanwrath(Monster): - """ - **Action Surge (Recharges on a Short or Long Rest).** On his turn, Langdedrosa - can take one additional action. - - **Improved Critical.** Langdedrosa's weapon attacks score a critical hit on a - roll of 19 or 20. - - **Multiattack:** Schlangdedrosa attacks twice, either with his greatsword or spear. - - **Greatsword.** Melee Weapon Attack: +6 to hit, reach 5 ft., one target. - Hit: 11 (2d6 + 4) slashing damage. - - **Spear.** Melee or Ranged Weapon Attack: +6 to hit, reach 5 ft. or - ranged 20/60 ft., one target. Hit: 7 (1d6 + 4) piercing damage. - - **Lightning Breath (Recharge 5-6)**. Schlangdedrosa breathes lightning in a - 30-foot line that is 5 feet wide. Each creature in the line must make a DC 13 - Dexterity saving throw, taking 22 (4d10) lightning damage on a failed save, or - half as much damage on a successful one. - - **Climbing speed:** 30 ft. - """ - - name = "Langdedrosa Cyanwrath" - description = "Medium humanoid (half-dragon), lawful evil" - challenge_rating = 4 - armor_class = 17 - skills = "Athletics +6, Intimidation +3, Perception +4" - senses = "blindsight 10 ft., darkvision 60ft., passive Perception 14" - strength = Ability(19) - dexterity = Ability(13) - constitution = Ability(16) - intelligence = Ability(10) - wisdom = Ability(14) - charisma = Ability(12) - speed = 30 - swim_speed = 0 - fly_speed = 0 - hp_max = 57 - hit_dice = "6d12+18" - - lang = LangdedrosaCyanwrath() - - battle = Encounter([char], [lang]) - results = battle.simulate() - - -class SimpleRanger(Character): # Taken from ranger2.py - """This file describes the heroic adventurer Ranger2. - - It's used primarily for saving characters from create-character, - where there will be many missing sections. - - Modify this file as you level up and then re-generate the character - sheet by running ``makesheets`` from the command line. - - """ - - dungeonsheets_version = "0.9.4" - - name = "Stravajiaxen" - player_name = "Ben" - - # Be sure to list Primary class first - classes = ['Ranger'] # ex: ['Wizard'] or ['Rogue', 'Fighter'] - levels = [3] # ex: [10] or [3, 2] - subclasses = ["Horizon Walker"] # ex: ['Necromacy'] or ['Thief', None] - background = "Uthgardt Tribe Member" - race = "Lizardfolk" - alignment = "Neutral good" - - xp = 0 - hp_max = 24 - inspiration = 0 # integer inspiration value - - # Ability Scores - strength = Ability(13) - dexterity = Ability(15) - constitution = Ability(12) - intelligence = Ability(8) - wisdom = Ability(15) - charisma = Ability(12) - - # Select what skills you're proficient with - # ex: skill_proficiencies = ('athletics', 'acrobatics', 'arcana') - skill_proficiencies = ('athletics', 'insight', 'investigation') - - # Any skills you have "expertise" (Bard/Rogue) in - skill_expertise = () - - # Named features / feats that aren't part of your classes, race, or background. - # Also include Eldritch Invocations and features you make multiple selection of - # (like Maneuvers for Fighter, Metamagic for Sorcerors, Trick Shots for - # Gunslinger, etc.) - # Example: - # features = ('Tavern Brawler',) # take the optional Feat from PHB - features = () - - # If selecting among multiple feature options: ex Fighting Style - # Example (Fighting Style): - # feature_choices = ('Archery',) - feature_choices = ('dueling',) - - # Weapons/other proficiencies not given by class/race/background - weapon_proficiencies = () # ex: ('shortsword', 'quarterstaff') - _proficiencies_text = () # ex: ("thieves' tools",) - - # Proficiencies and languages - languages = """Dwarvish, Common, Draconic""" - - # Inventory - # TODO: Get yourself some money - cp = 0 - sp = 0 - ep = 0 - gp = 0 - pp = 0 - - # TODO: Put your equipped weapons and armor here - weapons = ('rapier', 'hand crossbow') # Example: ('shortsword', 'longsword') - magic_items = () # Example: ('ring of protection',) - armor = ChainShirt # Eg "leather armor" - shield = "" # Eg "shield" - - equipment = """TODO: list the equipment and magic items your character carries""" - - attacks_and_spellcasting = """TODO: Describe how your character usually attacks - or uses spells.""" - - # List of known spells - # Example: spells_prepared = ('magic missile', 'mage armor') - spells_prepared = () # Todo: Learn some spells - - # Which spells have not been prepared - __spells_unprepared = () - - # all spells known - spells = spells_prepared + __spells_unprepared - - # Wild shapes for Druid - wild_shapes = () # Ex: ('ape', 'wolf', 'ankylosaurus') - - # Backstory - # Describe your backstory here - personality_traits = """TODO: How does your character behave? See the PHB for - examples of all the sections below""" - - ideals = """TODO: What does your character believe in?""" - - bonds = """TODO: Describe what debts your character has to pay, - and other commitments or ongoing quests they have.""" - - flaws = """TODO: Describe your characters interesting flaws. - """ - - features_and_traits = """TODO: Describe other features and abilities your - character has.""" diff --git a/tests/test_multiclass.py b/tests/test_multiclass.py index 441cd6b..3e5cd93 100644 --- a/tests/test_multiclass.py +++ b/tests/test_multiclass.py @@ -13,24 +13,24 @@ class TestMulticlass(TestCase): def test_constructor(self): char = Character( - name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] ) self.assertIsInstance(char, Character) def test_level(self): char = Character( - name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] ) self.assertEqual(char.level, 9) def test_spellcasting(self): char = Character( - name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] ) self.assertEqual(len(char.spellcasting_classes), 1) char = Character( name="Multiclass", - classes=["wizard", "fighter"], + class_list=["wizard", "fighter"], subclasses=[None, "Eldritch Knight"], levels=[5, 4], ) @@ -43,12 +43,12 @@ class TestMulticlass(TestCase): def test_proficiencies(self): char1 = Character( - name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] ) for svt in ("intelligence", "wisdom"): self.assertIn(svt, char1.saving_throw_proficiencies) - char2 = Character(name="Multiclass", classes=["wizard", "rogue"], levels=[5, 4]) - char3 = Character(name="Multiclass", classes=["rogue", "wizard"], levels=[4, 5]) + char2 = Character(name="Multiclass", class_list=["wizard", "rogue"], levels=[5, 4]) + char3 = Character(name="Multiclass", class_list=["rogue", "wizard"], levels=[4, 5]) sword = Shortsword() self.assertTrue(char1.is_proficient(sword)) # multiclassing into Rogue doesn't give simple weapon proficiency From b08af0865abc707b640045f61b7adece5ce0bb81 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Mon, 24 May 2021 20:40:29 -0400 Subject: [PATCH 13/16] Remove comma --- dungeonsheets/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dungeonsheets/entity.py b/dungeonsheets/entity.py index 3d270f8..bcf7a12 100644 --- a/dungeonsheets/entity.py +++ b/dungeonsheets/entity.py @@ -1,13 +1,15 @@ -from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill, +from dungeonsheets.stats import Ability, ArmorClass, Initiative, Speed, Skill from abc import ABC import os + 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!""" From 37c61f6b4a0bb19ba3cf1406918a55d7dca65566 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Mon, 24 May 2021 20:47:28 -0400 Subject: [PATCH 14/16] Fix some typos --- dungeonsheets/monsters.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dungeonsheets/monsters.py b/dungeonsheets/monsters.py index 594b422..60cb914 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -44,7 +44,7 @@ class Ankylosaurus(Monster): challenge_rating = 3 armor_class = 15 skills = "" - senses = "Passive perception 11" + senses = "passive Perception 11" strength = Ability(19) dexterity = Ability(11) constitution = Ability(15) @@ -74,7 +74,7 @@ class Ape(Monster): challenge_rating = 1 / 2 armor_class = 12 skills = "Athletics +5, Perception +3" - senses = "Passive perception 13" + senses = "passive Perception 13" strength = Ability(16) dexterity = Ability(14) constitution = Ability(14) @@ -109,7 +109,7 @@ class BlackBear(Monster): challenge_rating = 1 / 2 armor_class = 11 skills = "Perception +3" - senses = "Passive perception 13" + senses = "passive Perception 13" strength = Ability(15) dexterity = Ability(10) constitution = Ability(14) @@ -177,7 +177,7 @@ class GiantEagle(Monster): challenge_rating = 1 armor_class = 13 skills = "Perception +4" - senses = "Passive perception 14" + senses = "passive Perception 14" languages = "understands common and Auran but can't speak." strength = Ability(16) dexterity = Ability(17) @@ -281,9 +281,9 @@ class GiantPoisonousSnake(Monster): class PoisonousSnake(Monster): """**Bite:** Melee Weapon Attack: +5 to hit, reach 5 ft., one target. - Hit: 1 piercing damage, and the target must ma ke a DC 10 + Hit: 1 piercing damage, and the target must make a DC 10 Constitution saving throw, taking 5 (2d4) poison dam age on a - failed save, or ha lf as much damage on a successful one. + failed save, or half as much damage on a successful one. """ name = "Poisonous snake" From 9d4cea047f49dccc78b9b9ebbf7f2747bdb137fb Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Mon, 24 May 2021 20:56:45 -0400 Subject: [PATCH 15/16] Fix proficiency bonus bug for RemarkableAthlete --- dungeonsheets/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index 498d330..0f51cd0 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -129,7 +129,7 @@ class Skill: modifier += entity.proficiency_bonus // 2 elif entity.has_feature(RemarkableAthelete): if self.ability_name.lower() in ("strength", "dexterity", "constitution"): - modifier += ceil(entity.proficienc_bonus / 2.0) + modifier += ceil(entity.proficiency_bonus / 2.0) # Check for expertise is_expert = self.skill_name in entity.skill_expertise From ef6e3b4bcb93d0e7516a5e410dea6587259be179 Mon Sep 17 00:00:00 2001 From: Matthew DeMartino Date: Mon, 24 May 2021 21:45:45 -0400 Subject: [PATCH 16/16] Fix failing character.py tests --- dungeonsheets/character.py | 8 ++++---- tests/test_character.py | 6 +++--- tests/test_multiclass.py | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index ef42477..81b04ac 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -163,7 +163,7 @@ class Character(Entity): def __init__( self, - class_list: Sequence = [], + classes: Sequence = [], levels: Sequence[int] = [], subclasses: Sequence = [], **attrs, @@ -175,7 +175,7 @@ class Character(Entity): Parameters ========== - class_list + classes Strings with class names, or character class definitions representing the characters various D&D classes. levels @@ -191,7 +191,7 @@ class Character(Entity): super(Character, self).__init__() self.clear() # make sure class, race, background are set first - my_classes = class_list + my_classes = classes my_levels = levels my_subclasses = subclasses # backwards compatability @@ -219,7 +219,7 @@ class Character(Entity): self.__set_max_hp(attrs.get("hp_max", None)) def clear(self): - # reset class-definied items + # reset class-defined items self.class_list = list() self.weapons = list() self.magic_items = list() diff --git a/tests/test_character.py b/tests/test_character.py index 8f42a47..1dc4440 100755 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -64,7 +64,7 @@ class TestCharacter(TestCase): self.assertEqual(char.spells[0].name, "my spell!") def test_homebrew_infusions(self): - char = Character(class_list="artificer") + char = Character(classes="artificer") class MyInfusion(infusions.Infusion): name = "my infusion!" @@ -74,7 +74,7 @@ class TestCharacter(TestCase): self.assertIsInstance(char.infusions[0], infusions.Infusion) self.assertEqual(char.infusions[0].name, "my infusion!") # Pass a previously undefined infusion - char = Character(class_list="artificer") + char = Character(classes="artificer") char.set_attrs(infusions=("spam_infusion",)) self.assertIsInstance(char.infusions[0], infusions.Infusion) self.assertEqual(char.infusions[0].name, "Spam Infusion") @@ -129,7 +129,7 @@ class TestCharacter(TestCase): self.assertEqual(repr(char), "") def test_is_proficient(self): - char = Character(class_list=["Wizard"]) + char = Character(classes=["Wizard"]) char.weapon_proficiencies sword = Shortsword() # Check for not-proficient weapon diff --git a/tests/test_multiclass.py b/tests/test_multiclass.py index 3e5cd93..441cd6b 100644 --- a/tests/test_multiclass.py +++ b/tests/test_multiclass.py @@ -13,24 +13,24 @@ class TestMulticlass(TestCase): def test_constructor(self): char = Character( - name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] ) self.assertIsInstance(char, Character) def test_level(self): char = Character( - name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] ) self.assertEqual(char.level, 9) def test_spellcasting(self): char = Character( - name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] ) self.assertEqual(len(char.spellcasting_classes), 1) char = Character( name="Multiclass", - class_list=["wizard", "fighter"], + classes=["wizard", "fighter"], subclasses=[None, "Eldritch Knight"], levels=[5, 4], ) @@ -43,12 +43,12 @@ class TestMulticlass(TestCase): def test_proficiencies(self): char1 = Character( - name="Multiclass", class_list=["wizard", "fighter"], levels=[5, 4] + name="Multiclass", classes=["wizard", "fighter"], levels=[5, 4] ) for svt in ("intelligence", "wisdom"): self.assertIn(svt, char1.saving_throw_proficiencies) - char2 = Character(name="Multiclass", class_list=["wizard", "rogue"], levels=[5, 4]) - char3 = Character(name="Multiclass", class_list=["rogue", "wizard"], levels=[4, 5]) + char2 = Character(name="Multiclass", classes=["wizard", "rogue"], levels=[5, 4]) + char3 = Character(name="Multiclass", classes=["rogue", "wizard"], levels=[4, 5]) sword = Shortsword() self.assertTrue(char1.is_proficient(sword)) # multiclassing into Rogue doesn't give simple weapon proficiency