refactoring

This commit is contained in:
evilchili 2023-12-27 22:25:12 -08:00
parent 6d45ba9c4b
commit 86c2fce87d
12 changed files with 446 additions and 323 deletions

View File

@ -11,7 +11,8 @@ from rich.logging import RichHandler
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from dnd_item.types import WeaponGenerator, RollTable from dnd_item.types import RollTable
from dnd_item.weapons import WeaponGenerator
from dnd_item import five_e from dnd_item import five_e
app = typer.Typer() app = typer.Typer()

View File

@ -16,32 +16,32 @@ metadata:
necrotic: 1.0 necrotic: 1.0
radiant: 1.0 radiant: 1.0
fire: fire:
- flames, fire - 'flames, fire'
- flaming, burning - 'flaming, burning'
cold: cold:
- ice - 'ice, cold'
- freezing, frosty - 'freezing, frosty'
acid: acid:
- acid - 'acid'
- acidic, caustic - 'acidic, caustic'
thunder: thunder:
- thunder - 'thunder'
- thundering,booming - 'thundering,booming'
psychic: psychic:
- mind - 'mind, screams'
- psychic - 'psychic, screaming'
poison: poison:
- poison - 'poison, toxins, venom'
- poisonous,toxic - 'poisonous, toxic, venomous'
lightning: lightning:
- lightning,sparks,shocks - 'lightning,sparks,shocks'
- lightning,shocking,sparking - 'lightning,shocking,sparking'
force: force:
- force - 'force'
- forceful - 'forceful'
necrotic: necrotic:
- necrosis, darkness, shadows - 'necrosis, darkness, shadows'
- necrotic, dark, unholy - 'necrotic, dark, unholy'
radiant: radiant:
- radiance, shining - 'radiance, shining'
- radiant, holy - 'radiant, holy'

View File

@ -0,0 +1,24 @@
metadata:
headers:
- name
- description
light:
- 'A light weapon is small and easy to handle, making it ideal for use when fighting with two weapons.'
thrown:
- ''
heavy:
- ''
special:
- ''
two-handed:
- ''
versatile:
- ''
finesse:
- ''
ammunition:
- ''
reach:
- ''
loading:
- ''

View File

@ -4,9 +4,19 @@ metadata:
- nouns - nouns
- adjectives - adjectives
- description - description
- damage_type
- damage
- to_hit
- override_damage_type
- override_damage
- type - type
light: 'smiles':
- 'light' - 'smiles, grins, joy, smirks'
- 'light' - 'smiling, grinning, joyfulness, smirking'
- 'A light weapon is small and easy to handle, making it ideal for use when fighting with two weapons.' - 'While this {this.type} is equipped, you are forced to smile.'
- weapon - null
- null
- null
- null
- null
- item

View File

@ -4,5 +4,23 @@ metadata:
- nouns - nouns
- adjectives - adjectives
- description - description
- damage_type
- damage
- to_hit
- type - type
legendary: enchanted:
- '{enchantment.nouns}'
- '{enchantment.adjectives}'
- 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.'
- '{enchantment.damage_type}'
- 2d6
- 0
- weapon
magical:
- 'striking, smacking'
- '+3'
- This magical weapon grants +3 to attack and damage rolls.
- '{damage_type}'
- 3
- 3
- weapon

View File

@ -9,18 +9,18 @@ metadata:
- to_hit - to_hit
- type - type
enchanted: enchanted:
- '{enchanted.nouns}' - '{enchantment.nouns}'
- '{enchanted.adjectives}' - '{enchantment.adjectives}'
- 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.'
- '{enchanted.damage_type}' - '{enchantment.damage_type}'
- 1d6 - 1d6
- 0 - 0
- weapon - weapon
magical: magical:
- magic - 'striking, smacking'
- '+2' - '+2'
- This magical weapon grants +2 to attack and damage rolls. - This magical weapon grants +2 to attack and damage rolls.
- '{base.damage_type}' - '{damage_type}'
- 2 - 2
- 2 - 2
- weapon - weapon

