initial commit of types library

This commit is contained in:
evilchili 2023-12-23 16:41:21 -08:00
parent e073647e5d
commit f379d01f75
10 changed files with 415 additions and 92 deletions

View File

@ -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
```

View File

@ -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()

View File

@ -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

View File

@ -1,13 +0,0 @@
metadata:
headers:
- Rarity
- Enchantment Type
- Enchantment Noun
- Enchantment Adjective
Common:
Fire:
- Flames
- Flaming
Ice:
- Ice
- Freezing

View File

@ -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

View File

@ -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:

View File

@ -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'

View File

@ -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()
class Weapon(Item):
"""
An Item class representing a weapon.
"""
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):
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)
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

View File

@ -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"

11
tests/test_types.py Normal file
View File

@ -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