Copying over the bestiary from dnd_monster_stats and working on a similar SpellList object and Spell DB.
306 lines
9.5 KiB
Python
306 lines
9.5 KiB
Python
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")
|