dnd-item-generator/dnd_item/types.py

525 lines
19 KiB
Python

import logging
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
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"))
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")),
}
@dataclass
class AttributeMap(Mapping):
"""
AttributeMap is a data class that is also a mapping, converting a dict
into an object with attributes. Example:
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> amap.foo
True
>>> amap.bar
False
Instantiating an AttributeMap using the from_dict() class method will
recursively transform dictionary members sinto AttributeMaps:
>>> nested_dict = {'foo': {'bar': {'baz': True}, 'boz': False}}
>>> amap = AttributeMap.from_dict(nested_dict)
>>> amap.foo.bar.baz
True
>>> amap.foo.boz
False
The dictionary can be accessed directly via 'attributes':
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> list(amap.attributes.keys()):
>>>['foo', 'bar']
Because AttributeMap is a mapping, you can use it anywhere you would use
a regular mapping, like a dict:
>>> amap = AttributeMap(attributes={'foo': True, 'bar': False})
>>> 'foo' in amap
True
>>> "{foo}, {bar}".format(**amap)
True, False
"""
attributes: field(default_factory=dict)
def __getattr__(self, attr):
if attr in self.attributes:
return self.attributes[attr]
return self.__getattribute__(attr)
def __len__(self):
return len(self.attributes)
def __getitem__(self, key):
return self.attributes[key]
def __iter__(self):
return iter(self.attributes)
@classmethod
def from_dict(cls, kwargs: dict):
"""
Create a new AttributeMap object using keyword arguments. Dicts are
recursively converted to AttributeMap objects; everything else is
passed as-is.
"""
attrs = {}
for k, v in sorted(kwargs.items()):
attrs[k] = AttributeMap.from_dict(v) if type(v) is dict else v
return cls(attributes=attrs)
@dataclass
class Item(AttributeMap):
"""
Item is the base class for items, weapons, and spells, and is intended to
be subclassed to define those things. Item extends AttributeMap to provide
some helper methods, including the name and description properties.
Creating Items
To create an Item, call Item.from_dict() with a dictionary as you would an
AttributeMap. But unlike the base method, Item.from_dict() processes the
values of the dictionary and formats any template strings it encounters,
including templates which reference its own attributes. A simple example:
>>> properties = dict(
? name='{length}ft. pole',
? weight='7lbs.',
? value=5,
? length=10
? )
>>> ten_foot_pole = Item.from_dict(properties)
>>> ten_foot_pole.name
10ft. Pole
Note that the name value includes a template that refers to the length
property. This reference is resolved when the Item instance is created.
Properties Are Special
The 'properties' attribute has special meaning for Items; it is the mapping
of the item's in-game properties. For weapons, this includes standard
properties such as 'light', 'finesse', 'thrown', and so on, but they can be
anything you like. Item.properties is also unique in that its members'
templates can contain references to other attributes of the Item. For
example:
>>> properties = dict(
? name='{length}ft. pole',
? length=10,
? properties=dict(
? 'engraved'=dict(
? description='"Property of {info.owner}!"'
? ),
? ),
? info=dict(
? owner='Jules Ultardottir',
? )
? )
>>> ten_foot_pole = Item.from_dict(properties)
>>> ten_foot_pole.description
Engraved. "Property of Jules Ultardottir!"
Overriding Attributes with Properties
Properties can also override existing item attributes. Any key in the
properties dict of the form 'override_<attribute>' will replace the value
of <attribute> on the item:
>>> properties = dict(
? name='{length}ft. pole',
? length=10,
? properties=dict(
? 'broken'=dict(
? description="The end of this 10ft. pole has been snapped off.",
? override_length=7
? ),
? )
? )
>>> ten_foot_pole = Item.from_dict(properties)
>>> ten_foot_pole.name
7ft. Pole
>>> ten_foot_pole.description
Broken. The end of this 10ft. pole has been snapped off.
This is useful when generating randomized Items, as random properties can
be added to base objects, modifying their attribute; the WeaponGenerator
(see below) uses overrides to create low-level magic weapons that change
the basic bludgeoning/piercing/slashing damage to fire, ice, poison...
"""
_name: str = None
@property
def name(self) -> str:
"""
The item's name. This is a handy property for subclassers to override.
"""
return self._name
@property
def description(self):
"""
Summarize the properties of the item, as defined by Item.properties.
"""
desc = "\n".join([
k.title() + ". " + v.get('description', '')
for k, v in self.get("properties", {}).items()
])
return desc.format(**self)
@classmethod
def from_dict(cls, attrs: dict):
"""
Create a new Item object using keyword arguments. Dicts are recursively
converted to Item objects; everything else is passed as-is.
"""
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:
this = AttributeMap.from_dict(this)
# dicts and lists are descended into
if type(obj) is dict:
return AttributeMap.from_dict(
dict(
(_format(key, this=obj), _format(val, this=obj))
for key, val in obj.items()
)
)
if type(obj) is list:
return [_format(o, this=this) for o in obj]
# Strings are formatted wth values from attributes and this. Using
# attributes is important here, so that values containing template
# strings are processed before they are referenced.
if type(obj) is str:
return obj.format(**attributes, this=this)
# Any type other than dict, list, and string is returned unaltered.
return obj
properties = attrs.pop("properties", {})
# apply property overrides overrides before anything else
for prop in properties.values():
overrides = [k for k in prop.keys() if k.startswith("override_")]
for o in overrides:
if prop[o]:
attrs[o.replace("override_", "")] = prop[o]
# step through the supplied attributes and format each member.
for k, v in sorted(attrs.items(), key=lambda i: '{' in f"{i[0]}{i[1]}"):
if type(v) is dict:
attributes[k] = AttributeMap.from_dict(_format(v))
else:
attributes[k] = _format(v)
# process properties now that we have preprocessed everything else
if properties:
attributes["properties"] = AttributeMap.from_dict(_format(properties))
# 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"]
# At this point, attributes is a dictionary with members of multiple
# types, but every dict member has been converted to an AttributeMap,
# 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)
class ItemGenerator:
"""
The base class for random item generators.
This class is intended to be subclassed, by individual subclasses for each
type (weapon, item, wand...). An ItemGenerator is instantiated with
DataSourceSets for base item definitions and rarity, and a dictionary of
property definitions organized by rarity ('common', 'uncommon'...). This
module provides a set of pre-defined DataSourceSets (WEAPON_TYPES, RARITY,
PROPERTIES_BY_RARITY) for this purpose, but subclasses generally provide
sensible defaults specific to their use.
A simple subclassing example:
class SharpStickGenerator(types.ItemGenerator):
def __init__(self):
super().__init__(
bases=WeightedSet(
(dict(name='{type} stick', type='wooden', ...), 0.3),
(dict(name='{type} stick', type='lead', ...), 1.0),
(dict(name='{type} stick', type='silver', ...), 0.5),
(dict(name='{type} stick', type='glass', ...), 0.1),
),
raritytypes.RARITY,
properties_by_rarity=types.PROPERTIES_BY_RARITY,
)
Generating Random Items
Given an ItemGenerator class, use the ItemGenerator.random() method to
create randomized tiems. To do this, random() will:
1. Select a random base
2. Select a random rarity appropriate for the challenge rating
3. Select properties appropriate for legendary items
Example:
>>> stick = SharpStickGenerator().random(count=1, challenge_rating=17)
>>> stick[0].name
Silver Stick
>>> stick[0].rarity
legendary
>>> stick[0].description
Magical. This magical weapon grants +3 to attack and damage rolls.
For more complete examples, refer to the various modules in dnd_item.
"""
# random() will generate instances of this class. Subclassers should
# override this with a subclass of Item.
item_class = Item
def __init__(self, bases: WeightedSet, rarity: WeightedSet, properties_by_rarity: dict):
self.bases = bases
self.rarity = rarity
self.properties_by_rarity = properties_by_rarity
def _property_count_by_rarity(self, rarity: str) -> int:
"""
Return a number of properties to add to an item of some rarity. Common items
have a 10% chance of hanving one property; Legendary items will have either
2 or 3 properties. This is the primary method by which Items of greater
rarity become more valuable and wondrous, justifying their rarity.
"""
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)),
}
# don't try to apply more unique properties to the item than exist in
# the look-up tables.
return min(
property_count_by_rarity[rarity].random(),
len(self.properties_by_rarity[rarity].members)
)
def get_requirements(self, item) -> set:
"""
Step through all attributes of an object looking for template strings,
and return the unique set of attributes referenced in those template
strings.
>>> props = dict(foo="{one}", bar=dict(baz="{one}", boz="{two.three}"))
>>> ItemGenerator().get_requirements(props)
{'one', 'two'}
"""
# Given "{foo.bar.baz}", capture "foo"
pat = re.compile(r"{([^\.\}]+)")
def getreqs(obj):
if type(obj) is dict:
for val in obj.values():
yield from getreqs(val)
elif type(obj) is list:
yield from [getreqs(o) for o in obj]
elif type(obj) is str:
matches = pat.findall(obj)
if matches:
yield from matches
return set(getreqs(item))
def random_properties(self) -> dict:
"""
Create a dictionary of item attributes appropriate for a given rarity.
Dictionaries generated by this method are used as arguments to the
ItemGenerator.from_dict() method.
Properties From Callbacks
Property definitions are loaded from ItemGenerator.properties_by_rarity,
but additional properties can be defined by adding callbacks to the
ItemGenerator subclass. For example, given the template string:
{extras.owner}
if 'extras' does not exist in self.properties_by_rarity,
random_properties() will invoke the method self.get_extras with the
properties generated thus far, and add the return value to the item:
def get_extras(self, **item):
return dict(
'owner': 'Jules Utandottir',
)
This will result in:
>>> item['extras']['owner']
Jules Ultandottir
"""
# select a random base
item = self.bases.random()
# select a random rarity
item["rarity"] = self.rarity.random()
# select a number of properties appropriate to the rarity
num_properties = self._property_count_by_rarity(item["rarity"]["rarity"])
# generate the selected number of properties
properties = {}
while len(properties) != num_properties:
thisprop = self.properties_by_rarity[item["rarity"]["rarity"]].random()
properties[thisprop["name"]] = thisprop
# Base items might have properties already; weapons have things like
# 'versatile' and 'two-handed', for example. We'll add these to the
# properties dict by looking them up properties_by_rarity dict so the
# item we generate will have information about those base proprities.
for name in item.pop("properties", "").split(","):
name = name.strip()
if name:
properties[name] = self.properties_by_rarity["base"].source.as_dict()[name]
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"]
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)
except AttributeError:
logging.error("{item['name']} requires {self.__class__.__name__} to have a get_{requirement}() method.")
raise
return item
def random(self, count: int = 1, challenge_rating: int = 0) -> list:
"""
Generate one or more random Item instances by selecting random values
from the available data sources, appropriate to the specified challenge
rating.
Items generated will be appropriate for a challenge rating representing
an encounter for an adventuring party of four. This will prevent
lower-level encounters from generating legendary weapons and so on. If
challenge_rating is 0, a rarity is chosen at random.
"""
if challenge_rating in range(1, 5):
frequency = "1-4"
elif challenge_rating in range(5, 11):
frequency = "5-10"
elif challenge_rating in range(11, 17):
frequency = "11-16"
elif challenge_rating >= 17:
frequency = "17"
else:
frequency = "default"
self.rarity.set_frequency(frequency)
items = []
for _ in range(count):
items.append(self.item_class.from_dict(self.random_properties()))
return items
@dataclass
class GeneratorSource:
"""
A source for a RollTable instance that uses an ItemGenrator to generate
random data instead of loading data from a static file source.
"""
generator: ItemGenerator
cr: int
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],
)
for item in self.generator.random(count=count, challenge_rating=self.cr)
)
return [v[1] for v in vals]
class RollTable(rolltable.types.RollTable):
"""
A subclass of RollTable that uses ItemGenerator clsases to create table rows.
Instantiate it by supplying one or more ItemGenerator sources:
>>> rt = RollTable(sources[WeaponGenerator])
For a complete example, refer to th dnd_item.cli.table.
"""
def __init__(
self,
sources: list,
die: int = 20,
hide_rolls: bool = False,
challenge_rating: int = 0,
):
self._cr = challenge_rating
super().__init__(
sources=sources,
frequency="default",
die=die,
hide_rolls=hide_rolls,
)
def _config(self):
self._data = []
for src in self._sources:
self._data.append(GeneratorSource(generator=src(), cr=self._cr))
self._headers = [
"Name",
"Rarity",
"Summary",
"Properties",
"ID",
]
self._header_excludes = []