diff --git a/.gitignore b/.gitignore index 7bbc71c..9e270f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Emacs temp files +*~ + +# Pytest +.pytest_cache/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index dff7520..b2bda59 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -1,10 +1,12 @@ """Tools for describing a player character.""" import re +import warnings from .stats import Ability, Skill, findattr from .dice import read_dice_str -from . import weapons +from . import weapons, race +from .weapons import Weapon dice_re = re.compile('(\d+)d(\d+)') @@ -20,9 +22,8 @@ class Character(): background = "" level = 1 alignment = "Neutral" - race = "Human" + race = None xp = 0 - speed = 30 # In feet # Hit points hp_max = 10 hit_dice_faces = 2 @@ -35,7 +36,7 @@ class Character(): charisma = Ability() saving_throw_proficiencies = [] skill_proficiencies = tuple() - weapon_proficienies = tuple() + weapon_proficiencies = tuple() proficiencies_extra = tuple() languages = "" # Skills @@ -58,10 +59,12 @@ class Character(): stealth = Skill(ability='dexterity') survival = Skill(ability='wisdom') # Characteristics + attacks_and_spellcasting = "" personality_traits = "" ideals = "" bonds = "" flaws = "" + features_and_traits = "" # Inventory cp = 0 sp = 0 @@ -77,6 +80,16 @@ class Character(): self.weapons = [] self.set_attrs(**attrs) + def __str__(self): + return self.name + + def __repr__(self): + return f"<{self.class_name}: {self.name}>" + + @property + def speed(self): + return self.race.speed + def set_attrs(self, **attrs): """Bulk setting of attributes. Useful for loading a character from a dictionary.""" @@ -85,6 +98,9 @@ class Character(): # Treat weapons specially for weap in val: self.wield_weapon(weap) + elif attr == 'race': + MyRace = findattr(race, val) + self.race = MyRace() else: if not hasattr(self, attr): warnings.warn(f"Setting unknown character attribute {attr}", @@ -92,10 +108,29 @@ class Character(): # Lookup general attributes setattr(self, attr, val) + def is_proficient(self, weapon: Weapon): + """Is the character proficient with this item? + + Considers class proficiencies and race proficiencies. + + Parameters + ---------- + weapon + The weapon to be tested for proficiency. + + """ + all_proficiencies = tuple(self.weapon_proficiencies) + all_proficiencies += tuple(getattr(self.race, 'weapon_proficiencies', tuple())) + is_proficient = any((isinstance(weapon, W) for W in all_proficiencies)) + return is_proficient + @property def proficiencies_text(self): final_text = "" - all_proficiencies = (self._proficiencies_text + self.proficiencies_extra) + all_proficiencies = self._proficiencies_text + if self.race is not None: + all_proficiencies += self.race.proficiencies_text + all_proficiencies += self.proficiencies_extra # Create a single string out of all the proficiencies for txt in all_proficiencies: if not final_text: @@ -133,8 +168,7 @@ class Character(): weapon_.attack_bonus += ability_mod weapon_.bonus_damage += ability_mod # Check for prifiency - is_proficient = (weapon_.__class__ in self.weapon_proficienies) - if is_proficient: + if self.is_proficient(weapon_): weapon_.attack_bonus += self.proficiency_bonus # Save it to the array self.weapons.append(weapon_) @@ -172,7 +206,7 @@ class Barbarian(Character): saving_throw_proficiencies = ('strength', 'constitution') _proficiencies_text = ('light armor', 'medium armor', 'shields', 'simple weapons', 'martial weapons') - weapon_proficienies = (weapons.simple_weapons + weapons.martial_weapons) + weapon_proficiencies = (weapons.simple_weapons + weapons.martial_weapons) class Bard(Character): @@ -182,7 +216,7 @@ class Bard(Character): _proficiencies_text = ( 'Light armor', 'simple weapons', 'hand crossbows', 'longswords', 'rapiers', 'shortswords', 'three musical instruments of your choice') - weapon_proficienies = ((weapons.HandCrossbow, weapons.Longsword, + weapon_proficiencies = ((weapons.HandCrossbow, weapons.Longsword, weapons.Rapier, weapons.Shortsword) + weapons.simple_weapons) @@ -193,7 +227,7 @@ class Cleric(Character): saving_throw_proficiencies = ('wisdom', 'charisma') _proficiencies_text = ('light armor', 'medium armor', 'shields', 'all simple weapons') - weapon_proficienies = weapons.simple_weapons + weapon_proficiencies = weapons.simple_weapons class Druid(Character): @@ -205,7 +239,7 @@ class Druid(Character): 'shields (druids will not wear armor or use shields made of metal)', 'clubs', 'daggers', 'darts', 'javelins', 'maces', 'quarterstaffs', 'scimitars', 'sickles', 'slings', 'spears') - weapon_proficienies = (weapons.Club, weapons.Dagger, weapons.Dart, + weapon_proficiencies = (weapons.Club, weapons.Dagger, weapons.Dart, weapons.Javelin, weapons.Mace, weapons.Quarterstaff, weapons.Scimitar, weapons.Sickle, weapons.Sling, weapons.Spear) @@ -215,7 +249,7 @@ class Fighter(Character): hit_dice_faces = 10 saving_throw_proficiencies = ('strength', 'constitution') _proficiencies_text = ('All armar', 'shields', 'simple weapons', 'martial weapons') - weapon_proficienies = weapons.simple_weapons + weapons.martial_weapons + weapon_proficiencies = weapons.simple_weapons + weapons.martial_weapons class Monk(Character): @@ -225,7 +259,7 @@ class Monk(Character): _proficiencies_text = ( 'simple weapons', 'shortswords', "one type of artisan's tools or one musical instrument") - weapon_proficienies = (weapons.Shortsword,) + weapons.simple_weapons + weapon_proficiencies = (weapons.Shortsword,) + weapons.simple_weapons class Paladin(Character): @@ -234,7 +268,7 @@ class Paladin(Character): saving_throw_proficiencies = ('wisdom', 'charisma') _proficiencies_text = ('All armor', 'shields', 'simple weapons', 'martial weapons') - weapon_proficienies = weapons.simple_weapons + weapons.martial_weapons + weapon_proficiencies = weapons.simple_weapons + weapons.martial_weapons class Ranger(Character): @@ -243,7 +277,7 @@ class Ranger(Character): saving_throw_proficiencies = ('strength', 'dexterity') _proficiencies_text = ("light armor", "medium armor", "shields", "simple weapons", "martial weapons") - weapon_proficienies = weapons.simple_weapons + weapons.martial_weapons + weapon_proficiencies = weapons.simple_weapons + weapons.martial_weapons class Rogue(Character): @@ -253,7 +287,7 @@ class Rogue(Character): _proficiencies_text = ( 'light armor', 'simple weapons', 'hand crossbows', 'longswords', 'rapiers', 'shortswords', "thieves' tools") - weapon_proficienies = (weapons.HandCrossbow, weapons.Longsword, + weapon_proficiencies = (weapons.HandCrossbow, weapons.Longsword, weapons.Rapier, weapons.Shortsword) + weapons.simple_weapons @@ -263,7 +297,7 @@ class Sorceror(Character): saving_throw_proficiencies = ('constitution', 'charisma') _proficiencies_text = ('daggers', 'darts', 'slings', 'quarterstaffs', 'light crossbows') - weapon_proficienies = (weapons.Dagger, weapons.Dart, + weapon_proficiencies = (weapons.Dagger, weapons.Dart, weapons.Sling, weapons.Quarterstaff, weapons.LightCrossbow) @@ -273,7 +307,7 @@ class Warlock(Character): hit_dice_faces = 8 saving_throw_proficiencies = ('wisdom', 'charisma') _proficiencies_text = ("light Armor", "simple weapons") - weapon_proficienies = weapons.simple_weapons + weapon_proficiencies = weapons.simple_weapons class Wizard(Character): @@ -282,6 +316,6 @@ class Wizard(Character): saving_throw_proficiencies = ('intelligence', 'wisdom') _proficiencies_text = ('daggers', 'darts', 'slings', 'quarterstaffs', 'light crossbows') - weapon_proficienies = (weapons.Dagger, weapons.Dart, + weapon_proficiencies = (weapons.Dagger, weapons.Dart, weapons.Sling, weapons.Quarterstaff, weapons.LightCrossbow) diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index 747a63e..dbddd8e 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -61,7 +61,7 @@ def create_fdf(character, fdfname): ('ClassLevel', class_level), ('Background', character.background), ('PlayerName', character.player_name), - ('Race ', character.race), + ('Race ', str(character.race)), ('Alignment', character.alignment), ('XP', character.xp), # Abilities @@ -111,11 +111,13 @@ def create_fdf(character, fdfname): # Hit points ('HDTotal', character.hit_dice), ('HPMax', character.hp_max), - # Personality traits + # Personality traits and other features ('PersonalityTraits ', text_box(character.personality_traits)), ('Ideals', text_box(character.ideals)), ('Bonds', text_box(character.bonds)), ('Flaws', text_box(character.flaws)), + ('AttacksSpellcasting', text_box(character.attacks_and_spellcasting)), + ('Features and Traits', text_box(character.features_and_traits)), # Inventory ('CP', character.cp), ('SP', character.sp), diff --git a/dungeonsheets/race.py b/dungeonsheets/race.py new file mode 100644 index 0000000..f10c58e --- /dev/null +++ b/dungeonsheets/race.py @@ -0,0 +1,124 @@ +from . import weapons + +class Race(): + name = "Unknown" + size = "medium" + speed = 30 + proficiencies_text = tuple() + weapon_proficiences = tuple() + + def __str__(self): + return self.name + + def __repr__(self): + return f"" + + +# Dwarves +class Dwarf(Race): + name = "Dwarf" + size = "medium" + speed = 25 + proficiencies_text = ('battleaxes', 'handaxes', 'throwing hammers', 'warhammers') + weapon_proficiences = (weapons.Battleaxe, weapons.Handaxe, + weapons.ThrowingHammer, weapons.Warhammer) + + +class HillDwarf(Dwarf): + name = "Hill Dwarf" + + +class MountainDwarf(Dwarf): + name = "Mountain Dwarf" + + +# Elves +class Elf(Race): + name = "Elf" + size = "medium" + speed = 30 + + +class HighElf(Elf): + name = "High Elf" + weapon_proficiencies = (weapons.Longsword, weapons.Shortsword, + weapons.Shortbow, weapons.Longbow) + proficiencies_text = ('longswords', 'shortswords', 'shortbows', 'longbows') + + +class WoodElf(Elf): + name = "Wood Elf" + weapon_proficiencies = (weapons.Longsword, weapons.Shortsword, + weapons.Shortbow, weapons.Longbow) + proficiencies_text = ('longswords', 'shortswords', 'shortbows', 'longbows') + + +class DarkElf(Elf): + name = "Dark Elf" + weapon_proficiencies = (weapons.Rapier, weapons.Shortsword, weapons.HandCrossbow) + proficiencies_text = ('repiers', 'shortswords', 'hand crossbows') + + +# Halflings +class Halfling(Race): + name = "Halfling" + size = "small" + speed = 25 + + +class LightfootHalfling(Halfling): + name = "Lightfoot Halfling" + + +class StoutHalfling(Halfling): + name = "Stout Halfling" + + +# Humans +class Human(Race): + name = "Human" + size = "medium" + speed = 30 + + +# Dragonborn +class Dragonborn(Race): + name = "Dragonborn" + size = "medium" + speed = 30 + + +# Gnomes +class Gnome(Race): + name = "Gnome" + size = "small" + speed = 25 + + +class ForestGnome(Gnome): + name = "Forest Gnome" + + +class RockGnome(Gnome): + name = "Rock Gnome" + + +# Half-elves +class HalfElf(Race): + name = "Half-Elf" + size = "medium" + speed = 30 + + +# Half-Orcs +class HalfOrc(Race): + name = "Half-Orc" + size = "medium" + speed = 30 + + +# Tielflings +class Tiefling(Race): + name = "Tiefling" + size = "medium" + speed = 30 diff --git a/dungeonsheets/weapons.py b/dungeonsheets/weapons.py index d17e93e..30d53f5 100644 --- a/dungeonsheets/weapons.py +++ b/dungeonsheets/weapons.py @@ -305,6 +305,16 @@ class Shortsword(Weapon): ability = 'strength' +class ThrowingHammer(Weapon): + name = "Throwing Hammer" + cost = "15 gp" + base_damage = '1d6' + damage_type = "bludgeoning" + weight = 4 + properties = "Thrown (range 60/120)" + ability = "strength" + + class Trident(Weapon): name = "Trident" cost = "5 gp" @@ -403,8 +413,10 @@ simple_ranged_weapons = (LightCrossbow, Dart, Shortbow, Sling) simple_weapons = simple_melee_weapons + simple_ranged_weapons martial_melee_weapons = (Battleaxe, Flail, Glaive, Greataxe, - Greatsword, Halberd, Lance, Longsword, Maul, Morningstar, Pike, - Rapier, Scimitar, Shortsword, Trident, WarPick, Warhammer, Whip) + Greatsword, Halberd, Lance, Longsword, Maul, + Morningstar, Pike, Rapier, Scimitar, + Shortsword, ThrowingHammer, Trident, WarPick, + Warhammer, Whip) martial_ranged_weapons = (Blowgun, HandCrossbow, HeavyCrossbow, Longbow, Net) martial_weapons = martial_melee_weapons + martial_ranged_weapons diff --git a/examples/rogue.pdf b/examples/rogue.pdf index a6b4b52..c512798 100644 Binary files a/examples/rogue.pdf and b/examples/rogue.pdf differ diff --git a/examples/rogue.py b/examples/rogue.py index df35a87..b3997fd 100644 --- a/examples/rogue.py +++ b/examples/rogue.py @@ -7,7 +7,6 @@ level = 3 alignment = "Neutral" xp = 1984 hp_max = 19 -speed = 25 # Ability Scores strength = 8 @@ -41,6 +40,45 @@ equipment = ( tinderbox, waterskin, crowbar, set of dark common clothes including a hood, pouch.""") +attacks_and_spellcasting = ( + """Sneak Attack: Once per turn, when you hit a creature with a + Dexterity-based attack (such as with your shortsword or shortbow) + and you have advantage on the attack roll, you can deal an extra + 1d6 damage to your target. You don’t need advantage if another + enemy of the target is within 5 feet of it and isn’t + incapacitated. You can’t deal the extra damage, however, if you + have disadvantage on the attack roll.""") + +features_and_traits = ( + """Thieves' Cant: You know thieves’ cant, a secret mix of dialect, + jargon, and code that allows you to hide messages in seemingly + normal conversation. You also understand a set of secret signs and + symbols used to convey short, simple messages, such as whether an + area is dangerous, whether loot is nearby, or whether the people + in an area are easy marks or will provide a safe house for thieves + on the run. + + Lucky: When you roll a natural 1 on an attack roll, ability check, + or saving throw, you can reroll the die and must use the new roll. + + Brave: You have advantage on saving throws against being + frightened. + + Halfling Nimbleness: You can move through the space of any + creature that is of a size larger than yours. + + Naturally Stealthy: You can attempt to hide when you are obscured + by a creature that is at least one size larger than you. + + Criminal Contact: You have a contact who acts as your liaison to a + network of other criminals. You know how to get messages to and + from your contact, even over great distances; you know the local + messengers, corrupt caravan masters, and seedy sailors who can + carry messages for you. You can move secret information or stolen + goods through your contact in exchange for money or other + information you seek.""") + + # Backstory personality_traits = """I never have a plan, but I’m great at making things up as I go along. Also, the best way to get me to do something is to tell me I diff --git a/tests/test_character.py b/tests/test_character.py index c888c2d..952d5a6 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -2,7 +2,8 @@ from unittest import TestCase -from dungeonsheets.character import Character +from dungeonsheets import race +from dungeonsheets.character import Character, Wizard from dungeonsheets.weapons import Weapon, Shortsword @@ -27,11 +28,14 @@ class TestCharacter(TestCase): char.set_attrs(weapons=['shortsword']) self.assertEqual(len(char.weapons), 1) self.assertTrue(isinstance(char.weapons[0], Shortsword)) + # Check that race gets set to an object + char.set_attrs(race='high elf') + self.assertIsInstance(char.race, race.HighElf) def test_wield_weapon(self): char = Character() char.strength = 14 - char.weapon_proficienies = [Shortsword] + char.weapon_proficiencies = [Shortsword] # Add a weapon char.wield_weapon('shortsword') self.assertEqual(len(char.weapons), 1) @@ -46,7 +50,33 @@ class TestCharacter(TestCase): char.wield_weapon('shortsword') sword = char.weapons[0] self.assertEqual(sword.attack_bonus, 5) # dex + prof - + # Check if race weapon proficiencies are considered + char.weapons = [] + char.weapon_proficiencies = [] + char.race = race.HighElf() + char.wield_weapon('shortsword') + sword = char.weapons[0] + self.assertEqual(sword.attack_bonus, 5) + + def test_str(self): + char = Wizard(name="Inara") + self.assertEqual(str(char), 'Inara') + self.assertEqual(repr(char), '') + + def test_is_proficient(self): + char = Character() + char.weapon_proficiencies + sword = Shortsword() + # Check for not-proficient weapon + self.assertFalse(char.is_proficient(sword)) + # Check if we're proficient in the weapon + char.weapon_proficiencies = [Shortsword] + self.assertTrue(char.is_proficient(sword)) + # Now try it with a racial proficiency + char.weapon_proficiencies = tuple() + char.race = race.HighElf() + self.assertTrue(char.is_proficient(sword)) + def test_proficiencies_text(self): char = Character() char._proficiencies_text = ('hello', 'world') @@ -54,6 +84,11 @@ class TestCharacter(TestCase): # Check for extra proficiencies char.proficiencies_extra = ("it's", "me") self.assertEqual(char.proficiencies_text, "Hello, world, it's, me.") + # Check that race proficienceis are included + elf = race.HighElf() + char.race = elf + expected = "Hello, world, longswords, shortswords, shortbows, longbows, it's, me." + self.assertEqual(char.proficiencies_text, expected) def test_proficiency_bonus(self): char = Character()