diff --git a/README.md b/README.md index e27885f..9da7794 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ -# dnd-item-generator -Generate random weapons, items, and loot for Dungeons & Dragons 5th ed. +# D&D Weapon, Item, and Loot Generator + +**WIP!** + +This package includes a library and CLI for generating randomized weapons, items, and loot for +Dungeons & Dragons, 5th edition. + +## Usage + +The `dnd-item` command-line utility supports several comments: + +* **item**: Generate a random item (the default) +* **weapon**: Generate a basic, non-magical weapon +* **magical-weapon**: Generate a weapon with an added magical damage type + +### Examples: + +```shell +% dnd-item weapon +Pike + * type.category: martial + * type.damage: Piercing + * type.die: 1d10 + * type.properties: heavy, reach, two-handed + * type.range: None + * type.reload: + * type.type: Martial + * type.value: 500 + * type.weight: 18 +``` + +```shell +% dnd-item magic-weapon +Shortsword Of Thunder + * magic.adjective: booming + * magic.die: 1d4 + * magic.noun: thunder + * type.category: martial + * type.damage: Piercing + * type.die: 1d6 + * type.properties: finesse, light + * type.range: None + * type.reload: + * type.type: Martial + * type.value: 1000 + * type.weight: 2 +``` + + diff --git a/dnd_item/cli.py b/dnd_item/cli.py index e3f1978..21f8733 100644 --- a/dnd_item/cli.py +++ b/dnd_item/cli.py @@ -7,9 +7,8 @@ import typer from rich.logging import RichHandler from rich.console import Console -# from rich.table import Table -from dnd_item.types import random_item +from dnd_item.types import WeaponGenerator, MagicWeaponGenerator from dnd_item import five_e app = typer.Typer() @@ -30,13 +29,17 @@ def main(): @app.command() -def item(count: int = typer.Option(1, help="The number of items to generate.")): - items = random_item(count) +def weapon(count: int = typer.Option(1, help="The number of weapons to generate.")): console = Console() - for item in items: - console.print(f"{item['Name']} of {item['Enchantment Noun']} " - f"({item['Damage Dice']} {item['Damage Type']} + " - f"{item['Enchantment Damage']} {item['Enchantment Type']})") + for weapon in WeaponGenerator().random(count): + 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): + console.print(weapon.details) @app.command() diff --git a/dnd_item/five_e.py b/dnd_item/five_e.py index ff69eeb..cff09a1 100644 --- a/dnd_item/five_e.py +++ b/dnd_item/five_e.py @@ -3,7 +3,7 @@ import yaml from collections import defaultdict -from rolltable.tables import DataSource +from random_sets.datasources import DataSource from pathlib import Path diff --git a/dnd_item/sources/enchantments.yaml b/dnd_item/sources/enchantments.yaml deleted file mode 100644 index cf3af43..0000000 --- a/dnd_item/sources/enchantments.yaml +++ /dev/null @@ -1,13 +0,0 @@ -metadata: - headers: - - Rarity - - Enchantment Type - - Enchantment Noun - - Enchantment Adjective -Common: - Fire: - - Flames - - Flaming - Ice: - - Ice - - Freezing diff --git a/dnd_item/sources/magic_damage_types.yaml b/dnd_item/sources/magic_damage_types.yaml new file mode 100644 index 0000000..c404ee8 --- /dev/null +++ b/dnd_item/sources/magic_damage_types.yaml @@ -0,0 +1,50 @@ +metadata: + headers: + - damage_type + - noun + - adjectives + - effect + frequencies: + default: + fire: 1.0 + cold: 1.0 + acid: 1.0 + thunder: 1.0 + psychic: 1.0 + poison: 1.0 + lightning: 1.0 + force: 1.0 + necrotic: 1.0 + radiant: 1.0 +fire: + flames: + - flaming, burning + - burning +cold: + ice: + - freezing, frosty +acid: + acid: + - acidic, caustic +thunder: + thunder: + - thundering,booming +psychic: + psychic: + - psychic + - psychic +poison: + poison: + - poisonous,toxic +lightning: + lightning: + - lightning,shocking +force: + force: + - force +necrotic: + necrosis: + - necrotic, darkness, unholy +radiant: + radiance: + - radiant, holy diff --git a/dnd_item/sources/rarity.yaml b/dnd_item/sources/rarity.yaml new file mode 100644 index 0000000..6a04f39 --- /dev/null +++ b/dnd_item/sources/rarity.yaml @@ -0,0 +1,15 @@ +metadata: + headers: + - Rarity + frequencies: + default: + Common: 1.0 + Uncommon: 0.8 + Rare: 0.5 + Legendary: 0.1 + Unique: 0.05 +Common: +Uncommon: +Rare: +Legendary: +Unique: diff --git a/dnd_item/sources/types.yaml b/dnd_item/sources/weapons.yaml similarity index 86% rename from dnd_item/sources/types.yaml rename to dnd_item/sources/weapons.yaml index 8c6feae..94b7196 100644 --- a/dnd_item/sources/types.yaml +++ b/dnd_item/sources/weapons.yaml @@ -1,18 +1,16 @@ metadata: headers: - - Rarity - - Name - - Category - - Type - - Weight - - Damage Type - - Damage Dice - - Range - - Reload - - Value - - Properties -Common: -- Battleaxe: + - name + - category + - type + - weight + - damage + - die + - range + - reload + - value + - properties +Battleaxe: - martial - Martial - '4' @@ -22,7 +20,7 @@ Common: - '' - '1000' - versatile -- Blowgun: +Blowgun: - martial - Ranged - '1' @@ -32,7 +30,7 @@ Common: - '' - '1000' - ammmunition, loading -- Club: +Club: - simple - Martial - '2' @@ -42,7 +40,7 @@ Common: - '' - '10' - light -- Dagger: +Dagger: - simple - Martial - '1' @@ -52,7 +50,7 @@ Common: - '' - '200' - finesse, light, thrown -- Dart: +Dart: - simple - Ranged - '0.25' @@ -62,7 +60,7 @@ Common: - '' - '5' - finesse, thrown -- Double-bladed scimitar: +Double-bladed scimitar: - martial - Martial - '6' @@ -72,7 +70,7 @@ Common: - '' - '10000' - special, two-handed -- Flail: +Flail: - martial - Martial - '2' @@ -82,7 +80,7 @@ Common: - '' - '1000' - '' -- Glaive: +Glaive: - martial - Martial - '6' @@ -92,7 +90,7 @@ Common: - '' - '2000' - heavy, reach, two-handed -- Greataxe: +Greataxe: - martial - Martial - '7' @@ -102,7 +100,7 @@ Common: - '' - '3000' - heavy, two-handed -- Greatclub: +Greatclub: - simple - Martial - '10' @@ -112,7 +110,7 @@ Common: - '' - '20' - two-handed -- Greatsword: +Greatsword: - martial - Martial - '6' @@ -122,7 +120,7 @@ Common: - '' - '5000' - heavy, two-handed -- Halberd: +Halberd: - martial - Martial - '6' @@ -132,7 +130,7 @@ Common: - '' - '2000' - heavy, reach, two-handed -- Hand crossbow: +Hand crossbow: - martial - Ranged - '3' @@ -142,7 +140,7 @@ Common: - '' - '7500' - ammmunition, light, loading -- Handaxe: +Handaxe: - simple - Martial - '2' @@ -152,7 +150,7 @@ Common: - '' - '500' - light, thrown -- Heavy crossbow: +Heavy crossbow: - martial - Ranged - '18' @@ -162,7 +160,7 @@ Common: - '' - '5000' - ammmunition, heavy, loading, two-handed -- Hooked shortspear: +Hooked shortspear: - martial - Martial - '2' @@ -172,7 +170,7 @@ Common: - '' - '' - light -- Hoopak: +Hoopak: - martial - Martial - '2' @@ -182,7 +180,7 @@ Common: - '' - '10' - ammmunition, finesse, special, two-handed -- Javelin: +Javelin: - simple - Martial - '2' @@ -192,7 +190,7 @@ Common: - '' - '50' - thrown -- Lance: +Lance: - martial - Martial - '6' @@ -202,7 +200,7 @@ Common: - '' - '1000' - reach, special -- Light crossbow: +Light crossbow: - simple - Ranged - '5' @@ -212,7 +210,7 @@ Common: - '' - '2500' - ammmunition, loading, two-handed -- Light hammer: +Light hammer: - simple - Martial - '2' @@ -222,7 +220,7 @@ Common: - '' - '200' - light, thrown -- Light repeating crossbow: +Light repeating crossbow: - simple - Ranged - '5' @@ -232,7 +230,7 @@ Common: - '' - '' - ammmunition, two-handed -- Longbow: +Longbow: - martial - Ranged - '2' @@ -242,7 +240,7 @@ Common: - '' - '5000' - ammmunition, heavy, two-handed -- Longsword: +Longsword: - martial - Martial - '3' @@ -252,7 +250,7 @@ Common: - '' - '1500' - versatile -- Mace: +Mace: - simple - Martial - '4' @@ -262,7 +260,7 @@ Common: - '' - '500' - '' -- Maul: +Maul: - martial - Martial - '10' @@ -272,7 +270,7 @@ Common: - '' - '1000' - heavy, two-handed -- Morningstar: +Morningstar: - martial - Martial - '4' @@ -282,7 +280,7 @@ Common: - '' - '1500' - '' -- Net: +Net: - martial - Ranged - '3' @@ -292,7 +290,7 @@ Common: - '' - '100' - special, thrown -- Pike: +Pike: - martial - Martial - '18' @@ -302,7 +300,7 @@ Common: - '' - '500' - heavy, reach, two-handed -- Quarterstaff: +Quarterstaff: - simple - Martial - '4' @@ -312,7 +310,7 @@ Common: - '' - '20' - versatile -- Rapier: +Rapier: - martial - Martial - '2' @@ -322,7 +320,7 @@ Common: - '' - '2500' - finesse -- Scimitar: +Scimitar: - martial - Martial - '3' @@ -332,7 +330,7 @@ Common: - '' - '2500' - finesse, light -- Shortbow: +Shortbow: - simple - Ranged - '2' @@ -342,7 +340,7 @@ Common: - '' - '2500' - ammmunition, two-handed -- Shortsword: +Shortsword: - martial - Martial - '2' @@ -352,7 +350,7 @@ Common: - '' - '1000' - finesse, light -- Sickle: +Sickle: - simple - Martial - '2' @@ -362,7 +360,7 @@ Common: - '' - '100' - light -- Sling: +Sling: - simple - Ranged - '0' @@ -372,7 +370,7 @@ Common: - '' - '10' - ammmunition -- Spear: +Spear: - simple - Martial - '3' @@ -382,7 +380,7 @@ Common: - '' - '100' - thrown, versatile -- Trident: +Trident: - martial - Martial - '4' @@ -392,7 +390,7 @@ Common: - '' - '500' - thrown, versatile -- War pick: +War pick: - martial - Martial - '2' @@ -402,7 +400,7 @@ Common: - '' - '500' - '' -- Warhammer: +Warhammer: - martial - Martial - '2' @@ -412,7 +410,7 @@ Common: - '' - '1500' - versatile -- Whip: +Whip: - martial - Martial - '3' @@ -422,7 +420,7 @@ Common: - '' - '200' - finesse, reach -- Yklwa: +Yklwa: - simple - Martial - '3' diff --git a/dnd_item/types.py b/dnd_item/types.py index 6e60267..7c60c97 100644 --- a/dnd_item/types.py +++ b/dnd_item/types.py @@ -1,21 +1,232 @@ -from rolltable import tables +import random + from pathlib import Path +from dataclasses import dataclass, field +from random_sets.sets import WeightedSet, DataSourceSet + + +# 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") +MAGIC_DAMAGE = DataSourceSet(sources / Path('magic_damage_types.yaml')) +WEAPON_TYPES = DataSourceSet(sources / Path('weapons.yaml')) +# RARITY = DataSourceSet(sources / Path('rarity.yaml')) +@dataclass class Item: - pass + """ + 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 + _attrs: field(default_factory=dict) = None + + @property + def name(self): + return self._template.format(name=self._name, **self._attrs).title() + + @property + def details(self): + """ + Format the item attributes as nested bullet lists. + """ + 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([self.name] + sorted(list(attrs_to_lines(self)))) + + 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] + return self.__getattribute__(attr) + + @classmethod + def from_dict(cls, **kwargs: 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)' + 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) -def random_item(count=1): - types = (sources / Path('types.yaml')).read_text() - enchantments = (sources / Path('enchantments.yaml')).read_text() - items = [] - for _ in range(count): - rt = tables.RollTable([types, enchantments], die=1, hide_rolls=True) - item = dict(zip(rt.rows[0], rt.rows[1])) - item['Enchantment Damage'] = '1d4' - items.append(item) +class Weapon(Item): + """ + An Item class representing a weapon. + """ - return items + +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, + types: WeightedSet, + ): + self.types = types + self.templates = templates + + def random_properties(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. + """ + + # Select one random template string and one item type. + properties = { + 'template': self.templates.random(), + 'type': self.types.random(), + } + return properties + + def random(self, count: int = 1) -> list: + """ + Generate one or more random Item instances by selecting random values + from the available types and template + """ + items = [] + for _ in range(count): + items.append(self.item_class.from_dict(**self.random_properties())) + return items + + +class WeaponGenerator(ItemGenerator): + """ + An ItemGenerator that generates basic (non-magical) weapons. + """ + + item_class = Weapon + + def __init__( + self, + templates: WeightedSet = None, + types: WeightedSet = WEAPON_TYPES, + ): + if not templates: + templates = WeightedSet(('{type.name}', 1.0),) + super().__init__(types=types, templates=templates) + + +class MagicWeaponGenerator(WeaponGenerator): + """ + An ItemGenerator that generates weapons imbued with magical effects. + """ + def __init__( + self, + templates: WeightedSet = None, + types: WeightedSet = WEAPON_TYPES, + magic: WeightedSet = MAGIC_DAMAGE, + ): + self.magic = magic + if not templates: + templates = WeightedSet( + # "Shortsword of Flames" + ('{type.name} of {magic.noun}', 1.0), + # "Burning Lance" + ('{magic.adjective} {type.name}', 1.0), + ) + super().__init__(types=types, templates=templates) + + def random_properties(self): + """ + Select a random magical damage type and add it to our properties. + """ + properties = super().random_properties() + magic = self.magic.random() + properties['magic'] = { + 'adjective': random.choice(magic['adjectives'].split(',')).strip(), + 'noun': random.choice(magic['noun'].split(',')).strip(), + 'die': '1d4' + } + return properties diff --git a/pyproject.toml b/pyproject.toml index 44cefcc..ecfa307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ rich = "^13.7.0" typer = "^0.9.0" dice = "^4.0.0" -dnd-rolltable = { git = "https://github.com/evilchili/dnd-rolltable", branch='main' } +dnd-name-generator = { git = "https://github.com/evilchili/dnd-name-generator", branch='main' } +random-sets = { git = "https://github.com/evilchili/random-sets", branch='main' } [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" @@ -45,5 +46,5 @@ remove-duplicate-keys = true # remove all duplicate keys in objects remove-unused-variables = true # remove unused variables [tool.poetry.scripts] -fanitem = "dnd_item.cli:app" +dnd-item = "dnd_item.cli:app" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..fffdac3 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,11 @@ +from dnd_item import types + + +def test_item_attributes(): + item = types.Item.from_dict( + foo='bar', + baz={ + 'qaz': True + } + ) + assert item.baz.qaz is True