commit 4ee91edc61b95bbdfe2cbb4154201efe832303aa Author: David Kruger Date: Tue May 20 20:33:55 2025 -0700 Start of 5etools utilities and library Copying over the bestiary from dnd_monster_stats and working on a similar SpellList object and Spell DB. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cb3e7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.env/ + +*.egg-info/ +__pycache__/ + +*.swp +*.swo +*.log diff --git a/dnd5etools/__init__.py b/dnd5etools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dnd5etools/db/__init__.py b/dnd5etools/db/__init__.py new file mode 100644 index 0000000..42ab690 --- /dev/null +++ b/dnd5etools/db/__init__.py @@ -0,0 +1,28 @@ +import json +import pathlib +import typing + + +def parse_index(data_dir: pathlib.Path, db_type: str) -> typing.Dict[str, pathlib.Path]: + try: + index_file = data_dir / db_type / "index.json" + with index_file.open("rt") as index: + index_data = json.load(index) + except Exception as e: + raise Exception(f"Failed to read {db_type} index file: {e}") + return { + code: data_dir.joinpath(db_type, bfile) for code, bfile in index_data.items() + } + + +class DbIndex: + def __init__(self, data_dir: pathlib.Path, db_type: str) -> None: + self.data_dir = data_dir + self.db_type = db_type + self.source_index = parse_index(self.data_dir, self.db_type) + + def validate(self) -> None: + if not self.source_index: + raise Exception( + f"DB for {self.db_type} appears empty in data_dir{self.data_dir}" + ) diff --git a/dnd5etools/db/bestiary.py b/dnd5etools/db/bestiary.py new file mode 100644 index 0000000..18fd9ed --- /dev/null +++ b/dnd5etools/db/bestiary.py @@ -0,0 +1,305 @@ +import enum +import json +import numpy +import pathlib +import re +import typing +import dnd5etools.db + + +class MonsterSize(enum.StrEnum): + Tiny = "T" + Small = "S" + Medium = "M" + Large = "L" + Huge = "H" + Gargantuan = "G" + + +class MonsterSpeed(typing.NamedTuple): + walk: typing.Optional[int] + fly: typing.Optional[int] + swim: typing.Optional[int] + climb: typing.Optional[int] + burrow: typing.Optional[int] + can_hover: bool + + @classmethod + def from_json(cls, json_data) -> typing.Self: + as_dict = { + "walk": None, + "swim": None, + "climb": None, + "burrow": None, + "fly": None, + } + for st, val in json_data.items(): + if st in as_dict: + if isinstance(val, dict): + as_dict[st] = val["number"] + else: + as_dict[st] = val + can_hover = json_data.get("canHover", False) + if "choose" in json_data: + choices = json_data["choose"]["from"] + value = int(json_data["choose"]["amount"]) + for choice in choices: + as_dict[choice] = value + return cls( + walk=as_dict["walk"], + fly=as_dict["fly"], + swim=as_dict["swim"], + climb=as_dict["climb"], + burrow=as_dict["burrow"], + can_hover=can_hover, + ) + + +class AbilityScores(typing.NamedTuple): + strength: int + dexterity: int + constitution: int + intelligence: int + wisdom: int + charisma: int + + @classmethod + def from_json(cls, json_data) -> typing.Self: + return cls( + strength=int(json_data["str"]), + dexterity=int(json_data["dex"]), + constitution=int(json_data["con"]), + intelligence=int(json_data["int"]), + wisdom=int(json_data["wis"]), + charisma=int(json_data["cha"]), + ) + + +class Monster(typing.NamedTuple): + name: str + source: str + size: typing.List[MonsterSize] + speed: MonsterSpeed + creature_type: typing.List[str] + ac: typing.Optional[int] + hp: int + cr: float + strength: int + dexterity: int + constitution: int + intelligence: int + wisdom: int + charisma: int + atk_to_hit: typing.Optional[int] + save_dc: typing.Optional[int] + + @classmethod + def from_json(cls, json_data) -> typing.Self: + name = json_data["name"] + source = json_data["source"] + try: + size = cls.size_from_json(json_data) + except: + raise Exception(f"Unable to parse size for {name}") + try: + ac_val = cls.ac_from_json(json_data) + except: + raise Exception(f"Unable to parse AC for {name}") + try: + cr_val = cls.cr_from_json(json_data) + except: + raise Exception(f"Unable to parse CR for {name}") + try: + hp_val = cls.hp_from_json(json_data) + except: + raise Exception(f"Unable to parse HP for {name}") + try: + type_val = cls.creature_type_from_json(json_data) + except: + raise Exception(f"Unable to parse creature type for {name}") + try: + ability_scores = AbilityScores.from_json(json_data) + except: + raise Exception(f"Unable to parse ability scores for {name}") + try: + atk_to_hit = cls.attack_to_hit_from_json(json_data) + except Exception as e: + raise Exception(f"Unable to parse attacks to-hit for {name}") from e + try: + save_dc = cls.save_dc_from_json(json_data) + except: + raise Exception(f"Unable to parse attacks to-hit for {name}") + return cls( + name=name, + source=source, + size=size, + speed=MonsterSpeed.from_json(json_data["speed"]), + creature_type=type_val, + ac=ac_val, + hp=hp_val, + cr=cr_val, + strength=ability_scores.strength, + dexterity=ability_scores.dexterity, + constitution=ability_scores.constitution, + intelligence=ability_scores.intelligence, + wisdom=ability_scores.wisdom, + charisma=ability_scores.charisma, + atk_to_hit=atk_to_hit, + save_dc=save_dc, + ) + + @classmethod + def ac_from_json(cls, json_data) -> typing.Optional[int]: + max_ac_val = None + ac_vals = json_data["ac"] + for ac_val in ac_vals: + if isinstance(ac_val, dict): + ac_val = ac_val["ac"] + if max_ac_val is None: + max_ac_val = ac_val + else: + max_ac_val = max(max_ac_val, ac_val) + return max_ac_val + + @classmethod + def size_from_json(cls, json_data) -> typing.Optional[int]: + return [MonsterSize(s) for s in json_data["size"]] + + @classmethod + def cr_from_json(cls, json_data) -> float: + cr_val = json_data["cr"] + if isinstance(cr_val, dict): + cr_val = cr_val["cr"] + if cr_val == "1/8": + cr_val = 1 / 8 + elif cr_val == "1/4": + cr_val = 1 / 4 + elif cr_val == "1/2": + cr_val = 1 / 2 + return cr_val + + @classmethod + def hp_from_json(cls, json_data) -> int: + if "average" in json_data["hp"]: + return json_data["hp"]["average"] + return int(json_data["hp"]["special"]) + + @classmethod + def creature_type_from_json(cls, json_data) -> typing.List[str]: + type_val = json_data["type"] + if isinstance(type_val, dict): + tags = type_val.get("tags", []) + inner_type_val = type_val["type"] + if isinstance(inner_type_val, dict): + return tags + inner_type_val["choose"] + else: + return tags + [inner_type_val] + else: + return [type_val] + + @classmethod + def attack_to_hit_from_json(cls, json_data) -> typing.Optional[int]: + if "action" not in json_data: + return None + # actions + atk_to_hit = cls._parse_list_entries( + json_data.get("action", []), + re.compile(r"^\{@atk[^}]+\} .*\{@hit ([+-]?\d+)\}.*"), + ) + # spells + atk_to_hit.extend( + cls._parse_list_entries( + json_data.get("spellcasting", []), + re.compile(r".*\{@hit ([+-]?\d+)\}."), + key="headerEntries", + ) + ) + if len(atk_to_hit) == 0: + return None + return int(numpy.percentile(atk_to_hit, 50)) + + @classmethod + def save_dc_from_json(cls, json_data) -> typing.Optional[int]: + # spells + save_dcs = cls._parse_list_entries( + json_data.get("spellcasting", []), + re.compile(r".*spell save \{@dc ([+-]?\d+)\}."), + key="headerEntries", + ) + # actions + save_dcs.extend( + cls._parse_list_entries( + json_data.get("action", []), + re.compile(r".*\{@dc ([+-]?\d+)\}.*"), + ) + ) + # trailts + save_dcs.extend( + cls._parse_list_entries( + json_data.get("trait", []), + re.compile(r".*\{@dc ([+-]?\d+)\}.*"), + ) + ) + # legendary + save_dcs.extend( + cls._parse_list_entries( + json_data.get("legendary", []), + re.compile(r".*\{@dc ([+-]?\d+)\}.*"), + ) + ) + if len(save_dcs) == 0: + return None + return int(numpy.percentile(save_dcs, 50)) + + @classmethod + def _parse_list_entries( + cls, json_data_list, entry_re, key="entries" + ) -> typing.List[int]: + found = [] + for item in json_data_list: + for entry in item.get(key, []): + if isinstance(entry, str): + re_match = entry_re.match(entry) + if re_match is not None: + found.append(int(re_match.group(1))) + return found + + +class Bestiary: + def __init__(self, code: str, filepath: pathlib.Path) -> None: + self.code = code + self.filepath = filepath + self.monsters: typing.List[Monster] = self.parse_bestiary(self.filepath) + + @staticmethod + def parse_bestiary(filepath: pathlib.Path) -> typing.List[Monster]: + try: + with filepath.open("rt") as bestiary_file: + bestiary_data = json.load(bestiary_file) + except Exception as e: + raise Exception(f"Failed to read bestiary file {filepath}: {e}") + monsters: typing.List[Monster] = [] + for monster_data in bestiary_data["monster"]: + if "_copy" in monster_data: + continue # Ignore monsters that are just tweaks or reprints + try: + monsters.append(Monster.from_json(monster_data)) + except Exception as e: + print(f"[WARN] Failed to parse monster in {filepath} -- {e}") + continue + return monsters + + +class BestiaryDb: + def __init__(self, data_dir: pathlib.Path) -> None: + self.db_index = dnd5etools.db.DbIndex(data_dir, "bestiary") + self.bestiary_cache: typing.Dict[str, Bestiary] = {} + + def get_bestiary(self, code: str) -> Bestiary: + if code in self.bestiary_cache: + return self.bestiary_cache[code] + if code in self.source_index: + bestiary = Bestiary(code, self.source_index[code]) + self.bestiary_cache[code] = bestiary + return bestiary + raise Exception(f"No bestiary matching code {code} found in index") diff --git a/dnd5etools/db/spells.py b/dnd5etools/db/spells.py new file mode 100644 index 0000000..73c66fb --- /dev/null +++ b/dnd5etools/db/spells.py @@ -0,0 +1,93 @@ +import enum +import json +import pathlib +import typing +import dnd5etools.db + + +class Ability(enum.StrEnum): + Strength = "strength" + Dexterity = "dexterity" + Constitution = "constitution" + Intelligence = "intelligence" + Wisdom = "wisdom" + Charisma = "charisma" + + +class Spell(typing.NamedTuple): + name: str + source: str + description: str + ability_check: typing.Optional[Ability] + # remaining: 'affectsCreatureType', 'alias', 'areaTags', 'basicRules2024', 'components', 'conditionImmune', 'conditionInflict', 'damageImmune', 'damageInflict', 'damageResist', 'damageVulnerable', 'duration', 'entriesHigherLevel', 'hasFluffImages', 'level', 'meta', 'miscTags', 'page', 'range', 'savingThrow', 'scalingLevelDice', 'school', 'spellAttack', 'srd52', 'time' + + @classmethod + def from_json(cls, json_data) -> typing.Self: + name = json_data["name"] + source = json_data["source"] + try: + ability_check = cls.ability_check_from_json(json_data) + except Exception as e: + raise Exception(f"Unable to parse abilityCheck for {name}: {e}") + try: + description = cls.description_from_json(json_data) + except Exception as e: + raise Exception(f"Unable to parse description for {name}: {e}") + return cls( + name=name, + source=source, + description=description, + ability_check=ability_check, + ) + + @classmethod + def ability_check_from_json(cls, json_data) -> typing.Optional[Ability]: + if "abilityCheck" not in json_data: + return None + elif len(json_data["abilityCheck"]) > 1: + raise Exception(f"Unexpected abilityCheck length") + return Ability(json_data["abilityCheck"][0]) + + @classmethod + def description_from_json(cls, json_data) -> str: + return " ".join([str(e) for e in json_data["entries"]]) + + +class SpellList: + def __init__(self, code: str, filepath: pathlib.Path) -> None: + self.code = code + self.filepath = filepath + self.spells: typing.List[Spell] = self.parse_spell_list(self.filepath) + + @staticmethod + def parse_spell_list(filepath: pathlib.Path) -> typing.List[Spell]: + try: + with filepath.open("rt") as spell_list_file: + spell_list_data = json.load(spell_list_file) + except Exception as e: + raise Exception(f"Failed to read spells file {filepath}: {e}") + spells: typing.List[Spell] = [] + for spell_data in spell_list_data["spell"]: + if "_copy" in spell_data: + continue # Ignore spells that are just tweaks or reprints + try: + spells.append(Spell.from_json(spell_data)) + except Exception as e: + print(f"[WARN] Failed to parse spell in {filepath} -- {e}") + continue + return spells + + +class SpellsDb: + def __init__(self, data_dir: pathlib.Path) -> None: + self.db_index = dnd5etools.db.DbIndex(data_dir, "spells") + self.spell_list_cache: typing.dict[str, SpellList] = {} + + def get_spell_list(self, code: str) -> SpellList: + if code in self.spell_list_cache: + return self.spell_list_cache[code] + if code in self.db_index.source_index: + spell_list = SpellList(code, self.db_index.source_index[code]) + self.spell_list_cache[code] = spell_list + return spell_list + raise Exception(f"No spell list matching code {code} found in index") diff --git a/dnd5etools/scripts/__init__.py b/dnd5etools/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dnd5etools/scripts/argparse.py b/dnd5etools/scripts/argparse.py new file mode 100644 index 0000000..d3a385c --- /dev/null +++ b/dnd5etools/scripts/argparse.py @@ -0,0 +1,36 @@ +import argparse +import os +import re +import pathlib + + +def build_argument_parser(description: str) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + "--data-dir", + "-d", + type=valid_directory, + required=True, + help="Data directory from 5etools", + ) + parser.add_argument( + "--source-code", + "-s", + action="append", + metavar="CODE", + type=valid_source_code, + help="Filter data to just the given sources, may be provided multiple times", + ) + return parser + + +def valid_directory(path: str) -> pathlib.Path: + if os.path.isdir(path): + return pathlib.Path(os.path.realpath(path)) + raise argparse.ArgumentTypeError(f"{path} is not a directory") + + +def valid_source_code(code: str) -> str: + if re.match(r"^[A-Z]+$", code) is None: + raise argparse.ArgumentTypeError(f"{code} is not a valid data source code") + return code diff --git a/dnd5etools/scripts/spell_templates.py b/dnd5etools/scripts/spell_templates.py new file mode 100644 index 0000000..6379432 --- /dev/null +++ b/dnd5etools/scripts/spell_templates.py @@ -0,0 +1,17 @@ +import pprint +import dnd5etools.db.spells +import dnd5etools.scripts.argparse + + +def main(): + args = dnd5etools.scripts.argparse.build_argument_parser( + "Summarizes spell templates", + ).parse_args() + db = dnd5etools.db.spells.SpellsDb(args.data_dir) + if args.source_code is None: + codes = list(db.db_index.source_index.keys()) + else: + codes = args.source_code + for code in codes: + spell_list = db.get_spell_list(code) + pprint.pprint(spell_list.spells) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e10a04e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +#urls = { repository = "https://gitlab.krugerlabs.us/krugd/dnd_transcribe" } +authors = [{ name = "David Kruger" }] +name = "dnd_5etools_utils" +version = "1.0.0" +description = "Random utilities using the 5etools data" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +license-files = ["LICENSE"] + +dependencies = [ +] + + +[project.scripts] +spell_templates = "dnd5etools.scripts.spell_templates:main" + +[build-system] +requires = ["setuptools"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["dnd5etools*"]