From 6d45ba9c4b01638b91766daec6348c550ce7c488 Mon Sep 17 00:00:00 2001 From: evilchili Date: Wed, 27 Dec 2023 00:42:31 -0800 Subject: [PATCH] first working commit of random items --- dnd_item/cli.py | 12 +- dnd_item/sources/properties.yaml | 24 -- dnd_item/sources/properties_common.yaml | 2 +- dnd_item/sources/properties_rare.yaml | 20 +- dnd_item/sources/properties_uncommon.yaml | 20 +- dnd_item/sources/properties_very_rare.yaml | 20 +- dnd_item/sources/rarity.yaml | 6 + dnd_item/sources/weapons.yaml | 52 ++-- dnd_item/types.py | 303 ++++++++++++--------- 9 files changed, 246 insertions(+), 213 deletions(-) delete mode 100644 dnd_item/sources/properties.yaml diff --git a/dnd_item/cli.py b/dnd_item/cli.py index 39159cf..0aad686 100644 --- a/dnd_item/cli.py +++ b/dnd_item/cli.py @@ -11,7 +11,7 @@ from rich.logging import RichHandler from rich.console import Console from rich.table import Table -from dnd_item.types import WeaponGenerator, MagicWeaponGenerator, RollTable +from dnd_item.types import WeaponGenerator, RollTable from dnd_item import five_e app = typer.Typer() @@ -24,7 +24,6 @@ class OUTPUT_FORMATS(Enum): markdown = 'markdown' - @app.callback() def main( cr: int = typer.Option(default=None, help='The Challenge Rating to use when determining rarity.'), @@ -48,12 +47,6 @@ def weapon(count: int = typer.Option(1, help="The number of weapons to generate. console.print(weapon.details) -@app.command() -def magic_weapon(count: int = typer.Option(1, help="The number of weapons to generate.")): - console = Console() - for weapon in MagicWeaponGenerator().random(count=count, challenge_rating=app_state['cr']): - console.print(weapon.details) - @app.command("roll-table") def table( die: int = typer.Option( @@ -78,7 +71,7 @@ def table( CLI for creating roll tables of randomly-generated items. """ rt = RollTable( - sources=[MagicWeaponGenerator], + sources=[WeaponGenerator], die=die, hide_rolls=hide_rolls, challenge_rating=app_state['cr'], @@ -96,7 +89,6 @@ def table( print(table) - @app.command() def convert(): src = five_e.weapons() diff --git a/dnd_item/sources/properties.yaml b/dnd_item/sources/properties.yaml deleted file mode 100644 index f4c43e8..0000000 --- a/dnd_item/sources/properties.yaml +++ /dev/null @@ -1,24 +0,0 @@ -metadata: - headers: - - rarity - - name - - nouns - - adjectives - - description - - type - -common: -uncommon: -rare: - '+2': - - magic - - '+2' - - This magical weapon grants +2 to attack and damage rolls. - - weapon -very rare: - '+3': - - magic - - '+3' - - This magical weapon grants +3 to attack and damage rolls. - - weapon -legendary: diff --git a/dnd_item/sources/properties_common.yaml b/dnd_item/sources/properties_common.yaml index 6109c02..a10a2c1 100644 --- a/dnd_item/sources/properties_common.yaml +++ b/dnd_item/sources/properties_common.yaml @@ -8,5 +8,5 @@ metadata: light: - 'light' - 'light' - - 'Weapons with the light property...' + - 'A light weapon is small and easy to handle, making it ideal for use when fighting with two weapons.' - weapon diff --git a/dnd_item/sources/properties_rare.yaml b/dnd_item/sources/properties_rare.yaml index 8f9d238..8a98f08 100644 --- a/dnd_item/sources/properties_rare.yaml +++ b/dnd_item/sources/properties_rare.yaml @@ -4,17 +4,23 @@ metadata: - nouns - adjectives - description - - damage_modifier + - damage_type + - damage + - to_hit - type -element: - - '{element.nouns}' - - '{element.adjectives}' - - 'Attacks made with the {name} do an extra {this.damage_modifier} {element.damage_type} damage.' - - d6 +enchanted: + - '{enchanted.nouns}' + - '{enchanted.adjectives}' + - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' + - '{enchanted.damage_type}' + - 1d6 + - 0 - weapon -'+2': +magical: - magic - '+2' - This magical weapon grants +2 to attack and damage rolls. + - '{base.damage_type}' + - 2 - 2 - weapon diff --git a/dnd_item/sources/properties_uncommon.yaml b/dnd_item/sources/properties_uncommon.yaml index 8b2bb1a..041100f 100644 --- a/dnd_item/sources/properties_uncommon.yaml +++ b/dnd_item/sources/properties_uncommon.yaml @@ -4,17 +4,23 @@ metadata: - nouns - adjectives - description - - damage_modifier + - damage_type + - damage + - to_hit - type -element: - - '{element.nouns}' - - '{element.adjectives}' - - 'Attacks made with the {name} do an extra {this.damage_modifier} {element.damage_type} damage.' - - d4 +enchanted: + - '{enchanted.nouns}' + - '{enchanted.adjectives}' + - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' + - '{enchanted.damage_type}' + - 1d4 + - 0 - weapon -'+1': +magical: - magic - '+1' - This magical weapon grants +1 to attack and damage rolls. + - '{base.damage_type}' + - 1 - 1 - weapon diff --git a/dnd_item/sources/properties_very_rare.yaml b/dnd_item/sources/properties_very_rare.yaml index de41cd4..4fd6c1d 100644 --- a/dnd_item/sources/properties_very_rare.yaml +++ b/dnd_item/sources/properties_very_rare.yaml @@ -4,17 +4,23 @@ metadata: - nouns - adjectives - description - - damage modifier + - damage_type + - damage + - to_hit - type -element: - - '{element.nouns}' - - '{element.adjectives}' - - 'Attacks made with the {name} do an extra {this.damage_modifier} {element.damage_type} damage.' - - d8 +enchanted: + - '{enchanted.nouns}' + - '{enchanted.adjectives}' + - 'Attacks made with this magical weapon do an extra {this.damage} {this.damage_type} damage.' + - '{enchanted.damage_type}' + - 1d8 + - 0 - weapon -'+3': +magical: - magic - '+3' - This magical weapon grants +3 to attack and damage rolls. + - '{base.damage_type}' + - 3 - 3 - weapon diff --git a/dnd_item/sources/rarity.yaml b/dnd_item/sources/rarity.yaml index 24165a8..7acbf44 100644 --- a/dnd_item/sources/rarity.yaml +++ b/dnd_item/sources/rarity.yaml @@ -8,6 +8,7 @@ metadata: headers: - rarity + - sort_order frequencies: 'default': common: 1.0 @@ -40,7 +41,12 @@ metadata: very rare: 0.5 legendary: 0.3 common: + - 0 uncommon: + - 1 rare: + - 2 very rare: + - 3 legendary: + - 4 diff --git a/dnd_item/sources/weapons.yaml b/dnd_item/sources/weapons.yaml index 94b7196..76ed421 100644 --- a/dnd_item/sources/weapons.yaml +++ b/dnd_item/sources/weapons.yaml @@ -4,8 +4,8 @@ metadata: - category - type - weight + - damage_type - damage - - die - range - reload - value @@ -16,7 +16,7 @@ Battleaxe: - '4' - Slashing - 1d8 - - null + - 5 - '' - '1000' - versatile @@ -36,7 +36,7 @@ Club: - '2' - Bludgeoning - 1d4 - - null + - 5 - '' - '10' - light @@ -66,7 +66,7 @@ Double-bladed scimitar: - '6' - Slashing - 2d4 - - null + - 5 - '' - '10000' - special, two-handed @@ -76,7 +76,7 @@ Flail: - '2' - Bludgeoning - 1d8 - - null + - 5 - '' - '1000' - '' @@ -86,7 +86,7 @@ Glaive: - '6' - Slashing - 1d10 - - null + - 5 - '' - '2000' - heavy, reach, two-handed @@ -96,7 +96,7 @@ Greataxe: - '7' - Slashing - 1d12 - - null + - 5 - '' - '3000' - heavy, two-handed @@ -106,7 +106,7 @@ Greatclub: - '10' - Bludgeoning - 1d8 - - null + - 5 - '' - '20' - two-handed @@ -116,7 +116,7 @@ Greatsword: - '6' - Slashing - 2d6 - - null + - 5 - '' - '5000' - heavy, two-handed @@ -126,7 +126,7 @@ Halberd: - '6' - Slashing - 1d10 - - null + - 5 - '' - '2000' - heavy, reach, two-handed @@ -166,7 +166,7 @@ Hooked shortspear: - '2' - Piercing - 1d4 - - null + - 5 - '' - '' - light @@ -196,7 +196,7 @@ Lance: - '6' - Piercing - 1d12 - - null + - 5 - '' - '1000' - reach, special @@ -246,7 +246,7 @@ Longsword: - '3' - Slashing - 1d8 - - null + - 5 - '' - '1500' - versatile @@ -256,7 +256,7 @@ Mace: - '4' - Bludgeoning - 1d6 - - null + - 5 - '' - '500' - '' @@ -266,7 +266,7 @@ Maul: - '10' - Bludgeoning - 2d6 - - null + - 5 - '' - '1000' - heavy, two-handed @@ -276,7 +276,7 @@ Morningstar: - '4' - Piercing - 1d8 - - null + - 5 - '' - '1500' - '' @@ -285,7 +285,7 @@ Net: - Ranged - '3' - '' - - null + - 5 - 5/15 - '' - '100' @@ -296,7 +296,7 @@ Pike: - '18' - Piercing - 1d10 - - null + - 5 - '' - '500' - heavy, reach, two-handed @@ -306,7 +306,7 @@ Quarterstaff: - '4' - Bludgeoning - 1d6 - - null + - 5 - '' - '20' - versatile @@ -316,7 +316,7 @@ Rapier: - '2' - Piercing - 1d8 - - null + - 5 - '' - '2500' - finesse @@ -326,7 +326,7 @@ Scimitar: - '3' - Slashing - 1d6 - - null + - 5 - '' - '2500' - finesse, light @@ -346,7 +346,7 @@ Shortsword: - '2' - Piercing - 1d6 - - null + - 5 - '' - '1000' - finesse, light @@ -356,7 +356,7 @@ Sickle: - '2' - Slashing - 1d4 - - null + - 5 - '' - '100' - light @@ -396,7 +396,7 @@ War pick: - '2' - Piercing - 1d8 - - null + - 5 - '' - '500' - '' @@ -406,7 +406,7 @@ Warhammer: - '2' - Bludgeoning - 1d8 - - null + - 5 - '' - '1500' - versatile @@ -416,7 +416,7 @@ Whip: - '3' - Slashing - 1d4 - - null + - 5 - '' - '200' - finesse, reach diff --git a/dnd_item/types.py b/dnd_item/types.py index d6e3f58..e587c92 100644 --- a/dnd_item/types.py +++ b/dnd_item/types.py @@ -11,7 +11,7 @@ import rolltable.types # objects generated from yaml data files. These are used to supply default # values to item generators; see below. sources = Path(__file__).parent / Path("sources") -MAGIC_DAMAGE = DataSourceSet(sources / Path('magic_damage_types.yaml')) +ENCHANTMENT = DataSourceSet(sources / Path('magic_damage_types.yaml')) WEAPON_TYPES = DataSourceSet(sources / Path('weapons.yaml')) RARITY = DataSourceSet(sources / Path('rarity.yaml')) PROPERTIES = { @@ -24,79 +24,9 @@ PROPERTIES = { @dataclass -class Item: - """ - Item is a data class that constructs its attributes from keyword arguments - passed to the Item.from_dict() method. Any args that are dicts are - recursively converted into Item objects, allowing for access to nested - attribtues using dotted notation. Example: - - >>> orb = Item.from_dict(rarity='rare', name='Orb of Example', - extra={'cost_in_gp': 1000}) - >>> orb.rarity - rare - >>> orb.name - Orb of Example - >>> orb.extra.cost_in_gp - 1000 - - String Formatting: - - >>> orb.details - Orb of Example - * extra.cost_in_gp: 1000 - * rarity: rare - - Name Templates: - - Item names can be built by overriding the default template, using - any available attribute: - - >>> orb = Item.from_dict( - ? name="orb", - ? rarity="rare", - ? extra={"cost_in_gp": 1000, "color": "green"}, - ? template="{rarity} {extra.color} {name} of Example", - ? ) - >>> orb.name - Rare Green Orb of Example - """ - _name: str = None - _template: str = None +class AttributeDict: _attrs: field(default_factory=dict) = None - @property - def name(self): - return self._template.format(name=self._name, **self._attrs).format(**self._attrs).title() - - @property - def summary(self): - txt = [] - for k, v in self._attrs['properties']._attrs.items(): - txt.append(v.description.format( - this=v, - name=self.name, - **self._attrs, - )) - return "\n\n".join(txt) - - @property - def details(self): - """ - Format the item attributes as nested bullet lists. - """ - return f"{self.name} ({self.rarity.rarity})\n{self.summary}\n--\n{self.properties}" - - @property - def properties(self): - def attrs_to_lines(item, prefix: str = ''): - for (k, v) in item._attrs.items(): - if type(v) is Item: - 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)))) - def __getattr__(self, attr): """ Look up attributes in the _attrs dict first, then fall back to the default. @@ -105,6 +35,88 @@ class Item: return self._attrs[attr] return self.__getattribute__(attr) + def __str__(self): + return self._flatten(self) + + def __repr__(self): + return str(self) + + 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) + + @classmethod + def from_dict(cls, **kwargs: dict): + """ + Create a new AttributeDict object using keyword arguments. Dicts are + recursively converted to AttributeDict objects; everything else is + passed as-is. + """ + 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) + + +@dataclass +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 + + @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)))) + @classmethod def from_dict(cls, **kwargs: dict): """ @@ -117,18 +129,70 @@ class Item: 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] = Item.from_dict(**v)if type(v) is dict else v - return cls(_name=name, _template=template, _attrs=attrs) + 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) class Weapon(Item): """ - An Item class representing a weapon. + An Item class representing a weapon with the following attributes: """ + @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}" + + @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) + else: + dmg[key] += f"+{mod}" + + return ' + '.join([f"{v} {k}" for k, v in dmg.items()]) + + @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) + ]) + class ItemGenerator: """ @@ -165,11 +229,11 @@ class ItemGenerator: def __init__( self, templates: WeightedSet, - types: WeightedSet, + bases: WeightedSet, rarity: WeightedSet = RARITY, properties: WeightedSet = PROPERTIES, ): - self.types = types + self.bases = bases self.templates = templates self.rarity = rarity self.properties = properties @@ -191,7 +255,7 @@ class ItemGenerator: """ return { 'template': self.templates.random(), - 'type': self.types.random(), + 'base': self.bases.random(), 'rarity': self.rarity.random(), 'properties': self.properties.random(), } @@ -223,37 +287,18 @@ class ItemGenerator: class WeaponGenerator(ItemGenerator): - """ - An ItemGenerator that generates basic (non-magical) weapons. - """ - item_class = Weapon def __init__( self, - templates: WeightedSet = None, - types: WeightedSet = WEAPON_TYPES, + templates: WeightedSet = WeightedSet(('{base.name}', 1.0),), + bases: WeightedSet = WEAPON_TYPES, rarity: WeightedSet = RARITY, + enchanted: WeightedSet = ENCHANTMENT, properties: WeightedSet = PROPERTIES, ): - if not templates: - templates = WeightedSet(('{type.name}', 1.0),) - super().__init__(types=types, templates=templates, rarity=rarity, properties=properties) - - -class MagicWeaponGenerator(WeaponGenerator): - """ - An ItemGenerator that generates weapons imbued with magical effects. - """ - def __init__( - self, - types: WeightedSet = WEAPON_TYPES, - rarity: WeightedSet = RARITY, - element: WeightedSet = MAGIC_DAMAGE, - properties: WeightedSet = PROPERTIES, - ): - super().__init__(types=types, templates=None, rarity=rarity, properties=properties) - self.element = element + 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)), @@ -264,19 +309,19 @@ class MagicWeaponGenerator(WeaponGenerator): def get_template(self, attrs) -> WeightedSet: if not attrs['properties']: - return '{type.name}' + return '{base.name}' options = [] if attrs['nouns']: - options.append(('{type.name} of {nouns}', 1.0)) + options.append(('{base.name} of {nouns}', 1.0)) if attrs['adjectives']: - options.append(('{adjectives} {type.name}', 1.0)) + options.append(('{adjectives} {base.name}', 1.0)) if attrs['nouns'] and attrs['adjectives']: numprops = len(attrs['properties'].keys()) if numprops == 1: - options.append(('{adjectives} {type.name} of {nouns}', 1.0)) + options.append(('{adjectives} {base.name} of {nouns}', 0.1)) elif len(attrs['properties'].items()) > 1: - options.append(('{adjectives} {type.name} of {nouns}', 1.0)) - options.append(('{type.name} of {adjectives} {nouns}', 1.0)) + 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: @@ -288,10 +333,16 @@ class MagicWeaponGenerator(WeaponGenerator): # currently selectedon the rarity data source, which in turn will be # set by self.random(), controllable by the caller. attrs = dict( - type=self.types.random(), + 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( @@ -311,10 +362,10 @@ class MagicWeaponGenerator(WeaponGenerator): for prop_name, prop in attrs['properties'].items(): attrs['adjectives'].append(prop['adjectives']) attrs['nouns'].append(prop['nouns']) - if prop['name'] == 'element': - attrs['element'] = self.element.random() - attrs['element']['adjectives'] = random.choice(attrs['element']['adjectives'].split(',')).strip() - attrs['element']['nouns'] = random.choice(attrs['element']['nouns'].split(',')).strip() + 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']) @@ -328,10 +379,11 @@ class GeneratorSource: cr: int def random_values(self, count: int = 1) -> list: - return [ - [item.name, item.rarity.rarity, item.summary] + vals = sorted( + (item.rarity['sort_order'], [item.name, item.rarity['rarity'], item.summary, item.base.properties]) for item in self.generator.random(count=count, challenge_rating=self.cr) - ] + ) + return [v[1] for v in vals] class RollTable(rolltable.types.RollTable): @@ -340,14 +392,14 @@ class RollTable(rolltable.types.RollTable): sources: list, die: int = 20, hide_rolls: bool = False, - challenge_rating: int = 0 + challenge_rating: int = 0, ): self._cr = challenge_rating super().__init__( sources=sources, frequency='default', die=die, - hide_rolls=hide_rolls + hide_rolls=hide_rolls, ) def _config(self): @@ -358,18 +410,7 @@ class RollTable(rolltable.types.RollTable): self._headers = [ 'Name', 'Rarity', - 'Description', + 'Summary', + 'Properties' ] self._header_excludes = [] - - @property - def _values(self) -> list: - if not self._generated_values: - ds_values = [t.random_values(self.die) for t in self._data] - self._generated_values = [] - for face in range(self._die): - value = [] - for index, ds in enumerate(ds_values): - value += ds_values[index][face] - self._generated_values.append(value) - return self._generated_values