Added ability to import character files in JSON format from VTTES.

This commit is contained in:
Mark Wolfman
2020-11-08 01:09:55 -06:00
parent 839f684b02
commit 50ac9f8018
11 changed files with 8341 additions and 63 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ not, then this feature will be skipped.
Usage
=====
Each character is described by a python file, which gives many
attributes associated with the character. See examples_ for more
Each character is described by a python (or JSON) file, which gives
many attributes associated with the character. See examples_ for more
information about the character descriptions.
.. _examples: https://github.com/canismarko/dungeon-sheets/tree/master/examples
+10 -45
View File
@@ -1,10 +1,10 @@
"""Tools for describing a player character."""
__all__ = ('Character',)
from pathlib import Path
import importlib.util
import os
import re
import subprocess
import warnings
import math
@@ -18,6 +18,7 @@ from dungeonsheets.dice import read_dice_str
from dungeonsheets.stats import (Ability, ArmorClass, Initiative, Skill, Speed,
findattr)
from dungeonsheets.weapons import Weapon
from dungeonsheets.readers import read_character_file
def read(fname):
@@ -323,7 +324,7 @@ class Character():
Set maximum HP based on value in charlist py or calc from classes
"""
if hp_max:
assert isinstance(hp_max, int)
assert isinstance(hp_max, int), hp_max.__class__
self.hp_max = hp_max
else:
const_mod = self.constitution.modifier
@@ -696,7 +697,13 @@ class Character():
try:
NewWeapon = findattr(weapons, weapon)
except AttributeError:
raise AttributeError(f'Weapon "{weapon}" is not defined')
try:
findattr(spells, weapon)
except AttributeError:
raise AttributeError(f'Weapon "{weapon}" is not defined')
else:
warnings.warn(f"Ignoring spell {weapon} listed as weapon.")
return
weapon_ = NewWeapon(wielder=self)
elif issubclass(weapon, weapons.Weapon):
weapon_ = weapon(wielder=self)
@@ -802,48 +809,6 @@ class Character():
flatten=kwargs.get('flatten', True))
def read_character_file(filename):
"""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 : str
The path to the file that will be imported.
"""
# Parse the file name
dir_, fname = os.path.split(os.path.abspath(filename))
module_name, ext = os.path.splitext(fname)
if ext != '.py':
raise ValueError(f"Character definition {filename} is not a python file.")
# Check if this file contains the version string
version_re = re.compile('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
# Add backwards compatability for tests
class Artificer(Character):
def __init__(self, level=1, **attrs):
+6
View File
@@ -12,3 +12,9 @@ class LatexNotFoundError(LatexError):
class MonsterError(AttributeError):
"""Error retriving or using a D&D Monster."""
class JSONFormatError(RuntimeError):
"""The JSON file doesn't conform to the understood formats."""
class UnknownFileType(RuntimeError):
"""The input file does not match one of the known formats."""
+40 -14
View File
@@ -7,6 +7,7 @@ import os
import subprocess
import warnings
import re
from pathlib import Path
from multiprocessing import Pool, cpu_count
from itertools import product
@@ -15,7 +16,7 @@ import pdfrw
from jinja2 import Environment, PackageLoader
from dungeonsheets import character as _char
from dungeonsheets import exceptions, classes
from dungeonsheets import exceptions, classes, readers
from dungeonsheets.stats import mod_str
@@ -37,14 +38,14 @@ dice_re = re.compile(r'`*(\d+d\d+(?:\s*\+\s*\d+)?)`*')
# - a blank line
# - a non-list line or end of file
# list_re = re.compile('^[ \t\r\f\v]*\n((?:\s*[-*+]\s+[^\n]*\n)+)', flags=re.MULTILINE)
list_re = re.compile('^[ \t\r\f\v]*\n' # A blank line
'((?:\s*[-*+]\s+[^\n]*\n)+)' # The first line of each list item
list_re = re.compile(r'^[ \t\r\f\v]*\n' # A blank line
r'((?:\s*[-*+]\s+[^\n]*\n)+)' # The first line of each list item
'',
flags=re.MULTILINE)
# What defines a list item in reST:
# - a line starting with "- " then some text
# - zero or more lines starting with anything other than "- "
list_item_re = re.compile('^\s*[-*+]\s+', flags=re.MULTILINE)
list_item_re = re.compile(r'^\s*[-*+]\s+', flags=re.MULTILINE)
def _parse_rst_lists(rst):
@@ -143,7 +144,7 @@ def rst_to_latex(rst, top_heading_level=0):
list_tex = "\n\\begin{itemize}\n"
for item in list_items:
list_tex += f"\\item{{{item}}}\n"
list_tex += "\end{itemize}\n"
list_tex += "\\end{itemize}\n"
tex = tex.replace(list_rst, list_tex)
# Inline text formatting
tex = bold_re.sub(r'\\textbf{\1}', tex)
@@ -676,11 +677,22 @@ def merge_pdfs(src_filenames, dest_filename, clean_up=False):
os.remove(sheet)
load_character_file = _char.read_character_file
load_character_file = readers.read_character_file
def _build(filename, args):
basename = os.path.splitext(filename)[0]
def _build(filename, args) -> int:
known_extensions = readers.readers_by_extension.keys()
# Check if it's a directory we can recurse through
if filename.is_dir():
num_imported = 0
for child_file in filename.iterdir():
num_imported += _build(child_file, args)
return num_imported
# Check if we know how to import this file
if filename.suffix not in known_extensions:
return 0
# Single known file, so import it
basename = filename.stem
print(f"Processing {basename}...")
try:
make_sheet(character_file=filename, flatten=(not args.editable),
@@ -695,13 +707,13 @@ def _build(filename, args):
raise
else:
print(f"{basename} done")
return 1
def main():
# Prepare an argument parser
parser = argparse.ArgumentParser(
description='Prepare Dungeons and Dragons character sheets as PDFs')
parser.add_argument('filename', type=str, nargs="?",
parser.add_argument('filename', type=str, nargs="*",
help="Python file with character definition")
parser.add_argument('--editable', '-e', action="store_true",
help="Keep the PDF fields in place once processed.")
@@ -714,11 +726,25 @@ def main():
# Prepare logging if necessary
if args.debug:
logging.basicConfig(level=logging.DEBUG)
# Process the requested files
if args.filename is None:
filenames = [f for f in os.listdir('.') if os.path.splitext(f)[1] == '.py']
# Build the true list of filenames
input_filenames = args.filename
known_extensions = readers.readers_by_extension.keys()
if input_filenames == []:
input_filenames = [Path()]
else:
filenames = [args.filename]
input_filenames = [Path(f) for f in input_filenames]
def get_char_files(fpath):
valid_files = []
if fpath.is_dir():
for f in fpath.iterdir():
valid_files.extend(get_char_files(f))
elif fpath.suffix in known_extensions:
valid_files.append(fpath)
return valid_files
filenames = []
for fpath in input_filenames:
filenames.extend(get_char_files(fpath))
# Process the requested files
if args.debug:
for filename in filenames:
_build(filename, args)
+280
View File
@@ -0,0 +1,280 @@
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,
}
+3
View File
@@ -484,6 +484,9 @@ class Unarmed(MeleeWeapon):
ability = "strength"
UnarmedStrike = Unarmed
class SunBolt(RangedWeapon):
name = "Sun Bolt"
cost = "0 gp"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python
from unittest import TestCase
from pathlib import Path
import warnings
from dungeonsheets import race, monsters, exceptions, spells
from dungeonsheets.character import Character, Wizard, Druid
from dungeonsheets.character import Character, Wizard, Druid, read_character_file
from dungeonsheets.weapons import Weapon, Shortsword
from dungeonsheets.armor import Armor, LeatherArmor, Shield
+8
View File
@@ -1,5 +1,7 @@
import unittest
import os
from pathlib import Path
import subprocess
from dungeonsheets import make_sheets, character
@@ -7,6 +9,10 @@ EG_DIR = os.path.abspath(os.path.join(os.path.split(__file__)[0], '../examples/'
CHARFILE = os.path.join(EG_DIR, 'rogue1.py')
class CharacterFileTestCase(unittest.TestCase):
example_dir = Path(__file__).parent.parent / "examples"
# def test_(self):
# print(self.example_dir)
# subprocess.run(['makesheets', self.example_dir])
def test_load_character_file(self):
charfile = CHARFILE
result = make_sheets.load_character_file(charfile)
@@ -110,3 +116,5 @@ class MarkdownTestCase(unittest.TestCase):
tex = make_sheets.rst_to_latex(md_list)
print(tex)
self.assertIn("\\begin{itemize}", tex)
+87
View File
@@ -0,0 +1,87 @@
import warnings
from pathlib import Path
import unittest
import types
from dungeonsheets.readers import read_character_file
EG_DIR = (Path(__file__).parent.parent / "examples").resolve()
CHAR_PYTHON_FILE = EG_DIR / 'rogue1.py'
CHAR_JSON_FILE = EG_DIR / 'barbarian3.json'
SPELLCASTER_JSON_FILE = EG_DIR / 'artificer2.json'
class PythonReaderTests(unittest.TestCase):
def test_load_python_file(self):
charfile = CHAR_PYTHON_FILE
result = read_character_file(charfile)
self.assertEqual(result['strength'], 10)
class JSONReaderTests(unittest.TestCase):
def test_load_json_file(self):
charfile = CHAR_JSON_FILE
with warnings.catch_warnings(record=True) as w:
result = read_character_file(charfile)
expected_data = dict(
name="Ulthar Jenkins",
classes=["Barbarian"],
level=2,
background="Soldier",
alignment="Lawful Evil",
race="Hill Dwarf",
xp=557,
strength=13,
dexterity=12,
constitution=19,
intelligence=8,
hp_max=32,
skill_proficiencies=["athletics", "survival",],
weapon_proficiencies=["simple weapons", "martial weapons", "battleaxe", "handaxe", "light hammer", "warhammer", "unarmed strike",],
_proficiencies_text=["Brewer's Supplies",],
languages="common, dwarvish",
cp=26,
sp=55,
ep=0,
gp=207,
pp=0,
weapons=["handaxe", "javelin", "warhammer"],
magic_items=(),
armor="",
shield="",
personality_traits="Can easily dismember a body\n\nKnow fight battle tactics",
ideals="Vengence",
bonds="friends and adventurers.",
flaws="Bloodthirsty and wants to solve every problem by murder",
equipment=("warhammer, handaxe, explorer's pack, javelin (4), backpack, "
"bedroll, mess kit, tinderbox, torch (10), rations (10), "
"waterskin, hempen rope"),
attacks_and_spellcasting="",
spells_prepared=[],
spells=[],
)
for key, val in expected_data.items():
this_result = result[key]
# Force evaluation of generators
if isinstance(this_result, types.GeneratorType):
this_result = list(this_result)
self.assertEqual(this_result, val, key)
def test_load_json_spells(self):
charfile = SPELLCASTER_JSON_FILE
with warnings.catch_warnings(record=True) as w:
result = read_character_file(charfile)
expected_data = dict(
spells_prepared=["cure wounds",],
spells=["spare the dying", "fire bolt", "absorb elements",
"alarm", "catapult", "cure wounds", "detect magic",
"disguise self", "expeditious retreat", "faerie fire",
"false life", "feather fall", "grease", "identify",
"jump", "longstrider", "purify food and drink",
"sanctuary", "snare",],
)
for key, val in expected_data.items():
this_result = result[key]
# Force evaluation of generators
if isinstance(this_result, types.GeneratorType):
this_result = list(this_result)
self.assertEqual(this_result, val, key)