mirror of
https://github.com/Threnklyn/dungeon-sheets.git
synced 2026-05-18 20:23:27 +02:00
281 lines
11 KiB
Python
281 lines
11 KiB
Python
import importlib
|
|
import warnings
|
|
import json
|
|
import re
|
|
from functools import lru_cache
|
|
import logging
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from dungeonsheets import exceptions
|
|
from dungeonsheets import spells
|
|
|
|
log = logging.getLogger(__file__)
|
|
|
|
|
|
def read_character_file(filename: str):
|
|
"""Create a character object from the given definition file.
|
|
|
|
The definition file should be an importable python file or 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.
|
|
|
|
"""
|
|
filename = Path(filename)
|
|
# Parse the file name
|
|
dir_ = filename.parent
|
|
fname = filename.name
|
|
ext = filename.suffix
|
|
try:
|
|
reader = readers_by_extension[ext]()
|
|
except KeyError:
|
|
raise ValueError(f"Character definition {filename} is not a known file type.")
|
|
else:
|
|
new_char = reader(filename=filename)
|
|
return new_char
|
|
|
|
|
|
class BaseCharacterReader():
|
|
"""Callable to parse a generic character file. Meant to be subclassed."""
|
|
def __call__(self, filename):
|
|
"""
|
|
Parameters
|
|
----------
|
|
filename
|
|
The path to the file that will be imported.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
|
|
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()
|
|
def json_data(self):
|
|
# Load the JSON data from disk
|
|
with open(self.filename, mode='r') as fp:
|
|
data = json.load(fp)
|
|
return data
|
|
|
|
def as_int(self, val):
|
|
try:
|
|
val = int(val)
|
|
except ValueError:
|
|
val = 0
|
|
return val
|
|
|
|
def get_attrib(self, key, which="current", default=None):
|
|
for obj in self.json_data()['attribs']:
|
|
if obj['name'] == key:
|
|
val = obj[which]
|
|
return val
|
|
# No object was found
|
|
if default is not None:
|
|
return default
|
|
else:
|
|
raise KeyError(key)
|
|
|
|
def has_skill_proficiency(self, key):
|
|
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.
|
|
|
|
*kind* can be one of "weapon", "language", or None (all
|
|
proficiencies).
|
|
|
|
"""
|
|
prof_re = re.compile("repeating_proficiencies_([-0-9a-zA-Z]+)_name")
|
|
for obj in self.json_data()['attribs']:
|
|
match = prof_re.match(obj['name'])
|
|
if match:
|
|
prof_id = match.group(1)
|
|
prof_type = self.get_attrib(f"repeating_proficiencies_{prof_id}_prof_type")
|
|
if kind is None or prof_type == kind.upper():
|
|
yield self.get_attrib(f"repeating_proficiencies_{prof_id}_name").lower()
|
|
|
|
def equipment(self, kind=None):
|
|
"""Iterator over items in the character's inventory.
|
|
|
|
"""
|
|
prof_re = re.compile("repeating_inventory_([-0-9a-zA-Z]+)_itemname")
|
|
for obj in self.json_data()['attribs']:
|
|
match = prof_re.match(obj['name'])
|
|
if match:
|
|
item_id = match.group(1)
|
|
item_name = self.get_attrib(match.group(0))
|
|
item_count = int(self.get_attrib(f"repeating_inventory_{item_id}_itemcount", default=1))
|
|
item_weight = self.get_attrib(f"repeating_inventory_{item_id}_itemweight", default=0)
|
|
item_str = item_name.lower().strip()
|
|
if item_count > 1:
|
|
item_str += f" ({item_count})"
|
|
yield item_str
|
|
|
|
def weapons(self):
|
|
"""Iterator over the weapons the character is carrying in her inventory."""
|
|
item_re = re.compile("repeating_attack_([-0-9a-zA-Z]+)_atkname")
|
|
for obj in self.json_data()['attribs']:
|
|
match = item_re.match(obj['name'])
|
|
if match:
|
|
weapon_name = self.get_attrib(match.group()).lower()
|
|
if weapon_name[:3] == "i. ":
|
|
# Ignore artificer infusions
|
|
warnings.warn("Ignoring weapon infusion")
|
|
else:
|
|
weapon_name = weapon_name.split('(')[0].strip()
|
|
weapon_name = weapon_name.split(',')[0].strip()
|
|
yield weapon_name
|
|
|
|
def __call__(self, filename: str):
|
|
"""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:
|
|
raise exceptions.JSONFormatError("Cannot parse JSON schema version: %s" % version)
|
|
# Parse the json tree to get character properties
|
|
char_props = {}
|
|
char_props['name'] = self.json_data()['name']
|
|
char_props['level'] = self.as_int(self.get_attrib('base_level'))
|
|
char_props['classes'] = [self.get_attrib('class')]
|
|
char_props['background'] = self.get_attrib('background')
|
|
char_props['race'] = self.get_attrib('subrace')
|
|
char_props['alignment'] = self.get_attrib('alignment')
|
|
char_props['xp'] = self.as_int(self.get_attrib('experience', default=0))
|
|
# Attributes
|
|
attribute_names = ['strength', 'dexterity', 'constitution',
|
|
'intelligence', 'wisdom', 'charisma']
|
|
for attr in attribute_names:
|
|
char_props[attr] = self.as_int(self.get_attrib(f"{attr}_base"))
|
|
# 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.has_skill_proficiency(skill)]
|
|
char_props['skill_proficiencies'] = skill_profs
|
|
# Other proficiencies
|
|
char_props['weapon_proficiencies'] = self.proficiencies("weapon")
|
|
char_props['languages'] = ", ".join(self.proficiencies("language"))
|
|
# Tool proficiencies
|
|
prof_re = re.compile("repeating_tool_([-0-9a-zA-Z]+)_toolname")
|
|
tool_profs = []
|
|
for obj in self.json_data()['attribs']:
|
|
match = prof_re.match(obj['name'])
|
|
if match:
|
|
tool_profs.append(self.get_attrib(match.group(0)))
|
|
char_props['_proficiencies_text'] = tool_profs
|
|
# Combat stats
|
|
char_props['hp_max'] = self.as_int(self.get_attrib('hp', which="max"))
|
|
# Equipment
|
|
char_props['cp'] = self.as_int(self.get_attrib('cp', default=0))
|
|
char_props['sp'] = self.as_int(self.get_attrib('sp', default=0))
|
|
char_props['ep'] = self.as_int(self.get_attrib('ep', default=0))
|
|
char_props['gp'] = self.as_int(self.get_attrib('gp', default=0))
|
|
char_props['pp'] = self.as_int(self.get_attrib('pp', default=0))
|
|
char_props['weapons'] = self.weapons()
|
|
char_props['equipment'] = ", ".join(self.equipment())
|
|
# Personality, etc
|
|
char_props['personality_traits'] = self.get_attrib('personality_traits').strip()
|
|
char_props['flaws'] = self.get_attrib('flaws').strip()
|
|
char_props['ideals'] = self.get_attrib('ideals').strip()
|
|
char_props['bonds'] = self.get_attrib('bonds').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, armor, shield, attacks_and_spellcasting, "
|
|
"infusions, wild_shapes")
|
|
warnings.warn(warn_msg)
|
|
log.warning(warn_msg)
|
|
char_props['magic_items'] = ()
|
|
char_props['armor'] = ""
|
|
char_props["shield"] = ""
|
|
char_props["attacks_and_spellcasting"] = ""
|
|
char_props["infusions"] = []
|
|
char_props["wild_shapes"] = []
|
|
return char_props
|
|
|
|
|
|
class PythonCharacterReader(BaseCharacterReader):
|
|
def __call__(self, filename: str):
|
|
"""Create a character object from the given definition file.
|
|
|
|
The definition file should be an importable python file, filled
|
|
with variables describing the character.
|
|
|
|
Parameters
|
|
----------
|
|
filename
|
|
The path to the file that will be imported.
|
|
|
|
"""
|
|
# 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:
|
|
version = None
|
|
for line in f:
|
|
match = version_re.match(line)
|
|
if match:
|
|
version = match.group(1)
|
|
break
|
|
if version is None:
|
|
# Not a valid DND character file
|
|
raise exceptions.CharacterFileFormatError(
|
|
f"No ``dungeonsheets_version = `` entry in `{filename}`.")
|
|
# Import the module to extract the information
|
|
spec = importlib.util.spec_from_file_location('module', filename)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
# Prepare a list of properties for this character
|
|
char_props = {}
|
|
for prop_name in dir(module):
|
|
if prop_name[0:2] != '__':
|
|
char_props[prop_name] = getattr(module, prop_name)
|
|
return char_props
|
|
|
|
|
|
readers_by_extension = {
|
|
'.py': PythonCharacterReader,
|
|
'.json': JSONCharacterReader,
|
|
}
|