Added ability to parse JSON files exported from Foundry.

This commit is contained in:
Mark Wolfman
2021-05-21 09:47:40 -05:00
parent 9b1a33bfa2
commit 90e362055a
9 changed files with 3599 additions and 171 deletions
+305 -44
View File
@@ -7,7 +7,6 @@ import logging
from pathlib import Path
from dungeonsheets import exceptions
log = logging.getLogger(__file__)
@@ -30,17 +29,28 @@ def read_character_file(filename: str):
# Parse the file name
ext = filename.suffix
try:
reader = readers_by_extension[ext]()
reader = readers_by_extension[ext](filename=filename)
except KeyError:
raise ValueError(f"Character definition {filename} is not a known file type.")
else:
new_char = reader(filename=filename)
new_char = reader()
return new_char
class BaseCharacterReader:
"""Callable to parse a generic character file. Meant to be subclassed."""
def __init__(self, filename: str):
"""
Parameters
----------
filename
The path to the file that will be imported.
"""
self.filename = filename
def __call__(self, filename):
"""
Parameters
@@ -51,17 +61,24 @@ class BaseCharacterReader:
raise NotImplementedError()
def json_character_reader(filename: str):
"""Factory to create a JSON reader of the correct sub-type."""
# Try and extract the schema version to see if it's valid
json_reader = Roll20CharacterReader(filename)
try:
json_reader.schema_version
except KeyError:
# Assume it's a foundry file
json_reader = FoundryCharacterReader(filename)
return json_reader
class JSONCharacterReader(BaseCharacterReader):
"""Callable to parse a JSON character file from Roll20 VVTES.
The definition file should be a JSON file following one of the
supported formats, filled with variables describing the character.
Parameters
----------
filename
The path to the file that will be imported.
"""
@lru_cache()
@@ -78,6 +95,12 @@ class JSONCharacterReader(BaseCharacterReader):
val = 0
return val
class Roll20CharacterReader(JSONCharacterReader):
@property
def schema_version(self):
return self.json_data()["schema_version"]
def get_attrib(self, key, which="current", default=None):
for obj in self.json_data()["attribs"]:
if obj["name"] == key:
@@ -93,35 +116,6 @@ class JSONCharacterReader(BaseCharacterReader):
false_profs = ["", "0"]
return self.get_attrib(key=f"{key}_prof") not in false_profs
def spells(self, prepared: bool = False):
"""Iterator over the spells the character knows.
Parameters
==========
prepared
If true, only return prepared spells.
"""
# "name": "repeating_spell-cantrip_-MEzYWPA5cUZYd4ZOvMS_spellname",
prof_re = re.compile(
"repeating_spell-(cantrip|[0-9]+)_([-0-9a-zA-Z]+)_spellname"
)
for obj in self.json_data()["attribs"]:
match = prof_re.match(obj["name"])
if match:
level = match.group(1)
spell_id = match.group(2)
spell_name = self.get_attrib(
f"repeating_spell-{level}_{spell_id}_spellname"
)
is_prepared = self.as_int(
self.get_attrib(
f"repeating_spell-{level}_{spell_id}_spellprepared", default=0
)
)
if not prepared or is_prepared:
yield spell_name.lower()
def proficiencies(self, kind=None):
"""Iterator over the skills in which the character is proficient.
@@ -174,14 +168,41 @@ class JSONCharacterReader(BaseCharacterReader):
weapon_name = weapon_name.split(",")[0].strip()
yield weapon_name
def __call__(self, filename: str):
def spells(self, prepared: bool = False):
"""Iterator over the spells the character knows.
Parameters
==========
prepared
If true, only return prepared spells.
"""
# "name": "repeating_spell-cantrip_-MEzYWPA5cUZYd4ZOvMS_spellname",
prof_re = re.compile(
"repeating_spell-(cantrip|[0-9]+)_([-0-9a-zA-Z]+)_spellname"
)
for obj in self.json_data()["attribs"]:
match = prof_re.match(obj["name"])
if match:
level = match.group(1)
spell_id = match.group(2)
spell_name = self.get_attrib(
f"repeating_spell-{level}_{spell_id}_spellname"
)
is_prepared = self.as_int(
self.get_attrib(
f"repeating_spell-{level}_{spell_id}_spellprepared", default=0
)
)
if not prepared or is_prepared:
yield spell_name.lower()
def __call__(self):
"""Create a character property dictionary from the JSON file."""
# Verify the version compatibility
self.filename = filename
version = self.json_data()["schema_version"]
if version != 2:
if self.schema_version != 2:
raise exceptions.JSONFormatError(
"Cannot parse JSON schema version: %s" % version
"Cannot parse JSON schema version: %s" % self.schema_version
)
# Parse the json tree to get character properties
char_props = {}
@@ -274,8 +295,247 @@ class JSONCharacterReader(BaseCharacterReader):
return char_props
class FoundryCharacterReader(JSONCharacterReader):
def _skill_proficiency_value(self, key: str) -> float:
proficiency_labels = {
"acrobatics": "acr",
"animal_handling": "ani",
"arcana": "arc",
"athletics": "ath",
"deception": "dec",
"history": "his",
"insight": "ins",
"intimidation": "itm",
"investigation": "inv",
"medicine": "med",
"nature": "nat",
"perception": "prc",
"performance": "prf",
"persuasion": "per",
"religion": "rel",
"sleight_of_hand": "slt",
"stealth": "ste",
"survival": "sur",
}
proficiency_value = float(
self.json_data()["data"]["skills"][proficiency_labels[key]]["value"]
)
return proficiency_value
def skill_proficiency(self, key: str) -> bool:
return self._skill_proficiency_value(key) >= 1.0
def skill_expertise(self, key: str) -> bool:
return self._skill_proficiency_value(key) > 1.0
def proficiencies(self, kind=None):
"""Iterator over the skills in which the character is proficient.
*kind* can be one of "weapon", "language", or None (all
proficiencies).
"""
yield_weapons = kind is None or kind == "weapon"
yield_languages = kind is None or kind == "language"
# Weapon proficiencies
if yield_weapons:
weapon_prof = self.json_data()["data"]["traits"]["weaponProf"]
# Simple and martial weapons
if "sim" in weapon_prof["value"]:
yield "simple weapons"
if "mar" in weapon_prof["value"]:
yield "martial weapons"
# Extra weapons
for weapon in weapon_prof["custom"].split(";"):
yield weapon.strip()
if yield_languages:
# Languages
lang_json = self.json_data()["data"]["traits"]["languages"]
languages = lang_json["value"]
languages.extend([s.strip() for s in lang_json["custom"].split(";")])
yield from languages
def weapons(self):
"""Iterator over the weapons the character is carrying in her inventory."""
items = self.json_data()["items"]
for item in items:
if item["type"] == "weapon" and item["name"] != "<no name>":
yield item["name"].lower()
def armor(self):
items = self.json_data()["items"]
armor_types = ["light", "medium", "heavy"]
for item in items:
if (
item["type"] == "equipment"
and item["data"]["armor"]["type"] in armor_types
):
return item["name"].lower()
def shield(self):
items = self.json_data()["items"]
for item in items:
if (
item["type"] == "equipment"
and item["data"]["armor"]["type"] == "shield"
):
return item["name"].lower()
def equipment(self):
"""Iterator over the non-weapons the character is carrying in her
inventory.
"""
items = self.json_data()["items"]
item_types = ["consumable", "equipment", "tool", "backpack", "loot"]
for item in items:
if item["type"] in item_types:
item_name = item["name"]
quantity = self.as_int(item["data"]["quantity"])
if quantity != 1:
item_name += f"({quantity})"
yield item_name.lower()
def class_levels(self):
for item in self.json_data()["items"]:
if item["type"] == "class":
yield (item["name"], item["data"]["levels"])
def spells(self, prepared: bool = False):
"""Iterator over the spells the character knows.
Parameters
==========
prepared
If true, only return prepared spells.
"""
spells = (item for item in self.json_data()["items"] if item["type"] == "spell")
if prepared:
spells = (d for d in spells if d["data"]["preparation"]["prepared"])
spell_names = (d["name"] for d in spells)
yield from spell_names
def __call__(self):
"""Create a character property dictionary from the JSON file."""
# Parse the json tree to get character properties
char_props = {}
json_data = self.json_data()
details = json_data["data"]["details"]
char_props["name"] = json_data["name"]
char_props["background"] = details["background"]
char_props["race"] = details["race"]
char_props["alignment"] = details["alignment"]
char_props["xp"] = self.as_int(details["xp"]["value"])
classes, levels = zip(*self.class_levels())
char_props["levels"] = list(levels)
char_props["classes"] = list(classes)
# Attributes
attribute_names = {
"str": "strength",
"dex": "dexterity",
"con": "constitution",
"int": "intelligence",
"wis": "wisdom",
"cha": "charisma",
}
abilities = self.json_data()["data"]["abilities"]
save_proficiences = []
for abbr, attr in attribute_names.items():
char_props[attr] = self.as_int(abilities[abbr]["value"])
# Check proficiency
is_proficient = bool(abilities[abbr]["proficient"])
if is_proficient:
save_proficiences.append(attr)
char_props["saving_throw_proficiencies"] = save_proficiences
# Skill proficiencies
skill_names = [
"acrobatics",
"animal_handling",
"arcana",
"athletics",
"deception",
"history",
"insight",
"intimidation",
"investigation",
"medicine",
"nature",
"perception",
"performance",
"persuasion",
"religion",
"sleight_of_hand",
"stealth",
"survival",
]
skill_profs = [skill for skill in skill_names if self.skill_proficiency(skill)]
char_props["skill_proficiencies"] = skill_profs
skill_expertise = [
skill for skill in skill_names if self.skill_expertise(skill)
]
char_props["skill_expertise"] = skill_expertise
# Other proficiencies
char_props["weapon_proficiencies"] = self.proficiencies("weapon")
char_props["languages"] = ", ".join(self.proficiencies("language"))
# Tool proficiencies
tool_labels = {
"art": "artisan's tools",
"disg": "disguise kit",
"forg": "forger's kit",
"game": "gaming set",
"herb": "herbalism kit",
"music": "musical instrument",
"navg": "navigator's tools",
"pois": "poisoner's kit",
"thief": "thieves' tools",
"vehicle": "vehicle (land or water)",
}
tool_profs = json_data["data"]["traits"]["toolProf"]["value"]
tool_profs = [tool_labels[prof] for prof in tool_profs]
custom_tool_profs = json_data["data"]["traits"]["toolProf"]["custom"]
tool_profs.extend([s.strip() for s in custom_tool_profs.split(";")])
char_props["_proficiencies_text"] = tool_profs
# Combat stats
char_props["hp_max"] = self.as_int(
json_data["data"]["attributes"]["hp"]["value"]
)
# Equipment
currency = json_data["data"]["currency"]
char_props["cp"] = currency["cp"]
char_props["sp"] = currency["sp"]
char_props["ep"] = currency["ep"]
char_props["gp"] = currency["gp"]
char_props["pp"] = currency["pp"]
char_props["weapons"] = list(self.weapons())
char_props["equipment"] = ", ".join(self.equipment())
char_props["armor"] = self.armor()
char_props["shield"] = self.shield()
# Personality, etc
char_props["personality_traits"] = details["trait"].strip()
char_props["flaws"] = details["flaw"].strip()
char_props["ideals"] = details["ideal"].strip()
char_props["bonds"] = details["bond"].strip()
# Spells
char_props["spells"] = self.spells()
char_props["spells_prepared"] = self.spells(prepared=True)
# Some unused values
warn_msg = (
"Importing the following traits from JSON is not yet supported: "
"magic_items, attacks_and_spellcasting, "
"infusions, wild_shapes."
)
warnings.warn(warn_msg)
log.warning(warn_msg)
char_props["magic_items"] = ()
char_props["attacks_and_spellcasting"] = ""
char_props["infusions"] = []
char_props["wild_shapes"] = []
return char_props
class PythonCharacterReader(BaseCharacterReader):
def __call__(self, filename: str):
def __call__(self):
"""Create a character object from the given definition file.
The definition file should be an importable python file, filled
@@ -287,6 +547,7 @@ class PythonCharacterReader(BaseCharacterReader):
The path to the file that will be imported.
"""
filename = self.filename
# Check if this file contains the version string
version_re = re.compile(r'dungeonsheets_version\s*=\s*[\'"]([0-9.]+)[\'"]')
with open(filename, mode="r") as f:
@@ -315,5 +576,5 @@ class PythonCharacterReader(BaseCharacterReader):
readers_by_extension = {
".py": PythonCharacterReader,
".json": JSONCharacterReader,
".json": json_character_reader,
}