automatic formatting

This commit is contained in:
evilchili 2023-12-29 19:24:26 -08:00
parent 51ecf83357
commit 5b4c26ecf2
4 changed files with 186 additions and 207 deletions

View File

@ -1,33 +1,31 @@
import logging
import os
from enum import Enum
from pathlib import Path
import typer
from rich import print
from rich.logging import RichHandler
from rich.console import Console
from rich.logging import RichHandler
from rich.table import Table
from dnd_item import five_e
from dnd_item.types import RollTable
from dnd_item.weapons import WeaponGenerator
from dnd_item import five_e
app = typer.Typer()
app_state = {}
class OUTPUT_FORMATS(Enum):
text = 'text'
yaml = 'yaml'
markdown = 'markdown'
text = "text"
yaml = "yaml"
markdown = "markdown"
@app.callback()
def main(
cr: int = typer.Option(default=None, help='The Challenge Rating to use when determining rarity.'),
cr: int = typer.Option(default=None, help="The Challenge Rating to use when determining rarity."),
):
debug = os.getenv("FANITEM_DEBUG", None)
logging.basicConfig(
@ -35,38 +33,32 @@ def main(
level=logging.DEBUG if debug else logging.INFO,
handlers=[RichHandler(rich_tracebacks=True, tracebacks_suppress=[typer])],
)
logging.getLogger('markdown_it').setLevel(logging.ERROR)
logging.getLogger("markdown_it").setLevel(logging.ERROR)
app_state['cr'] = cr or 0
app_state['data'] = Path(__file__).parent / Path("sources")
app_state["cr"] = cr or 0
app_state["data"] = Path(__file__).parent / Path("sources")
@app.command()
def weapon(count: int = typer.Option(1, help="The number of weapons to generate.")):
console = Console()
for weapon in WeaponGenerator().random(count=count, challenge_rating=app_state['cr']):
for weapon in WeaponGenerator().random(count=count, challenge_rating=app_state["cr"]):
console.print(weapon.details)
@app.command("roll-table")
def table(
die: int = typer.Option(
20,
help='The size of the die for which to create a table'),
die: int = typer.Option(20, help="The size of the die for which to create a table"),
hide_rolls: bool = typer.Option(
False,
help='If True, do not show the Roll column.',
help="If True, do not show the Roll column.",
),
collapsed: bool = typer.Option(
True,
help='If True, collapse multiple die values with the same option.'),
width: int = typer.Option(
180,
help='Width of the table.'),
collapsed: bool = typer.Option(True, help="If True, collapse multiple die values with the same option."),
width: int = typer.Option(180, help="Width of the table."),
output: OUTPUT_FORMATS = typer.Option(
'text',
help='The output format to use.',
)
"text",
help="The output format to use.",
),
):
"""
CLI for creating roll tables of randomly-generated items.
@ -75,7 +67,7 @@ def table(
sources=[WeaponGenerator],
die=die,
hide_rolls=hide_rolls,
challenge_rating=app_state['cr'],
challenge_rating=app_state["cr"],
)
if output == OUTPUT_FORMATS.yaml:

View File

@ -1,46 +1,31 @@
import json
import yaml
from collections import defaultdict
from random_sets.datasources import DataSource
from pathlib import Path
import yaml
from random_sets.datasources import DataSource
sources = Path(__file__).parent / Path("sources")
RARITY = {
'unknown': 'common',
'none': 'common',
'': ''
}
RARITY = {"unknown": "common", "none": "common", "": ""}
TYPE = {
'M': 'martial',
'R': 'ranged',
'': ''
}
TYPE = {"M": "martial", "R": "ranged", "": ""}
DAMAGE = {
'S': 'Slashing',
'P': 'Piercing',
'B': 'Bludgeoning',
'': ''
}
DAMAGE = {"S": "Slashing", "P": "Piercing", "B": "Bludgeoning", "": ""}
PROPERTIES = {
'F': 'finesse',
'AF': 'firearm',
'A': 'ammmunition',
'T': 'thrown',
'L': 'light',
'2H': 'two-handed',
'V': 'versatile',
'RLD': 'reload',
'LD': 'loading',
'S': 'special',
'H': 'heavy',
'R': 'reach',
"F": "finesse",
"AF": "firearm",
"A": "ammmunition",
"T": "thrown",
"L": "light",
"2H": "two-handed",
"V": "versatile",
"RLD": "reload",
"LD": "loading",
"S": "special",
"H": "heavy",
"R": "reach",
}
@ -49,42 +34,56 @@ class Weapons(DataSource):
A rolltables data source backed by a 5e.tools json data file. used to
convert the 5e.tools data to the yaml format consumed by dnd-rolltables.
"""
def read_source(self) -> None:
src = json.load(self.source)['baseitem']
src = json.load(self.source)["baseitem"]
self.data = defaultdict(list)
headers = ['Rarity', 'Name', 'Category', 'Type', 'Weight', 'Damage Type',
'Damage Dice', 'Range', 'Reload', 'Value', 'Properties']
headers = [
"Rarity",
"Name",
"Category",
"Type",
"Weight",
"Damage Type",
"Damage Dice",
"Range",
"Reload",
"Value",
"Properties",
]
for item in src:
if not item.get('weapon', False):
if not item.get("weapon", False):
continue
if item.get('age', False):
if item.get("age", False):
continue
rarity = RARITY.get(item['rarity'], 'Common').capitalize()
itype = TYPE.get(item['type'], '_unknown').capitalize()
properties = ', '.join([PROPERTIES[p] for p in item.get('property', [])])
rarity = RARITY.get(item["rarity"], "Common").capitalize()
itype = TYPE.get(item["type"], "_unknown").capitalize()
properties = ", ".join([PROPERTIES[p] for p in item.get("property", [])])
self.data[rarity].append({
item['name'].capitalize(): [
item['weaponCategory'],
self.data[rarity].append(
{
item["name"].capitalize(): [
item["weaponCategory"],
itype,
str(item.get('weight', 0)),
DAMAGE.get(item.get('dmgType', '')),
item.get('dmg1', None),
item.get('range', None),
str(item.get('reload', '')),
str(item.get('value', '')),
str(item.get("weight", 0)),
DAMAGE.get(item.get("dmgType", "")),
item.get("dmg1", None),
item.get("range", None),
str(item.get("reload", "")),
str(item.get("value", "")),
properties,
]
})
self.metadata = {'headers': headers}
}
)
self.metadata = {"headers": headers}
@property
def as_yaml(self) -> str:
return yaml.dump({'metadata': self.metadata}) + yaml.dump(dict(self.data))
return yaml.dump({"metadata": self.metadata}) + yaml.dump(dict(self.data))
def weapons(source_path: str = 'items-base.json') -> dict:
def weapons(source_path: str = "items-base.json") -> dict:
with open(sources / Path(source_path)) as filehandle:
ds = Weapons(source=filehandle)
return ds

View File

@ -1,29 +1,26 @@
import re
import logging
from pathlib import Path
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from random_sets.sets import WeightedSet, DataSourceSet
from pathlib import Path
import rolltable.types
from random_sets.sets import DataSourceSet, WeightedSet
# Create DataSourceSets, which are WeightedSets populated with DataSource
# objects generated from yaml data files. These are used to supply default
# values to item generators; see below.
sources = Path(__file__).parent / Path("sources")
ENCHANTMENT = DataSourceSet(sources / Path('magic_damage_types.yaml'))
WEAPON_TYPES = DataSourceSet(sources / Path('weapons.yaml'))
RARITY = DataSourceSet(sources / Path('rarity.yaml'))
ENCHANTMENT = DataSourceSet(sources / Path("magic_damage_types.yaml"))
WEAPON_TYPES = DataSourceSet(sources / Path("weapons.yaml"))
RARITY = DataSourceSet(sources / Path("rarity.yaml"))
PROPERTIES_BY_RARITY = {
'base': DataSourceSet(sources / Path('properties_base.yaml')),
'common': DataSourceSet(sources / Path('properties_common.yaml')),
'uncommon': DataSourceSet(sources / Path('properties_uncommon.yaml')),
'rare': DataSourceSet(sources / Path('properties_rare.yaml')),
'very rare': DataSourceSet(sources / Path('properties_very_rare.yaml')),
'legendary': DataSourceSet(sources / Path('properties_legendary.yaml')),
"base": DataSourceSet(sources / Path("properties_base.yaml")),
"common": DataSourceSet(sources / Path("properties_common.yaml")),
"uncommon": DataSourceSet(sources / Path("properties_uncommon.yaml")),
"rare": DataSourceSet(sources / Path("properties_rare.yaml")),
"very rare": DataSourceSet(sources / Path("properties_very_rare.yaml")),
"legendary": DataSourceSet(sources / Path("properties_legendary.yaml")),
}
@ -63,8 +60,8 @@ class AttributeDict(Mapping):
@dataclass
class Item(AttributeDict):
"""
"""
""" """
_name: str = None
@property
@ -76,7 +73,7 @@ class Item(AttributeDict):
@property
def description(self):
desc = "\n".join([k.title() + ". " + v.description for k, v in self.get('properties', {}).items()])
desc = "\n".join([k.title() + ". " + v.description for k, v in self.get("properties", {}).items()])
return desc.format(**self)
@classmethod
@ -88,13 +85,12 @@ class Item(AttributeDict):
# delay processing the 'properties' attribute until after the other
# attributes, because they may contain references to those attributes.
properties = attrs.pop('properties', None)
properties = attrs.pop("properties", None)
attributes = dict()
# recursively locate and populate template strings
def _format(obj, this=None):
# enables use of the 'this' keyword to refer to the current context
# in a template. Refer to the enchantment sources for an example.
if this:
@ -102,9 +98,7 @@ class Item(AttributeDict):
# dicts and lists are descended into
if type(obj) is dict:
return AttributeDict.from_dict(dict(
(key, _format(val, this=obj)) for key, val in obj.items()
))
return AttributeDict.from_dict(dict((key, _format(val, this=obj)) for key, val in obj.items()))
if type(obj) is list:
return [_format(o, this=this) for o in obj]
@ -124,32 +118,29 @@ class Item(AttributeDict):
else:
attributes[k] = _format(v)
if properties:
attributes['properties'] = AttributeDict.from_dict(_format(properties))
for prop in attributes['properties'].values():
overrides = [k for k in prop.attributes.keys() if k.startswith('override_')]
attributes["properties"] = AttributeDict.from_dict(_format(properties))
for prop in attributes["properties"].values():
overrides = [k for k in prop.attributes.keys() if k.startswith("override_")]
for o in overrides:
if prop.attributes[o]:
attributes[o.replace('override_', '')] = prop.attributes[o]
attributes[o.replace("override_", "")] = prop.attributes[o]
# store the item name as the _name attribute; it is accessable directly, or
# via the name property. This makes overriding the name convenient for subclassers,
# which may require naming semantics that cannot be resolved at instantiation time.
_name = attributes['name']
del attributes['name']
_name = attributes["name"]
del attributes["name"]
# At this point, attributes is a dictionary with members of multiple
# types, but every dict member has been converted to an AttributeDict,
# and all template strings in the object have been formatted. Return an
# instance of the Item class using these formatted attributes.
return cls(
_name=_name,
attributes=attributes
)
return cls(_name=_name, attributes=attributes)
class ItemGenerator:
"""
"""
""" """
item_class = Item
def __init__(self, bases: WeightedSet, rarity: WeightedSet, properties_by_rarity: dict):
@ -159,19 +150,16 @@ class ItemGenerator:
def _property_count_by_rarity(self, rarity: str) -> int:
property_count_by_rarity = {
'common': WeightedSet((1, 0.1), (0, 1.0)),
'uncommon': WeightedSet((1, 1.0)),
'rare': WeightedSet((1, 1.0), (2, 0.5)),
'very rare': WeightedSet((1, 0.5), (2, 1.0)),
'legendary': WeightedSet((2, 1.0), (3, 1.0)),
"common": WeightedSet((1, 0.1), (0, 1.0)),
"uncommon": WeightedSet((1, 1.0)),
"rare": WeightedSet((1, 1.0), (2, 0.5)),
"very rare": WeightedSet((1, 0.5), (2, 1.0)),
"legendary": WeightedSet((2, 1.0), (3, 1.0)),
}
return min(
property_count_by_rarity[rarity].random(),
len(self.properties_by_rarity[rarity].members)
)
return min(property_count_by_rarity[rarity].random(), len(self.properties_by_rarity[rarity].members))
def get_requirements(self, item) -> set:
pat = re.compile(r'{([^\.\}]+)')
pat = re.compile(r"{([^\.\}]+)")
def getreqs(obj):
if type(obj) is dict:
@ -188,28 +176,28 @@ class ItemGenerator:
def random_properties(self) -> dict:
item = self.bases.random()
item['rarity'] = self.rarity.random()
item["rarity"] = self.rarity.random()
properties = {}
num_properties = self._property_count_by_rarity(item['rarity']['rarity'])
num_properties = self._property_count_by_rarity(item["rarity"]["rarity"])
while len(properties) != num_properties:
thisprop = self.properties_by_rarity[item['rarity']['rarity']].random()
properties[thisprop['name']] = thisprop
thisprop = self.properties_by_rarity[item["rarity"]["rarity"]].random()
properties[thisprop["name"]] = thisprop
# add properties from the base item (versatile, thrown, artifact..)
for name in item.pop('properties', '').split(','):
for name in item.pop("properties", "").split(","):
name = name.strip()
if name:
properties[name] = self.properties_by_rarity['base'].source.as_dict()[name]
properties[name] = self.properties_by_rarity["base"].source.as_dict()[name]
item['properties'] = properties
item["properties"] = properties
# look for template strings that reference item attributes which do not yet exist.
# Add anything that is missing via a callback.
predefined = list(item.keys()) + ['this', '_name']
predefined = list(item.keys()) + ["this", "_name"]
for requirement in [r for r in self.get_requirements(item) if r not in predefined]:
try:
item[requirement] = getattr(self, f'get_{requirement}')(**item)
item[requirement] = getattr(self, f"get_{requirement}")(**item)
except AttributeError:
logging.error("{item['name']} requires {self.__class__.__name__} to have a get_{requirement}() method.")
raise
@ -225,15 +213,15 @@ class ItemGenerator:
# select the appropriate frequency distributionnb ased on the specified
# challenge rating. By default, all rarities are weighted equally.
if challenge_rating in range(1, 5):
frequency = '1-4'
frequency = "1-4"
elif challenge_rating in range(5, 11):
frequency = '5-10'
frequency = "5-10"
elif challenge_rating in range(11, 17):
frequency = '11-16'
frequency = "11-16"
elif challenge_rating >= 17:
frequency = '17'
frequency = "17"
else:
frequency = 'default'
frequency = "default"
self.rarity.set_frequency(frequency)
items = []
@ -250,14 +238,8 @@ class GeneratorSource:
def random_values(self, count: int = 1) -> list:
vals = sorted(
(
item.rarity['sort_order'],
[
item.name,
item.rarity['rarity'],
item.summary,
', '.join(item.get('properties', [])),
item.id
]
item.rarity["sort_order"],
[item.name, item.rarity["rarity"], item.summary, ", ".join(item.get("properties", [])), item.id],
)
for item in self.generator.random(count=count, challenge_rating=self.cr)
)
@ -275,7 +257,7 @@ class RollTable(rolltable.types.RollTable):
self._cr = challenge_rating
super().__init__(
sources=sources,
frequency='default',
frequency="default",
die=die,
hide_rolls=hide_rolls,
)
@ -286,10 +268,10 @@ class RollTable(rolltable.types.RollTable):
self._data.append(GeneratorSource(generator=src(), cr=self._cr))
self._headers = [
'Name',
'Rarity',
'Summary',
'Properties',
'ID',
"Name",
"Rarity",
"Summary",
"Properties",
"ID",
]
self._header_excludes = []

View File

@ -1,20 +1,19 @@
import random
import base64
import hashlib
import random
from functools import cached_property
from dnd_item import types
from random_sets.sets import WeightedSet, equal_weights
from dnd_item import types
def random_from_csv(csv: str) -> str:
return random.choice(csv.split(',')).strip()
return random.choice(csv.split(",")).strip()
class Weapon(types.Item):
"""
"""
""" """
def _descriptors(self) -> tuple:
"""
@ -22,30 +21,32 @@ class Weapon(types.Item):
"""
nouns = dict()
adjectives = dict()
if not hasattr(self, 'properties'):
if not hasattr(self, "properties"):
return (nouns, adjectives)
for prop_name, prop in self.properties.items():
if hasattr(prop, 'nouns'):
nouns[prop_name] = equal_weights(prop.nouns.split(','), blank=False)
if hasattr(prop, 'adjectives'):
adjectives[prop_name] = equal_weights(prop.adjectives.split(','), blank=False)
if hasattr(prop, "nouns"):
nouns[prop_name] = equal_weights(prop.nouns.split(","), blank=False)
if hasattr(prop, "adjectives"):
adjectives[prop_name] = equal_weights(prop.adjectives.split(","), blank=False)
return (nouns, adjectives)
def _name_template(self, with_adjectives: bool, with_nouns: bool) -> str:
num_properties = len(self.properties)
options = []
if with_nouns and not with_adjectives:
options.append(('{name} of {nouns}', 0.5))
options.append(("{name} of {nouns}", 0.5))
if with_adjectives and not with_nouns:
options.append(('{adjectives} {name}', 0.5))
options.append(("{adjectives} {name}", 0.5))
if with_nouns and with_adjectives:
if num_properties == 1:
options.append(('{adjectives} {name} of {nouns}', 1.0))
options.append(("{adjectives} {name} of {nouns}", 1.0))
elif num_properties > 1:
options.extend([
('{adjectives} {name} of {nouns}', 1.0),
('{name} of {adjectives} {nouns}', 0.5),
])
options.extend(
[
("{adjectives} {name} of {nouns}", 1.0),
("{name} of {adjectives} {nouns}", 0.5),
]
)
return WeightedSet(*options).random()
def _random_descriptors(self):
@ -64,7 +65,6 @@ class Weapon(types.Item):
seen_nouns = dict()
for prop_name in set(list(nouns.keys()) + list(adjectives.keys())):
if prop_name in nouns and prop_name not in adjectives:
if prop_name not in seen_nouns:
add_word(prop_name, random_nouns, nouns)
@ -89,8 +89,8 @@ class Weapon(types.Item):
add_word(prop_name, random_nouns, nouns)
add_word(prop_name, random_adjectives, adjectives)
random_nouns = ' and '.join(random_nouns)
random_adjectives = ' '.join(random_adjectives)
random_nouns = " and ".join(random_nouns)
random_adjectives = " ".join(random_adjectives)
return (random_nouns, random_adjectives)
@cached_property
@ -109,11 +109,11 @@ class Weapon(types.Item):
@property
def to_hit(self):
bonus_val = 0
bonus_dice = ''
if not hasattr(self, 'properties'):
return ''
bonus_dice = ""
if not hasattr(self, "properties"):
return ""
for prop in self.properties.values():
mod = getattr(prop, 'to_hit', None)
mod = getattr(prop, "to_hit", None)
if not mod:
continue
if type(mod) is int:
@ -124,24 +124,22 @@ class Weapon(types.Item):
@property
def damage_dice(self):
if not hasattr(self, 'properties'):
return ''
dmg = {
self.damage_type: str(self.damage) or ''
}
if not hasattr(self, "properties"):
return ""
dmg = {self.damage_type: str(self.damage) or ""}
for prop in self.properties.values():
mod = getattr(prop, 'damage', None)
mod = getattr(prop, "damage", None)
if not mod:
continue
key = str(prop.damage_type)
this_damage = dmg.get(key, '')
this_damage = dmg.get(key, "")
if this_damage:
dmg[key] = f"{this_damage}+{mod}"
else:
dmg[key] = mod
return ' + '.join([f"{v} {k}" for k, v in dmg.items()])
return " + ".join([f"{v} {k}" for k, v in dmg.items()])
@property
def summary(self):
@ -152,20 +150,28 @@ class Weapon(types.Item):
"""
Format the item properties as nested bullet lists.
"""
props = ', '.join(self.get('properties', dict()).keys())
return "\n".join([
props = ", ".join(self.get("properties", dict()).keys())
return "\n".join(
[
f"{self.name}",
f" * {self.rarity.rarity} {self.category} weapon ({props})",
f" * {self.summary}",
f"\n{self.description}\n"
])
f"\n{self.description}\n",
]
)
@property
def id(self):
sha1bytes = hashlib.sha1(''.join([
self._name, self.to_hit, self.damage_dice,
]).encode())
return base64.urlsafe_b64encode(sha1bytes.digest()).decode('ascii')[:10]
sha1bytes = hashlib.sha1(
"".join(
[
self._name,
self.to_hit,
self.damage_dice,
]
).encode()
)
return base64.urlsafe_b64encode(sha1bytes.digest()).decode("ascii")[:10]
class WeaponGenerator(types.ItemGenerator):
@ -182,16 +188,16 @@ class WeaponGenerator(types.ItemGenerator):
def random_properties(self) -> dict:
# add missing base weapon defaults (TODO: update the sources)
item = super().random_properties()
item['targets'] = 1
if item['category'] == 'Martial':
if not item['range']:
item['range'] = ''
item["targets"] = 1
if item["category"] == "Martial":
if not item["range"]:
item["range"] = ""
return item
# handlers for extra properties
def get_enchantment(self, **attrs) -> dict:
prop = types.ENCHANTMENT.random()
prop['adjectives'] = random_from_csv(prop['adjectives'])
prop['nouns'] = random_from_csv(prop['nouns'])
prop["adjectives"] = random_from_csv(prop["adjectives"])
prop["nouns"] = random_from_csv(prop["nouns"])
return prop