From f2949521d7ede23fe44b8e11c15ab2f189ba1d0e Mon Sep 17 00:00:00 2001 From: bw-mutley Date: Sat, 19 Mar 2022 12:31:03 -0300 Subject: [PATCH] Included companions and weight management --- dungeonsheets/character.py | 66 ++++++++++- dungeonsheets/dice.py | 35 ++++-- dungeonsheets/equipment_reader.py | 188 ++++++++++++++++++++++++++++++ dungeonsheets/make_sheets.py | 25 +++- dungeonsheets/stats.py | 60 +++++++++- 5 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 dungeonsheets/equipment_reader.py diff --git a/dungeonsheets/character.py b/dungeonsheets/character.py index f931759..f0cf4c4 100644 --- a/dungeonsheets/character.py +++ b/dungeonsheets/character.py @@ -25,6 +25,7 @@ from dungeonsheets.content_registry import find_content from dungeonsheets.weapons import Weapon from dungeonsheets.content import Creature from dungeonsheets.dice import combine_dice +from dungeonsheets.equipment_reader import equipment_weight_parser dice_re = re.compile(r"(\d+)d(\d+)") @@ -86,6 +87,10 @@ class Character(Creature): attacks_and_spellcasting = "" class_list = list() _background = None + _companions = [] + _carrying_capacity = 0 + _carrying_weight = 0 + equipment_weight_dict = {} # Characteristics personality_traits = ( @@ -747,17 +752,38 @@ class Character(Creature): featS = featS[:N] + ["(...)"] featS += info_list return "\n\n".join(featS) + + @property + def carrying_capacity(self): + _ccModD = {"tiny":0.5, "small":1, "medium":1, + "large":2, "huge":4, "gargantum":8} + cc_mod = _ccModD[self.race.size.lower()] + return 15*self.strength.value*cc_mod + + @property + def carrying_weight(self): + weight = equipment_weight_parser(self.equipment, + self.equipment_weight_dict) + weight += sum([w.weight for w in self.weapons]) + weight += self.armor.weight + if self.shield: + weight += 6 + weight += sum([self.cp, self.sp, self.ep, self.gp, self.pp])/50 + return round(weight, 2) @property def equipment_text(self): eq_list = [] - if hasattr(self, "magic_items"): + if hasattr(self, "magic_items") and len(self.magic_items) > 0: eq_list += ["**Magic Items**"] eq_list += [item.name for item in self.magic_items] - if hasattr(self, "equipment"): + if hasattr(self, "equipment") and len(self.equipment.strip()) > 0: eq_list += ["**Other Equipment**"] eq_list += [text.strip() for text in self.equipment.split("\n") if not(text.isspace())] + cw, cc = self.carrying_weight, self.carrying_capacity + eq_list += [f"**Weight:** {cw} lb\n\n**Capacity:** {cc} lb"] + return "\n\n".join(eq_list) @property @@ -961,6 +987,42 @@ class Character(Creature): if hasattr(self, "Druid"): self.Druid.wild_shapes = new_shapes + @property + def ranger_beast(self): + if hasattr(self, "Ranger"): + return self.Ranger.ranger_beast + else: + return None + + @ranger_beast.setter + def ranger_beast(self, beast): + msg = ( + f"Companion '{beast}' not found. Please add it to" + " ``monsters.py``" ) + beast = self._resolve_mechanic(beast, monsters.Monster, msg) + self.Ranger.ranger_beast = (beast, self.proficiency_bonus) + + @property + def companions(self): + """Return the list of companions and summonables""" + companions = [compa for compa in self._companions] + if self.ranger_beast: + companions.append(self.ranger_beast) + return companions + + @companions.setter + def companions(self, compas): + companions_list = [] + # Retrieve the actual monster classes if possible + for compa in compas: + msg = ( + f"Companion '{compa}' not found. Please add it to" + " ``monsters.py``" ) + new_compa = self._resolve_mechanic(compa, monsters.Monster, msg) + companions_list.append(new_compa) + # Save the updated list for later + self._companions = companions_list + @property def infusions_text(self): if hasattr(self, "Artificer"): diff --git a/dungeonsheets/dice.py b/dungeonsheets/dice.py index 2fe47e7..2a64a1c 100644 --- a/dungeonsheets/dice.py +++ b/dungeonsheets/dice.py @@ -5,27 +5,38 @@ from itertools import groupby from dungeonsheets.exceptions import DiceError -dice_re = re.compile(r"(\d+)d(\d+)", flags=re.I) +dice_re = re.compile(r"(\d+)d(\d+)([+-]\d+)*", flags=re.I) dice_part_re = re.compile(r"[0-9d]+", flags=re.I) -Dice = namedtuple("Dice", ("num", "faces")) +Dice = namedtuple("Dice", ("num", "faces", "modifier")) def read_dice_str(dice_str): - """Interpret a D&D dice string, eg. 3d10. + """Interpret a D&D dice string, eg. 3d10+2. Returns ------- dice : tuple - A named tuple with the scheme (num, faces), so '3d10' return - (num=3, faces=10) + A named tuple with the scheme (num, faces), so '3d10-2' return + (num=3, faces=10, modifier=-2) """ + dice_str = dice_str.replace(" ", "").replace("\n", "") match = dice_re.match(dice_str) if match is None: raise DiceError(f"Cannot interpret dice string {dice_str}") - dice = Dice(num=int(match.group(1)), faces=int(match.group(2))) + num, faces = int(match.group(1)), int(match.group(2)) + if match.group(3) is None: + modifier = 0 + else: + modifier = int(match.group(3)) + dice = Dice(num, faces, modifier) return dice +def _dice_mean(dice, force_min=True): + """Support function for calculating dice string mean.""" + dmg_min = dice.num + dice.modifier + dmg_max = dice.num*dice.faces + dice.modifier + return (dmg_max - dmg_min)/2.0 + dmg_min def combine_dice(dice_str): """Condense a dice string into its simplest representation. @@ -56,10 +67,20 @@ def combine_dice(dice_str): new_dice_str = " + ".join(new_parts) return new_dice_str - 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)]) + +def dice_roll_mean(dice_text): + """Takes a dice string like '3d6 +3' and returns its average roll.""" + dice = read_dice_str(dice_text) + return round(_dice_mean(dice)) + +if __name__ == "__main__": + ds = "10d12+10" + v = read_dice_str(ds) + print(v) + print(_dice_mean(v)) \ No newline at end of file diff --git a/dungeonsheets/equipment_reader.py b/dungeonsheets/equipment_reader.py new file mode 100644 index 0000000..9201101 --- /dev/null +++ b/dungeonsheets/equipment_reader.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Tue Feb 15 22:41:46 2022 + +@author: mauricio +""" +import warnings +import re +from dungeonsheets.weapons import simple_weapons, martial_weapons, firearms +from dungeonsheets.armor import all_armors + +all_weapons = simple_weapons + martial_weapons + firearms +item_reader = re.compile(r"(\d*)(\s*)(.+)") +gear_weight = {"abacus":2, + "vial of acid":1, + "flask of alchemist's fire":1, + "arrows":1/20, + "arrow":1/20, + "bowgun needles": 1/50, + "crosbow bolts":1.5/20, + "sling bullets":1.5/20, + "antitoxin":0, + "crystal":1, + "orb":3, + "rod":2, + "staff":4, + "wand":1, + "backpack":5, + "ball bearings":2/1000, + "barrel":70, + "basked":2, + "bedroll":7, + "bell":0, + "blanket":3, + "block and tackle":5, + "book":5, + "glass bottle":2, + "bucket":2, + "caltrops":2/20, + "candle":0, + "crosbow bolt case":1, + "scroll case":1, + "map case":1, + "feet of chain":1, + "feet chain":1, + "chalk":0, + "chest":25, + "climber's kit":12, + "common clothes":3, + "costume":4, + "fine clothes":6, + "traveler's clothes":4, + "component pouch":2, + "crowbar": 5, + "sprig of mistletoe":0, + "totem":0, + "wooden staff":4, + "yew wand":1, + "fishing tackle":4, + "flask":1, + "tankard":1, + "grappling hook":4, + "hammer":3, + "knife":1, + "sacrificial knife":1, + "sledge hammer":10, + "healer's kit":3, + "amulet":1, + "emblem":0, + "reliquary":2, + "flask of holy water":1, + "hourglass":1, + "hunting trap":25, + "bottle of ink":0, + "ink pen":0, + "jug":4, + "pitcher":4, + "ladder":25, + "lamp":1, + "bullseye lantern":2, + "hooded lantern":2, + "lock":1, + "magnifying glass":0, + "manacles":6, + "mess kit":1, + "steel mirror":0.5, + "flask of oil":1, + "paper sheet":0, + "parchment":0, + "vial of perfume":0, + "miner's pick":10, + "piton":0.25, + "pitons":0.25, + "vial of poison":0, + "feet pole":7, + "iron pot":10, + "potion of healing":0.5, + "pouch":1, + "quiver":1, + "portable ram":35, + "days of rations":2, + "day of ration":2, + "robes":4, + "feet of hempen rope":10/50, + "feet hempen rope":10/50, + "feet of silk rope":5/50, + "feet silk rope":5/50, + "sack":0.5, + "merchant's scale":3, + "sealing wax":0, + "shovel":5, + "signal whistle":0, + "signet ring":0, + "soap":0, + "spell book":3, + "iron spikes":5/10, + "spyglass":1, + "two-person tent":20, + "tinderbox":1, + "torch":1, + "torches":1, + "vial":0, + "waterskin":5, + "wheatstone":1} + +tools_weight = {"alchemist's supplies":8, + "brewer's supplies":9, + "calligrapher's supplies":5, + "capenter's tools":6, + "cartographer's tools":6, + "cobbler's tools":5, + "cook's utensils":8, + "glassblower's tools":5, + "jeweler's tools":2, + "leatherworker's tools":5, + "mason's tools":8, + "painter's supplies":5, + "potter's tools":3, + "smith's tools":8, + "tinker's tools":10, + "weaver's tools":5, + "woodcarver's tools":5, + "disguise kit":3, + "forgery kit":5, + "dice set":0, + "set of bone dice":0, + "dragonchess set":0.5, + "playing card set":0, + "three-dragon ante set":0, + "herbalism kit":3, + "bagpipes":6, + "drum":3, + "dulcimer":10, + "flute":1, + "lute":2, + "lyre":2, + "horn":2, + "pan flute":2, + "shawm":1, + "viol":1, + "navigator's tools":2, + "poisoner's kit":2, + "thieves' tools":1} +gear_weight.update(tools_weight) +gear_weight.update({armor.name.lower():armor.weight for armor in all_armors}) +gear_weight.update({w.name.lower():w.weight for w in all_weapons}) + +def equipment_weight_parser(equipment, gear_dict={}): + if not equipment.strip(): + return 0 + gear_w = gear_weight.copy() + gear_w.update(gear_dict) + weight = 0 + for gear in equipment.split(','): + gear = gear.lower().strip().strip(".") + q, _, item = item_reader.match(gear).groups() + if q: + q = int(q) + else: + q = 1 + if not(item in gear_w.keys()): + msg = f'{item} not found in gear_weight dictionary, please add.' + warnings.warn(msg) + continue + weight = weight + q*gear_w[item] + return weight + diff --git a/dungeonsheets/make_sheets.py b/dungeonsheets/make_sheets.py index bc789dc..3ebf558 100644 --- a/dungeonsheets/make_sheets.py +++ b/dungeonsheets/make_sheets.py @@ -86,14 +86,20 @@ def create_monsters_content( monsters: Sequence[Union[monsters.Monster, str]], suffix: str, use_dnd_decorations: bool = False, + base_template: str = "monsters_template" ) -> str: # Convert strings to Monster objects - template = jinja_env.get_template(f"monsters_template.{suffix}") + template = jinja_env.get_template(base_template+f".{suffix}") spell_list = [Spell() for monster in monsters for Spell in monster.spells] return template.render(monsters=monsters, use_dnd_decorations=use_dnd_decorations, spell_list=spell_list) +def create_gm_spellbook(spell_list, suffix): + template = jinja_env.get_template(f"gm_spellbook_template.{suffix}") + return template.render(spells=spell_list) + + def create_party_summary_content( party: Sequence[Creature], summary_rst: str, @@ -282,6 +288,12 @@ def make_gm_sheet( monsters_, suffix=content_suffix, use_dnd_decorations=fancy_decorations ) ) + + # Add the GM Spellbook + spells = [Spell() for monster in monsters_ for Spell in monster.spells] + spells = set(spells) + content.append(create_gm_spellbook(spells, content_suffix)) + # Add the random tables tables = [ find_content(s, valid_classes=[random_tables.RandomTable]) @@ -414,6 +426,13 @@ def make_character_content( content.append( create_druid_shapes_content(character, content_suffix=content_format, use_dnd_decorations=fancy_decorations) ) + + # Create a list of companions + if len(getattr(character, "companions", [])) > 0: + content.append( + create_monsters_content(character.companions, suffix=content_format, + use_dnd_decorations=fancy_decorations, base_template="companions_template") + ) # Postamble, empty for HTML content.append( jinja_env.get_template(f"postamble.{content_format}").render( @@ -479,8 +498,8 @@ def make_character_sheet( character_props = readers.read_sheet_file(char_file) character = _char.Character.load(character_props) # Load image file if present - portrait_file="" - if character.portrait: + portrait_file = character.portrait + if portrait_file is True: portrait_file=char_file.stem + ".jpeg" # Set the fields in the FDF basename = char_file.stem diff --git a/dungeonsheets/stats.py b/dungeonsheets/stats.py index c610916..2d8e960 100644 --- a/dungeonsheets/stats.py +++ b/dungeonsheets/stats.py @@ -1,9 +1,11 @@ +import re import math from collections import namedtuple from math import ceil import logging from dungeonsheets.armor import HeavyArmor, NoArmor, NoShield +from dungeonsheets.dice import dice_roll_mean from dungeonsheets.features import ( AmbushMaster, Defense, @@ -28,7 +30,14 @@ from dungeonsheets.features import ( log = logging.getLogger(__name__) - +skill_text_locator = re.compile(r"\S+ [+-]\d+") +attack_text_locator = re.compile(r"attack:.*?damage", re.IGNORECASE|re.DOTALL) +attack = re.compile(r"attack:.*?to hit", re.IGNORECASE|re.DOTALL) +damage = re.compile(r"hit:.*?(\d+)d(\d+).*?damage", re.IGNORECASE|re.DOTALL) +damage_avg = re.compile(r"hit:.*?(\d+)", re.IGNORECASE|re.DOTALL) +damage_nodice = re.compile(r"hit:.*?damage", re.IGNORECASE|re.DOTALL) +modifier = re.compile(r"[+-].*?(\d+)", re.IGNORECASE|re.DOTALL) +single_damage = re.compile(r"(\d+)") def mod_str(modifier): """Converts a modifier to a string, eg 2 -> '+2'.""" @@ -325,3 +334,52 @@ class Initiative(NumericalInitiative): if has_advantage: ini += "(A)" return ini + +def _add_modifier(att_text, prof): + """Auxiliary function to add proficiency bonus prof + to att_text.""" + _att_bonus_re = modifier.search(att_text) + att_bonus_text = _att_bonus_re.group() + att_bonus = int(att_bonus_text.replace(" ", "").replace("\n", "")) + prof + return re.sub(modifier, "{:+d}".format(att_bonus), att_text) + +def skill_modifier(skills_text, prof): + """Modifies the skill text string adding the proficiency + bonus to its values.""" + skills_updated = [] + skill_list = re.findall(skill_text_locator, skills_text) + if not skill_list: + return "" + for sk in skill_list: + increased_skill = _add_modifier(sk, prof) + skills_updated.append(increased_skill) + return ", ".join(skills_updated) + +def att_dmg_modifier(text, prof): + """Modify the attack and damage rolls for a strip + of attack text description.""" + _att_re = attack.search(text) + if not _att_re: + raise ValueError("No attack info detected.") + att_text = _att_re.group() + new_att_text = _add_modifier(att_text, prof) + text = re.sub(attack, new_att_text, text) + _dmg_re = damage.search(text) + if _dmg_re: + dmg_text = _dmg_re.group() + new_dmg_text = _add_modifier(dmg_text, prof) + dmg_avg_value = dice_roll_mean(new_dmg_text) + _dmg_avg_re = damage_avg.search(new_dmg_text) + dmg_avg_text = _dmg_avg_re.group() + new_dmg_avg_text = re.sub("(\d+)", "{:d}".format(dmg_avg_value), + dmg_avg_text, 1) + new_dmg_text = re.sub(damage_avg, new_dmg_avg_text, new_dmg_text) + text = re.sub(damage, new_dmg_text, text) + else: + _dmg_re = damage_nodice.search(text) + dmg_text = _dmg_re.group() + _sdamage_re = single_damage.search(dmg_text) + sdamage = int(_sdamage_re.group()) + prof + new_dmg_text = re.sub(single_damage, "{:d}".format(sdamage), dmg_text) + text = re.sub(single_damage, new_dmg_text, text) + return text