View File

@ -7,20 +7,38 @@ metadata:
- damage_type - damage_type
- damage - damage
- to_hit - to_hit
- override_damage_type
- override_damage
- type - type
'elemental damage':
- '{enchantment.nouns}'
- '{enchantment.adjectives}'
- 'This magical {name} deals {this.damage_type} damage.'
- '{enchantment.damage_type}'
- 0
- 0
- '{enchantment.damage_type}'
- null
- weapon
enchanted: enchanted:
- '{enchanted.nouns}' - '{enchantment.nouns}'
- '{enchanted.adjectives}' - '{enchantment.adjectives}'
- 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.'
- '{enchanted.damage_type}' - '{enchantment.damage_type}'
- 1d4 - 1d4
- 0 - 0
- null
- null
- null
- weapon - weapon
magical: magical:
- magic - 'striking, smacking'
- '+1' - '+1'
- This magical weapon grants +1 to attack and damage rolls. - This magical weapon grants +1 to attack and damage rolls.
- '{base.damage_type}' - '{damage_type}'
- 1 - 1
- 1 - 1
- null
- null
- null
- weapon - weapon

View File

@ -9,18 +9,18 @@ metadata:
- to_hit - to_hit
- type - type
enchanted: enchanted:
- '{enchanted.nouns}' - '{enchantment.nouns}'
- '{enchanted.adjectives}' - '{enchantment.adjectives}'
- 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.'
- '{enchanted.damage_type}' - '{enchantment.damage_type}'
- 1d8 - 1d8
- 0 - 0
- weapon - weapon
magical: magical:
- magic - 'striking, smacking'
- '+3' - '+3'
- This magical weapon grants +3 to attack and damage rolls. - This magical weapon grants +3 to attack and damage rolls.
- '{base.damage_type}' - '{damage_type}'
- 3 - 3
- 3 - 3
- weapon - weapon

View File

@ -39,7 +39,8 @@ metadata:
uncommon: 0.05 uncommon: 0.05
rare: 0.5 rare: 0.5
very rare: 0.5 very rare: 0.5
legendary: 0.3 #legendary: 0.3
legendary: 0.0
common: common:
- 0 - 0
uncommon: uncommon:

View File

@ -29,7 +29,7 @@ Blowgun:
- 25/100 - 25/100
- '' - ''
- '1000' - '1000'
- ammmunition, loading - ammunition, loading
Club: Club:
- simple - simple
- Martial - Martial
@ -139,7 +139,7 @@ Hand crossbow:
- 30/120 - 30/120
- '' - ''
- '7500' - '7500'
- ammmunition, light, loading - ammunition, light, loading
Handaxe: Handaxe:
- simple - simple
- Martial - Martial
@ -159,7 +159,7 @@ Heavy crossbow:
- 100/400 - 100/400
- '' - ''
- '5000' - '5000'
- ammmunition, heavy, loading, two-handed - ammunition, heavy, loading, two-handed
Hooked shortspear: Hooked shortspear:
- martial - martial
- Martial - Martial
@ -179,7 +179,7 @@ Hoopak:
- 40/160 - 40/160
- '' - ''
- '10' - '10'
- ammmunition, finesse, special, two-handed - ammunition, finesse, special, two-handed
Javelin: Javelin:
- simple - simple
- Martial - Martial
@ -209,7 +209,7 @@ Light crossbow:
- 80/320 - 80/320
- '' - ''
- '2500' - '2500'
- ammmunition, loading, two-handed - ammunition, loading, two-handed
Light hammer: Light hammer:
- simple - simple
- Martial - Martial
@ -229,7 +229,7 @@ Light repeating crossbow:
- 40/160 - 40/160
- '' - ''
- '' - ''
- ammmunition, two-handed - ammunition, two-handed
Longbow: Longbow:
- martial - martial
- Ranged - Ranged
@ -239,7 +239,7 @@ Longbow:
- 150/600 - 150/600
- '' - ''
- '5000' - '5000'
- ammmunition, heavy, two-handed - ammunition, heavy, two-handed
Longsword: Longsword:
- martial - martial
- Martial - Martial
@ -339,7 +339,7 @@ Shortbow:
- 80/320 - 80/320
- '' - ''
- '2500' - '2500'
- ammmunition, two-handed - ammunition, two-handed
Shortsword: Shortsword:
- martial - martial
- Martial - Martial
@ -369,7 +369,7 @@ Sling:
- 30/120 - 30/120
- '' - ''
- '10' - '10'
- ammmunition - ammunition
Spear: Spear:
- simple - simple
- Martial - Martial

