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")