Start of 5etools utilities and library
Copying over the bestiary from dnd_monster_stats and working on a similar SpellList object and Spell DB.
This commit is contained in:
commit
4ee91edc61
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/.env/
|
||||
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
0
dnd5etools/__init__.py
Normal file
0
dnd5etools/__init__.py
Normal file
28
dnd5etools/db/__init__.py
Normal file
28
dnd5etools/db/__init__.py
Normal file
@ -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}"
|
||||
)
|
305
dnd5etools/db/bestiary.py
Normal file
305
dnd5etools/db/bestiary.py
Normal file
@ -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")
|
93
dnd5etools/db/spells.py
Normal file
93
dnd5etools/db/spells.py
Normal file
@ -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")
|
0
dnd5etools/scripts/__init__.py
Normal file
0
dnd5etools/scripts/__init__.py
Normal file
36
dnd5etools/scripts/argparse.py
Normal file
36
dnd5etools/scripts/argparse.py
Normal file
@ -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
|
17
dnd5etools/scripts/spell_templates.py
Normal file
17
dnd5etools/scripts/spell_templates.py
Normal file
@ -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)
|
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@ -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*"]
|
Loading…
x
Reference in New Issue
Block a user