diff --git a/dnd_item/cli.py b/dnd_item/cli.py index 0aad686..c121255 100644 --- a/dnd_item/cli.py +++ b/dnd_item/cli.py @@ -11,7 +11,8 @@ from rich.logging import RichHandler from rich.console import Console 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 app = typer.Typer() diff --git a/dnd_item/sources/magic_damage_types.yaml b/dnd_item/sources/magic_damage_types.yaml index b21abef..7891265 100644 --- a/dnd_item/sources/magic_damage_types.yaml +++ b/dnd_item/sources/magic_damage_types.yaml @@ -16,32 +16,32 @@ metadata: necrotic: 1.0 radiant: 1.0 fire: - - flames, fire - - flaming, burning + - 'flames, fire' + - 'flaming, burning' cold: - - ice - - freezing, frosty + - 'ice, cold' + - 'freezing, frosty' acid: - - acid - - acidic, caustic + - 'acid' + - 'acidic, caustic' thunder: - - thunder - - thundering,booming + - 'thunder' + - 'thundering,booming' psychic: - - mind - - psychic + - 'mind, screams' + - 'psychic, screaming' poison: - - poison - - poisonous,toxic + - 'poison, toxins, venom' + - 'poisonous, toxic, venomous' lightning: - - lightning,sparks,shocks - - lightning,shocking,sparking + - 'lightning,sparks,shocks' + - 'lightning,shocking,sparking' force: - - force - - forceful + - 'force' + - 'forceful' necrotic: - - necrosis, darkness, shadows - - necrotic, dark, unholy + - 'necrosis, darkness, shadows' + - 'necrotic, dark, unholy' radiant: - - radiance, shining - - radiant, holy + - 'radiance, shining' + - 'radiant, holy' diff --git a/dnd_item/sources/properties_base.yaml b/dnd_item/sources/properties_base.yaml new file mode 100644 index 0000000..759516e --- /dev/null +++ b/dnd_item/sources/properties_base.yaml @@ -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: + - '' diff --git a/dnd_item/sources/properties_common.yaml b/dnd_item/sources/properties_common.yaml index a10a2c1..40cf45a 100644 --- a/dnd_item/sources/properties_common.yaml +++ b/dnd_item/sources/properties_common.yaml @@ -4,9 +4,19 @@ metadata: - nouns - adjectives - description + - damage_type + - damage + - to_hit + - override_damage_type + - override_damage - type -light: - - 'light' - - 'light' - - 'A light weapon is small and easy to handle, making it ideal for use when fighting with two weapons.' - - weapon +'smiles': + - 'smiles, grins, joy, smirks' + - 'smiling, grinning, joyfulness, smirking' + - 'While this {this.type} is equipped, you are forced to smile.' + - null + - null + - null + - null + - null + - item diff --git a/dnd_item/sources/properties_legendary.yaml b/dnd_item/sources/properties_legendary.yaml index 6a61e31..6c6d1ca 100644 --- a/dnd_item/sources/properties_legendary.yaml +++ b/dnd_item/sources/properties_legendary.yaml @@ -4,5 +4,23 @@ metadata: - nouns - adjectives - description + - damage_type + - damage + - to_hit - 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 diff --git a/dnd_item/sources/properties_rare.yaml b/dnd_item/sources/properties_rare.yaml index 8a98f08..5653481 100644 --- a/dnd_item/sources/properties_rare.yaml +++ b/dnd_item/sources/properties_rare.yaml @@ -9,18 +9,18 @@ metadata: - to_hit - type enchanted: - - '{enchanted.nouns}' - - '{enchanted.adjectives}' + - '{enchantment.nouns}' + - '{enchantment.adjectives}' - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' - - '{enchanted.damage_type}' + - '{enchantment.damage_type}' - 1d6 - 0 - weapon magical: - - magic + - 'striking, smacking' - '+2' - This magical weapon grants +2 to attack and damage rolls. - - '{base.damage_type}' + - '{damage_type}' - 2 - 2 - weapon diff --git a/dnd_item/sources/properties_uncommon.yaml b/dnd_item/sources/properties_uncommon.yaml index 041100f..07896e8 100644 --- a/dnd_item/sources/properties_uncommon.yaml +++ b/dnd_item/sources/properties_uncommon.yaml @@ -7,20 +7,38 @@ metadata: - damage_type - damage - to_hit + - override_damage_type + - override_damage - 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.nouns}' - - '{enchanted.adjectives}' + - '{enchantment.nouns}' + - '{enchantment.adjectives}' - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' - - '{enchanted.damage_type}' + - '{enchantment.damage_type}' - 1d4 - 0 + - null + - null + - null - weapon magical: - - magic + - 'striking, smacking' - '+1' - This magical weapon grants +1 to attack and damage rolls. - - '{base.damage_type}' + - '{damage_type}' - 1 - 1 + - null + - null + - null - weapon diff --git a/dnd_item/sources/properties_very_rare.yaml b/dnd_item/sources/properties_very_rare.yaml index 4fd6c1d..4de5844 100644 --- a/dnd_item/sources/properties_very_rare.yaml +++ b/dnd_item/sources/properties_very_rare.yaml @@ -9,18 +9,18 @@ metadata: - to_hit - type enchanted: - - '{enchanted.nouns}' - - '{enchanted.adjectives}' + - '{enchantment.nouns}' + - '{enchantment.adjectives}' - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' - - '{enchanted.damage_type}' + - '{enchantment.damage_type}' - 1d8 - 0 - weapon magical: - - magic + - 'striking, smacking' - '+3' - This magical weapon grants +3 to attack and damage rolls. - - '{base.damage_type}' + - '{damage_type}' - 3 - 3 - weapon diff --git a/dnd_item/sources/rarity.yaml b/dnd_item/sources/rarity.yaml index 7acbf44..bb85e86 100644 --- a/dnd_item/sources/rarity.yaml +++ b/dnd_item/sources/rarity.yaml @@ -39,7 +39,8 @@ metadata: uncommon: 0.05 rare: 0.5 very rare: 0.5 - legendary: 0.3 + #legendary: 0.3 + legendary: 0.0 common: - 0 uncommon: diff --git a/dnd_item/sources/weapons.yaml b/dnd_item/sources/weapons.yaml index 76ed421..185d220 100644 --- a/dnd_item/sources/weapons.yaml +++ b/dnd_item/sources/weapons.yaml @@ -29,7 +29,7 @@ Blowgun: - 25/100 - '' - '1000' - - ammmunition, loading + - ammunition, loading Club: - simple - Martial @@ -139,7 +139,7 @@ Hand crossbow: - 30/120 - '' - '7500' - - ammmunition, light, loading + - ammunition, light, loading Handaxe: - simple - Martial @@ -159,7 +159,7 @@ Heavy crossbow: - 100/400 - '' - '5000' - - ammmunition, heavy, loading, two-handed + - ammunition, heavy, loading, two-handed Hooked shortspear: - martial - Martial @@ -179,7 +179,7 @@ Hoopak: - 40/160 - '' - '10' - - ammmunition, finesse, special, two-handed + - ammunition, finesse, special, two-handed Javelin: - simple - Martial @@ -209,7 +209,7 @@ Light crossbow: - 80/320 - '' - '2500' - - ammmunition, loading, two-handed + - ammunition, loading, two-handed Light hammer: - simple - Martial @@ -229,7 +229,7 @@ Light repeating crossbow: - 40/160 - '' - '' - - ammmunition, two-handed + - ammunition, two-handed Longbow: - martial - Ranged @@ -239,7 +239,7 @@ Longbow: - 150/600 - '' - '5000' - - ammmunition, heavy, two-handed + - ammunition, heavy, two-handed Longsword: - martial - Martial @@ -339,7 +339,7 @@ Shortbow: - 80/320 - '' - '2500' - - ammmunition, two-handed + - ammunition, two-handed Shortsword: - martial - Martial @@ -369,7 +369,7 @@ Sling: - 30/120 - '' - '10' - - ammmunition + - ammunition Spear: - simple - Martial diff --git a/dnd_item/types.py b/dnd_item/types.py index e587c92..0360e89 100644 --- a/dnd_item/types.py +++ b/dnd_item/types.py @@ -1,5 +1,8 @@ -import random +import re +import logging + from pathlib import Path +from collections.abc import Mapping from dataclasses import dataclass, field 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')) WEAPON_TYPES = DataSourceSet(sources / Path('weapons.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')), 'uncommon': DataSourceSet(sources / Path('properties_uncommon.yaml')), 'rare': DataSourceSet(sources / Path('properties_rare.yaml')), @@ -24,39 +28,28 @@ PROPERTIES = { @dataclass -class AttributeDict: - _attrs: field(default_factory=dict) = None +class AttributeDict(Mapping): + attributes: field(default_factory=dict) def __getattr__(self, attr): """ Look up attributes in the _attrs dict first, then fall back to the default. """ - if attr in self._attrs: - return self._attrs[attr] + if attr in self.attributes: + return self.attributes[attr] return self.__getattribute__(attr) - def __str__(self): - return self._flatten(self) + def __len__(self): + return len(self.attributes) - def __repr__(self): - return str(self) + def __getitem__(self, key): + return self.attributes[key] - def _flatten(self, obj, prefix=None): - if prefix == '': - 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) + def __iter__(self): + return iter(self.attributes) @classmethod - def from_dict(cls, **kwargs: dict): + def from_dict(cls, kwargs: dict): """ Create a new AttributeDict object using keyword arguments. Dicts are recursively converted to AttributeDict objects; everything else is @@ -64,8 +57,8 @@ class AttributeDict: """ attrs = {} for k, v in sorted(kwargs.items()): - attrs[k] = AttributeDict.from_dict(**v)if type(v) is dict else v - return cls(_attrs=attrs) + attrs[k] = AttributeDict.from_dict(v)if type(v) is dict else v + return cls(attributes=attrs) @dataclass @@ -73,192 +66,155 @@ class Item(AttributeDict): """ """ _name: str = None - rarity: str = None - _template: str = None @property - def name(self): - return self._template.format(name=self._name, **self._attrs).format(**self._attrs).title() - - @property - def properties(self): - return self._attrs['properties']._attrs + def name(self) -> str: + """ + The item's name. This is a handy property for subclassers to override. + """ + return self._name @property def description(self): - txt = [] - for k, v in self.properties.items(): - 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)))) + desc = "\n".join([k.title() + ". " + v.description for k, v in self.get('properties', {}).items()]) + return desc.format(**self) @classmethod - def from_dict(cls, **kwargs: dict): + 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. - - 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): - return str(self) + # delay processing the 'properties' attribute until after the other + # attributes, because they may contain references to those attributes. + properties = attrs.pop('properties', None) + attributes = dict() -class Weapon(Item): - """ - An Item class representing a weapon with the following attributes: - """ + # recursively locate and populate template strings + def _format(obj, this=None): - @property - def to_hit(self): - bonus_val = 0 - bonus_dice = '' - 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}" + # 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 = AttributeDict.from_dict(this) - @property - def damage_dice(self): - dmg = dict() - dmg[self.base.damage_type] = self.base.damage or '' - for prop in self.properties.values(): - mod = getattr(prop, 'damage', None) - if not mod: - continue - key = str(prop.damage_type).format(**self._attrs).title() - if key not in dmg: - dmg[key] = str(mod) + # 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() + )) + 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 + + # 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: - 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 - def summary(self): - return f"{self.to_hit} to hit, {self.base.range} ft., {self.base.targets} targets. {self.damage_dice}" - - @property - def details(self): - """ - 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) - ]) + # 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 + ) 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 - def __init__( - self, - templates: WeightedSet, - bases: WeightedSet, - rarity: WeightedSet = RARITY, - properties: WeightedSet = PROPERTIES, - ): + def __init__(self, bases: WeightedSet, rarity: WeightedSet, properties_by_rarity: dict): self.bases = bases - self.templates = templates self.rarity = rarity - self.properties = properties + self.properties_by_rarity = properties_by_rarity - def random_attributes(self) -> dict: - """ - Select random values from the available attributes. These values will - be passed as arguments to the Item constructor. - - If you subclass this class and override this method, be sure that - whatever attributes are referenced in your template strings are - 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(), + 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)), } + 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: """ @@ -282,97 +238,10 @@ class ItemGenerator: items = [] 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 -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 class GeneratorSource: generator: ItemGenerator @@ -380,7 +249,13 @@ class GeneratorSource: def random_values(self, count: int = 1) -> list: 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) ) return [v[1] for v in vals] diff --git a/dnd_item/weapons.py b/dnd_item/weapons.py new file mode 100644 index 0000000..70dd552 --- /dev/null +++ b/dnd_item/weapons.py @@ -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