View File

@ -1,5 +1,8 @@
import random import re
import logging
from pathlib import Path from pathlib import Path
from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from random_sets.sets import WeightedSet, DataSourceSet from random_sets.sets import WeightedSet, DataSourceSet
@ -14,7 +17,8 @@ sources = Path(__file__).parent / Path("sources")
ENCHANTMENT = DataSourceSet(sources / Path('magic_damage_types.yaml')) ENCHANTMENT = DataSourceSet(sources / Path('magic_damage_types.yaml'))
WEAPON_TYPES = DataSourceSet(sources / Path('weapons.yaml')) WEAPON_TYPES = DataSourceSet(sources / Path('weapons.yaml'))
RARITY = DataSourceSet(sources / Path('rarity.yaml')) RARITY = DataSourceSet(sources / Path('rarity.yaml'))
PROPERTIES = { PROPERTIES_BY_RARITY = {
'base': DataSourceSet(sources / Path('properties_base.yaml')),
'common': DataSourceSet(sources / Path('properties_common.yaml')), 'common': DataSourceSet(sources / Path('properties_common.yaml')),
'uncommon': DataSourceSet(sources / Path('properties_uncommon.yaml')), 'uncommon': DataSourceSet(sources / Path('properties_uncommon.yaml')),
'rare': DataSourceSet(sources / Path('properties_rare.yaml')), 'rare': DataSourceSet(sources / Path('properties_rare.yaml')),
@ -24,39 +28,28 @@ PROPERTIES = {
@dataclass @dataclass
class AttributeDict: class AttributeDict(Mapping):
_attrs: field(default_factory=dict) = None attributes: field(default_factory=dict)
def __getattr__(self, attr): def __getattr__(self, attr):
""" """
Look up attributes in the _attrs dict first, then fall back to the default. Look up attributes in the _attrs dict first, then fall back to the default.
""" """
if attr in self._attrs: if attr in self.attributes:
return self._attrs[attr] return self.attributes[attr]
return self.__getattribute__(attr) return self.__getattribute__(attr)
def __str__(self): def __len__(self):
return self._flatten(self) return len(self.attributes)
def __repr__(self): def __getitem__(self, key):
return str(self) return self.attributes[key]
def _flatten(self, obj, prefix=None): def __iter__(self):
if prefix == '': return iter(self.attributes)
prefix = f"{self.__class__.__name__}."
else:
prefix = ''
lines = []
for (k, v) in obj._attrs.items():
if type(v) is AttributeDict:
lines.append(self._flatten(v, prefix=f"{prefix}{k}"))
else:
lines.append(f"{prefix}{k} = {v}")
return "\n".join(lines)
@classmethod @classmethod
def from_dict(cls, **kwargs: dict): def from_dict(cls, kwargs: dict):
""" """
Create a new AttributeDict object using keyword arguments. Dicts are Create a new AttributeDict object using keyword arguments. Dicts are
recursively converted to AttributeDict objects; everything else is recursively converted to AttributeDict objects; everything else is
@ -64,8 +57,8 @@ class AttributeDict:
""" """
attrs = {} attrs = {}
for k, v in sorted(kwargs.items()): for k, v in sorted(kwargs.items()):
attrs[k] = AttributeDict.from_dict(**v)if type(v) is dict else v attrs[k] = AttributeDict.from_dict(v)if type(v) is dict else v
return cls(_attrs=attrs) return cls(attributes=attrs)
@dataclass @dataclass
@ -73,192 +66,155 @@ class Item(AttributeDict):
""" """
""" """
_name: str = None _name: str = None
rarity: str = None
_template: str = None
@property @property
def name(self): def name(self) -> str:
return self._template.format(name=self._name, **self._attrs).format(**self._attrs).title() """
The item's name. This is a handy property for subclassers to override.
@property """
def properties(self): return self._name
return self._attrs['properties']._attrs
@property @property
def description(self): def description(self):
txt = [] desc = "\n".join([k.title() + ". " + v.description for k, v in self.get('properties', {}).items()])
for k, v in self.properties.items(): return desc.format(**self)
txt.append(k.title() + ". " + v.description.format(
this=v,
name=self.name,
**self._attrs,
).format(name=self.name, **self._attrs))
return "\n".join(txt)
@property
def details(self):
"""
Format the item attributes as nested bullet lists.
"""
return "\n".join([
f"{self.name} ({self.rarity['rarity']}):",
self.description,
"",
self._flatten(self.base, prefix=None)
])
@property
def _properties_as_text(self):
def attrs_to_lines(item, prefix: str = ''):
for (k, v) in item._attrs.items():
if type(v) is AttributeDict:
yield from attrs_to_lines(v, prefix=f"{k}.")
continue
yield f" * {prefix}{k}: {v}"
return "\n".join(["Properties:"] + sorted(list(attrs_to_lines(self))))
@classmethod @classmethod
def from_dict(cls, **kwargs: dict): def from_dict(cls, attrs: dict):
""" """
Create a new Item object using keyword arguments. Dicts are recursively Create a new Item object using keyword arguments. Dicts are recursively
converted to Item objects; everything else is passed as-is. converted to Item objects; everything else is passed as-is.
The "name" and "template" arguments, if supplied, are removed from the
keyword arguments and used to populate those attributes directly; all
other attributes will be added to the _attrs dict so they can be
accessed directly through dotted attribute notation.
""" """
name = kwargs.pop('name') if 'name' in kwargs else '(unnamed)'
rarity = kwargs.pop('rarity') if 'rarity' in kwargs else 'common'
template = kwargs.pop('template') if 'template' in kwargs else '{name}'
attrs = {}
for k, v in kwargs.items():
attrs[k] = AttributeDict.from_dict(**v)if type(v) is dict else v
return cls(_name=name, rarity=rarity, _template=template, _attrs=attrs)
def __repr__(self): # delay processing the 'properties' attribute until after the other
return str(self) # attributes, because they may contain references to those attributes.
properties = attrs.pop('properties', None)
attributes = dict()
class Weapon(Item): # recursively locate and populate template strings
""" def _format(obj, this=None):
An Item class representing a weapon with the following attributes:
"""
@property # enables use of the 'this' keyword to refer to the current context
def to_hit(self): # in a template. Refer to the enchantment sources for an example.
bonus_val = 0 if this:
bonus_dice = '' this = AttributeDict.from_dict(this)
for prop in self.properties.values():
mod = getattr(prop, 'to_hit', None)
if not mod:
continue
if type(mod) is int:
bonus_val += mod
elif type(mod) is str:
bonus_dice += f"+{mod}"
return f"+{bonus_val}{bonus_dice}"
@property # dicts and lists are descended into
def damage_dice(self): if type(obj) is dict:
dmg = dict() return AttributeDict.from_dict(dict(
dmg[self.base.damage_type] = self.base.damage or '' (key, _format(val, this=obj)) for key, val in obj.items()
for prop in self.properties.values(): ))
mod = getattr(prop, 'damage', None) if type(obj) is list:
if not mod: return [_format(o, this=this) for o in obj]
continue
key = str(prop.damage_type).format(**self._attrs).title() # Strings are formatted wth values from attributes and this. Using
if key not in dmg: # attributes is important here, so that values containing template
dmg[key] = str(mod) # 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
# step through the supplied attributes and format each member.
for k, v in attrs.items():
if type(v) is dict:
attributes[k] = AttributeDict.from_dict(_format(v))
else: else:
dmg[key] += f"+{mod}" 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_')]
for o in overrides:
if prop.attributes[o]:
attributes[o.replace('override_', '')] = prop.attributes[o]
return ' + '.join([f"{v} {k}" for k, v in dmg.items()]) # 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']
@property # At this point, attributes is a dictionary with members of multiple
def summary(self): # types, but every dict member has been converted to an AttributeDict,
return f"{self.to_hit} to hit, {self.base.range} ft., {self.base.targets} targets. {self.damage_dice}" # and all template strings in the object have been formatted. Return an
# instance of the Item class using these formatted attributes.
@property return cls(
def details(self): _name=_name,
""" attributes=attributes
Format the item attributes as nested bullet lists. )
"""
return "\n".join([
f"{self.name}",
f" * {self.rarity['rarity']} {self.base.category} weapon ({self.base.properties})",
f" * {self.summary}",
f"\n{self.description}\n" if self.description else "",
"----",
self._flatten(self.base, prefix=None)
])
class ItemGenerator: class ItemGenerator:
""" """
Generate randomized instances of Item objects.
The main interfaces is the random() method, which will generate one or
more random Item instances by selecting random values from the supplied
WeightedSets. This allows for fully-controllable frequency distributions.
You probably want to subclass this class, in order to provide sensible
defaults, and control what attributes are available; refer to the
subclasses elsewhere in this module.
The class requires two arguments to instantiate:
* templates - a WeightedSet of format strings for item names; and
* types - a WeightedSet of item types to be selected from at random.
Example:
>>> ig = ItemGenerator(
? templates=WeightedSet("{type.name}", 1.0),
? types=WeightedSet(
? ({'name': 'ring'}, 1.0),
? ({'name': 'hat'}, 1.0),
? ),
? )
>>> ig.random(3).name
['hat', 'hat', 'ring']
""" """
# Create instances of this class. Subclasses may wish to override this.
item_class = Item item_class = Item
def __init__( def __init__(self, bases: WeightedSet, rarity: WeightedSet, properties_by_rarity: dict):
self,
templates: WeightedSet,
bases: WeightedSet,
rarity: WeightedSet = RARITY,
properties: WeightedSet = PROPERTIES,
):
self.bases = bases self.bases = bases
self.templates = templates
self.rarity = rarity self.rarity = rarity
self.properties = properties self.properties_by_rarity = properties_by_rarity
def random_attributes(self) -> dict: def _property_count_by_rarity(self, rarity: str) -> int:
""" property_count_by_rarity = {
Select random values from the available attributes. These values will 'common': WeightedSet((1, 0.1), (0, 1.0)),
be passed as arguments to the Item constructor. 'uncommon': WeightedSet((1, 1.0)),
'rare': WeightedSet((1, 1.0), (2, 0.5)),
If you subclass this class and override this method, be sure that 'very rare': WeightedSet((1, 0.5), (2, 1.0)),
whatever attributes are referenced in your template strings are 'legendary': WeightedSet((2, 1.0), (3, 1.0)),
available as properties here. For example, if you have a subclass with
the template:
WeightedSet("{this.color} {that.thing}", 1.0)
This method must return a dict that includes both this and that, and
each of them must be either Item instances or dictionaries.
"""
return {
'template': self.templates.random(),
'base': self.bases.random(),
'rarity': self.rarity.random(),
'properties': self.properties.random(),
} }
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'{([^\.\}]+)')
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:
item = self.bases.random()
item['rarity'] = self.rarity.random()
properties = {}
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
# add properties from the base item (versatile, thrown, artifact..)
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: def random(self, count: int = 1, challenge_rating: int = 0) -> list:
""" """
@ -282,97 +238,10 @@ class ItemGenerator:
items = [] items = []
for _ in range(count): for _ in range(count):
items.append(self.item_class.from_dict(**self.random_attributes())) items.append(self.item_class.from_dict(self.random_properties()))
return items return items
class WeaponGenerator(ItemGenerator):
item_class = Weapon
def __init__(
self,
templates: WeightedSet = WeightedSet(('{base.name}', 1.0),),
bases: WeightedSet = WEAPON_TYPES,
rarity: WeightedSet = RARITY,
enchanted: WeightedSet = ENCHANTMENT,
properties: WeightedSet = PROPERTIES,
):
super().__init__(bases=bases, templates=None, rarity=rarity, properties=properties)
self.enchanted = enchanted
self.property_count_by_rarity = {
'common': WeightedSet((0, 1.0)),
'uncommon': WeightedSet((1, 1.0)),
'rare': WeightedSet((1, 1.0), (2, 0.1)),
'very rare': WeightedSet((1, 1.0), (2, 1.0)),
'legendary': WeightedSet((2, 1.0), (3, 1.0)),
}
def get_template(self, attrs) -> WeightedSet:
if not attrs['properties']:
return '{base.name}'
options = []
if attrs['nouns']:
options.append(('{base.name} of {nouns}', 1.0))
if attrs['adjectives']:
options.append(('{adjectives} {base.name}', 1.0))
if attrs['nouns'] and attrs['adjectives']:
numprops = len(attrs['properties'].keys())
if numprops == 1:
options.append(('{adjectives} {base.name} of {nouns}', 0.1))
elif len(attrs['properties'].items()) > 1:
options.append(('{adjectives} {base.name} of {nouns}', 1.0))
options.append(('{base.name} of {adjectives} {nouns}', 1.0))
return WeightedSet(*options).random()
def random_attributes(self) -> dict:
"""
Select a random magical damage type and add it to our properties.
"""
# Select a random rarity. This will use the frequency distribution
# currently selectedon the rarity data source, which in turn will be
# set by self.random(), controllable by the caller.
attrs = dict(
base=self.bases.random(),
rarity=self.rarity.random(),
properties=dict(),
)
attrs['base']['targets'] = 1
if attrs['base']['category'] == 'Martial':
if not attrs['base']['range']:
attrs['base']['range'] = '5'
rarity = attrs['rarity']['rarity']
numprops = min(
self.property_count_by_rarity[rarity].random(),
len(self.properties[rarity].members)
)
while len(attrs['properties']) != numprops:
prop = self.properties[rarity].random()
if prop['name'] in attrs['properties']:
continue
attrs['properties'][prop['name']] = prop
# combine multiple property template arguments together
attrs['adjectives'] = []
attrs['nouns'] = []
for prop_name, prop in attrs['properties'].items():
attrs['adjectives'].append(prop['adjectives'])
attrs['nouns'].append(prop['nouns'])
if prop['name'] == 'enchanted':
attrs['enchanted'] = self.enchanted.random()
attrs['enchanted']['adjectives'] = random.choice(attrs['enchanted']['adjectives'].split(',')).strip()
attrs['enchanted']['nouns'] = random.choice(attrs['enchanted']['nouns'].split(',')).strip()
attrs['template'] = self.get_template(attrs)
attrs['adjectives'] = ' '.join(attrs['adjectives'])
attrs['nouns'] = ' '.join(attrs['nouns'])
return attrs
@dataclass @dataclass
class GeneratorSource: class GeneratorSource:
generator: ItemGenerator generator: ItemGenerator
@ -380,7 +249,13 @@ class GeneratorSource:
def random_values(self, count: int = 1) -> list: def random_values(self, count: int = 1) -> list:
vals = sorted( vals = sorted(
(item.rarity['sort_order'], [item.name, item.rarity['rarity'], item.summary, item.base.properties]) (
item.rarity['sort_order'],
[
item.name, item.rarity['rarity'],
item.summary, ', '.join(item.get('properties', []))
]
)
for item in self.generator.random(count=count, challenge_rating=self.cr) for item in self.generator.random(count=count, challenge_rating=self.cr)
) )
return [v[1] for v in vals] return [v[1] for v in vals]

176
dnd_item/weapons.py Normal file
View File

@ -0,0 +1,176 @@
import random
from functools import cached_property
from dnd_item import types
from random_sets.sets import WeightedSet, equal_weights
def random_from_csv(csv: str) -> str:
return random.choice(csv.split(',')).strip()
class Weapon(types.Item):
"""
"""
def _descriptors(self) -> tuple:
"""
Collect the nouns and adjectives from the properties of this item.
"""
nouns = dict()
adjectives = dict()
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)
return (nouns, adjectives)
def _name_template(self, with_adjectives: bool, with_nouns: bool) -> str:
num_properties = len(self.properties)
options = []
if with_nouns:
options.append(('{name} of {nouns}', 0.5))
if with_adjectives:
options.append(('{adjectives} {name}', 0.5))
if with_nouns and with_adjectives:
if num_properties == 1:
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),
])
return WeightedSet(*options).random()
def _random_descriptors(self):
"""
Select random nouns and adjectives from the object
"""
random_nouns = []
random_adjectives = []
(nouns, adjectives) = self._descriptors()
if not (nouns or adjectives):
return (random_nouns, random_adjectives)
def add_word(key, obj, source):
obj.append(source[key].random().strip())
for prop_name in set(list(nouns.keys()) + list(adjectives.keys())):
if prop_name in nouns and prop_name in adjectives:
val = random.random()
if val <= 0.4:
add_word(prop_name, random_nouns, nouns)
elif val <= 0.8:
add_word(prop_name, random_adjectives, adjectives)
else:
add_word(prop_name, random_nouns, nouns)
add_word(prop_name, random_adjectives, adjectives)
elif prop_name in nouns:
add_word(prop_name, random_nouns, nouns)
elif prop_name in adjectives:
add_word(prop_name, random_adjectives, adjectives)
random_nouns = ' '.join(random_nouns)
random_adjectives = ' '.join(random_adjectives)
return (random_nouns, random_adjectives)
@cached_property
def name(self) -> str:
base_name = super().name
(nouns, adjectives) = self._random_descriptors()
if not (nouns or adjectives):
return base_name
template = self._name_template(
with_adjectives=True if adjectives else False,
with_nouns=True if nouns else False,
)
return template.format(**self, adjectives=adjectives, nouns=nouns, name=base_name).title()
@property
def to_hit(self):
bonus_val = 0
bonus_dice = ''
if not hasattr(self, 'properties'):
return ''
for prop in self.properties.values():
mod = getattr(prop, 'to_hit', None)
if not mod:
continue
if type(mod) is int:
bonus_val += mod
elif type(mod) is str:
bonus_dice += f"+{mod}"
return f"+{bonus_val}{bonus_dice}"
@property
def damage_dice(self):
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)
if not mod:
continue
key = str(prop.damage_type)
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()])
@property
def summary(self):
return f"{self.to_hit} to hit, {self.range} ft., {self.targets} targets. {self.damage_dice}"
@property
def details(self):
"""
Format the item properties as nested bullet lists.
"""
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"
])
class WeaponGenerator(types.ItemGenerator):
item_class = Weapon
def __init__(
self,
bases: WeightedSet = types.WEAPON_TYPES,
rarity: WeightedSet = types.RARITY,
properties_by_rarity: dict = types.PROPERTIES_BY_RARITY,
):
super().__init__(bases=bases, rarity=rarity, properties_by_rarity=properties_by_rarity)
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'] = ''
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'])
return prop