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/character.py b/dungeonsheets/character.py index 5f41420..3bd369f 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -20,16 +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 - - -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+)") @@ -145,57 +139,18 @@ def _resolve_mechanic(mechanic, module, SuperClass, warning_message=None): return Mechanic -class Character: +class Character(Entity): """A generic player character.""" - # General attirubtes - name = "" + # Character-specific player_name = "" - alignment = "Neutral" - dungeonsheets_version = __version__ - 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 = "" + class_list = list() + _background = None + + # Characteristics personality_traits = ( "TODO: Describe how your character behaves, interacts with others" ) @@ -203,17 +158,7 @@ class Character: 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 @@ -223,6 +168,7 @@ class Character: # Features IN MAJOR DEVELOPMENT custom_features = list() feature_choices = list() + # Appearance # portrait = placeholder not sure how to implement age = 0 @@ -266,6 +212,7 @@ class Character: character. """ + super(Character, self).__init__() self.clear() # make sure class, race, background are set first my_classes = classes @@ -296,7 +243,7 @@ class Character: 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() @@ -999,7 +946,7 @@ class Character: 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/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/entity.py b/dungeonsheets/entity.py new file mode 100644 index 0000000..bcf7a12 --- /dev/null +++ b/dungeonsheets/entity.py @@ -0,0 +1,89 @@ +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 d5a8efc..60cb914 100644 --- a/dungeonsheets/monsters.py +++ b/dungeonsheets/monsters.py @@ -3,30 +3,24 @@ shape forms.""" from dungeonsheets.stats import Ability +from dungeonsheets.entity import Entity -class Monster: +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" + def __init__(self): + super(Monster, self).__init__() + @property def is_beast(self): is_beast = "beast" in self.description.lower() @@ -50,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) @@ -80,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) @@ -115,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) @@ -183,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) @@ -209,8 +203,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 +236,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) @@ -287,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" 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/dungeonsheets/stats.py b/dungeonsheets/stats.py index c2d5f60..0f51cd0 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.proficiency_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,47 +183,56 @@ 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) -class Initiative: +class NumericalInitiative: + """A numerical representation of initiative""" + + 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 = ( + entity.has_feature(NaturalExplorerRevised) + or entity.has_feature(FeralInstinct) + or entity.has_feature(AmbushMaster) + ) + return ini, has_advantage + + +class Initiative(NumericalInitiative): """A character's 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, has_advantage = super(Initiative, self).__get__(entity, Entity) ini = "{:+d}".format(ini) - has_advantage = ( - char.has_feature(NaturalExplorerRevised) - or char.has_feature(FeralInstinct) - or char.has_feature(AmbushMaster) - ) if has_advantage: ini += "(A)" return ini diff --git a/tests/test_dice.py b/tests/test_dice.py index fc3c606..17f494e 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,17 @@ 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 + for _ in range(num_tests): + result = roll(2, 4) # Roll 2d4 + self.assertGreaterEqual(result, 2) + self.assertLessEqual(result, 8)