Compare commits

...

11 Commits

Author SHA1 Message Date
krugd b7b9e00f19 Tweaked output 2025-05-22 17:33:32 -07:00
krugd 96624631b2 spell_templates working
Merged together cylinders+circles+spheres and squares+cubes for the
purpose of printing templates. Also added additional filtering to remove
spells just mentioning shining light, and ignoring self-targetting
spells.
2025-05-21 16:54:29 -07:00
krugd 632a8c4f1b Exclude emanations from the list 2025-05-20 23:28:48 -07:00
krugd 13458a0173 Working on a filter to find spells requiring a template of some sort 2025-05-20 23:26:23 -07:00
krugd 27eb488e78 Parse the spell range mostly 2025-05-20 23:26:06 -07:00
krugd 6cab3b289a Parsing the spell attack type and area type 2025-05-20 22:13:17 -07:00
krugd 48b41ca885 Parse spell duration 2025-05-20 21:40:33 -07:00
krugd b992873072 Support multiple ability checks 2025-05-20 21:15:48 -07:00
krugd 1ad7b2c502 Updating URL in pyproject 2025-05-20 20:45:35 -07:00
krugd 4253495dc5 Merge remote-tracking branch 'origin/main' 2025-05-20 20:42:24 -07:00
krugd 4ee91edc61 Start of 5etools utilities and library
Copying over the bestiary from dnd_monster_stats and working on a
similar SpellList object and Spell DB.
2025-05-20 20:33:55 -07:00
9 changed files with 752 additions and 1 deletions
+5 -1
View File
@@ -1,3 +1,8 @@
# Vim files
*.swp
*.swo
*.log
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -167,4 +172,3 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
View File
+28
View 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
View 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")
+235
View File
@@ -0,0 +1,235 @@
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 DurationType(enum.StrEnum):
Timed = "timed"
Instant = "instant"
Permanent = "permanent"
Special = "special"
class DurationTimeType(enum.StrEnum):
Round = "round"
Minute = "minute"
Hour = "hour"
Day = "day"
class Duration(typing.NamedTuple):
type: DurationType
time_type: typing.Optional[DurationTimeType]
time_value: typing.Optional[int]
concentration: bool
end_condition: typing.Set[str] # TODO: replace with a full enum
@classmethod
def from_json(cls, json_data) -> typing.Self:
dt = DurationType(json_data["type"])
return cls(
type=dt,
time_type=(
DurationTimeType(json_data["duration"]["type"])
if dt == DurationType.Timed
else None
),
time_value=(
int(json_data["duration"]["amount"])
if dt == DurationType.Timed
else None
),
concentration=json_data.get("concentration", False),
end_condition=set(json_data.get("end", [])),
)
class SpellAttackType(enum.StrEnum):
Melee = "M"
Ranged = "R"
Other = "O"
class SpellAreaType(enum.StrEnum):
SingleTarget = "ST"
MultipleTargets = "MT"
Cube = "C"
Cone = "N"
Cylinder = "Y"
Sphere = "S"
Circle = "R"
Square = "Q"
Line = "L"
Hemisphere = "H"
Wall = "W"
class SpellRangeType(enum.StrEnum):
Special = "special"
Point = "point"
Line = "line"
Cube = "cube"
Cone = "cone"
Emanation = "emanation"
Radius = "radius"
Sphere = "sphere"
Hemisphere = "hemisphere"
Cylinder = "cylinder"
Self = "self"
Sight = "sight"
Unlimited = "unlimited"
UnlimitedSamePlane = "plane"
Touch = "touch"
class SpellRange(typing.NamedTuple):
type: SpellRangeType
distance_type: str # TODO Replace with a full enum
distance_value: typing.Optional[int]
@classmethod
def from_json(cls, json_data) -> typing.Self:
return cls(
type=SpellRangeType(json_data["type"]),
distance_type=json_data["distance"]["type"],
distance_value=(
int(json_data["distance"]["amount"])
if "amount" in json_data["distance"]
else None
),
)
class Spell(typing.NamedTuple):
name: str
source: str
description: str
ability_check: typing.Set[Ability]
duration: typing.List[Duration]
attack_type: typing.Set[SpellAttackType]
area_type: typing.Set[SpellAreaType]
range: SpellRange
# remaining: 'affectsCreatureType', 'alias', 'basicRules2024', 'components', 'conditionImmune', 'conditionInflict', 'damageImmune', 'damageInflict', 'damageResist', 'damageVulnerable', 'entriesHigherLevel', 'hasFluffImages', 'level', 'meta', 'miscTags', 'page', 'savingThrow', 'scalingLevelDice', 'school', '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}")
try:
duration = cls.duration_from_json(json_data)
except Exception as e:
raise Exception(f"Unable to parse duration for {name}: {e}")
try:
attack_type = cls.attack_type_from_json(json_data)
except Exception as e:
raise Exception(f"Unable to parse spellAttack for {name}: {e}")
try:
area_type = cls.area_type_from_json(json_data)
except Exception as e:
raise Exception(f"Unable to parse areaTags for {name}: {e}")
try:
range = cls.range_from_json(json_data)
except Exception as e:
raise Exception(f"Unable to parse range for {name}: {e}")
return cls(
name=name,
source=source,
description=description,
ability_check=ability_check,
duration=duration,
attack_type=attack_type,
area_type=area_type,
range=range,
)
@classmethod
def ability_check_from_json(cls, json_data) -> typing.Set[Ability]:
return cls.json_str_enum_list(json_data, "abilityCheck", Ability)
@classmethod
def attack_type_from_json(cls, json_data) -> typing.Set[SpellAttackType]:
return cls.json_str_enum_list(json_data, "spellAttack", SpellAttackType)
@classmethod
def area_type_from_json(cls, json_data) -> typing.Set[SpellAreaType]:
return cls.json_str_enum_list(json_data, "areaTags", SpellAreaType)
@classmethod
def json_str_enum_list(
cls, json_data, key: str, enum_cls: typing.Type[enum.StrEnum]
) -> typing.Set[enum.StrEnum]:
if key not in json_data:
return set()
return {enum_cls(c) for c in json_data[key]}
@classmethod
def description_from_json(cls, json_data) -> str:
return " ".join([str(e) for e in json_data["entries"]])
@classmethod
def duration_from_json(cls, json_data) -> typing.List[Duration]:
return [Duration.from_json(d) for d in json_data["duration"]]
@classmethod
def range_from_json(cls, json_data) -> typing.List[SpellRange]:
return SpellRange.from_json(json_data["range"])
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] = []
dbg_duration_type = set()
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")
View File
+36
View 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
+116
View File
@@ -0,0 +1,116 @@
import collections
import pprint
import re
import typing
import dnd5etools.db.spells
import dnd5etools.scripts.argparse
area_size_re = re.compile(r"\d+-foot", flags=re.IGNORECASE)
light_size_re = re.compile(r"(light|light\|\w+\}) in a \d+-foot", flags=re.IGNORECASE)
circle_size_re = re.compile(
r"(?P<size>\d+)-foot-radius[ -](sphere|circle)", flags=re.IGNORECASE
)
radius_size_re = re.compile(r"(?P<size>\d+)-foot radius", flags=re.IGNORECASE)
circle_diam_size_re = re.compile(
r"(?P<size>\d+)-foot-diameter[ -](sphere|circle)", flags=re.IGNORECASE
)
cylinder_size_re = re.compile(
r"(?P<size>\d+)-foot-radius, \d+-foot[ -](high|tall) cylinder", flags=re.IGNORECASE
)
alt_cylinder_size_re = re.compile(
r"\d+-foot[ -](high|tall), (?P<size>\d+)-foot-radius cylinder", flags=re.IGNORECASE
)
square_size_re = re.compile(r"(?P<size>\d+)-foot[ -](cube|square)", flags=re.IGNORECASE)
cone_size_re = re.compile(r"(?P<size>\d+)-foot cone", flags=re.IGNORECASE)
line_size_re = re.compile(
r"\d+-foot-wide, (?P<size>\d+)-foot[ -]long line", flags=re.IGNORECASE
)
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
all_template_spells = []
for code in codes:
all_template_spells += filter(is_template_spell, db.get_spell_list(code).spells)
num_matches = 0
circles = collections.defaultdict(list)
squares = collections.defaultdict(list)
cones = collections.defaultdict(list)
lines = collections.defaultdict(list)
for spell in all_template_spells:
found = False
for search_re, dest in [
(line_size_re, lines),
(circle_size_re, circles),
(cylinder_size_re, circles),
(circle_diam_size_re, circles),
(alt_cylinder_size_re, circles),
(square_size_re, squares),
(cone_size_re, cones),
(radius_size_re, circles),
]:
m = search_re.search(spell.description)
if m is not None:
size = int(m.group("size"))
if search_re not in (
cone_size_re,
square_size_re,
circle_diam_size_re,
line_size_re,
):
size *= 2
dest[size].append(spell.name)
found = True
num_matches += 1
break
print(f"{num_matches} matched from {len(all_template_spells)}")
print("circles/diameter")
print_sizes(circles, 2)
print("squares/size")
print_sizes(squares, 2)
print("cones/size")
print_sizes(cones, 2)
print("lines/length")
print_sizes(lines, 2)
def is_template_spell(spell: dnd5etools.db.spells.Spell) -> bool:
num_area_sizes = 0
for _ in area_size_re.finditer(spell.description):
num_area_sizes += 1
num_light_sizes = 0
for _ in light_size_re.finditer(spell.description):
num_light_sizes += 1
return (
len(spell.area_type) > 0
and not "Wall" in spell.name
and has_timed_duration(spell)
and spell.range.type != dnd5etools.db.spells.SpellRangeType.Emanation
and spell.range.distance_type != "self"
and num_area_sizes > 0
and num_light_sizes < num_area_sizes
)
def has_timed_duration(spell: dnd5etools.db.spells.Spell) -> bool:
for sd in spell.duration:
if sd.type != dnd5etools.db.spells.DurationType.Instant:
return True
return False
def print_sizes(sizes_dict: typing.Mapping[int, int], indent: int) -> None:
indent_str = " " * indent
for s in sorted(sizes_dict.items(), key=lambda kv: len(kv[1]), reverse=True):
print(f"{indent_str} {s[0]}ft/{s[0]/5}in => {len(s[1])}")
print(
f"{indent_str}{indent_str}{pprint.pformat(s[1], indent=3*indent, compact=True)}"
)
+27
View File
@@ -0,0 +1,27 @@
[project]
urls = { repository = "https://gitlab.krugerlabs.us/krugd/dnd_5etools_utils" }
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*"]