1.0 release

This commit is contained in:
evilchili 2022-07-31 15:03:19 -07:00
parent 50379e9a2a
commit 1cc245af07
6 changed files with 285 additions and 293 deletions

147
README.md
View File

@ -1,88 +1,71 @@
Generate a roll table using weighted distributions of random options.
Given source.yaml containing options such as:
# RollTables
option1:
- key1: description
- key2: description
...
...
RollTables is a python library for generating tables suitable for selecting random options using dice rolls.
Generate a random table:
## Quick Start
>>> print(RollTable(path='source.yaml'))
d1 option6 key3 description
d2 option2 key2 description
d3 option3 key4 description
...
```
# example.yaml
You can customize the frequency distribution, headers, and table size by
defining metadata in your source file.
# metadata is optional
metadata:
# headers are optional
headers:
# The first column header always applies to the frequency label;
# you can hide this (or any other column) by setting the header to null
- Rarity
- Color
- Notes
# frequencies are optional; by default distribution will be uniform
frequencies:
# multiple distributions may be specified besides 'default'
default:
- common: 0.5
- uncommon: 0.3
- rare: 0.15
- wondrous: 0.05
# 'common' is the text label for the frequency distribution
common:
# each time a 'common' value is selected for the table, it will be
# chosen at random from the following values
- red
- orange
- yellow
uncommon:
- green
- blue
rare:
- indigo
- violet
wondrous:
# choices can be definitions; both key and the value will be added as columns
- octarine: the color of magic
```
```
% poetry run roll-table example.yaml
┏━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Roll ┃ Rarity ┃ Color ┃ Notes ┃
┡━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ d1-d5 │ common │ red │ │
│ d6-d10 │ common │ yellow │ │
│ d11-d12 │ rare │ indigo │ │
│ d13 │ rare │ violet │ │
│ d14-d15 │ uncommon │ blue │ │
│ d16-d19 │ uncommon │ green │ │
│ d20 │ wondrous │ octarine │ the color of magic │
└─────────┴──────────┴──────────┴────────────────────┘
```
Using Metadata:
By default options are given uniform distribution and random keys will be
selected from each option with equal probability. This behaviour can be
changed by adding an optional metadata section to the source file:
metadata:
frequenceis:
default:
option1: 0.5
option2: 0.1
option3: 0.3
option4: 0.1
This will guarantee that random keys from option1 are selected 50% of the
time, from option 2 10% of the time, and so forth. Frequencies should add
up to 1.0.
If the metadata section includes 'frequencies', The 'default' distribution
must be defined. Additional optional distributions may also be defined, if
you want to provide alternatives for specific use cases.
metadata:
frequenceis:
default:
option1: 0.5
option2: 0.1
option3: 0.3
option4: 0.1
inverted:
option1: 0.1
option2: 0.3
option3: 0.1
option4: 0.5
A specific frequency distribution can be specifed by passing the 'frequency'
parameter at instantiation:
>>> t = RollTable('source.yaml', frequency='inverted')
The metadata section can also override the default size of die to use for
the table (a d20). For example, this creates a 100-row table:
metadata:
die: 100
This too can be overridden at instantiation:
>>> t = RollTable('source.yaml', die=64)
Finally, headers for your table columns can also be defined in metadata:
metadata:
headers:
- Roll
- Frequency
- Description
- Effect
This will yield output similar to:
>>> print(RollTable(path='source.yaml'))
Roll Category Name Effect
d1 option6 key3 description
d2 option2 key2 description
d3 option3 key4 description
...
### Library Use
```
from rolltable import tables
sources = [
Path('spells.yaml').read_text(),
Path('weapons.yaml').read_text(),
Path('items.yaml').read_text()
]
rt = tables.RollTable(sources, die=100)
```

37
example.yaml Normal file
View File

@ -0,0 +1,37 @@
# example.yaml
#
# This is an annotated example source file for generating random roll tables.
#
# metadata is optional
metadata:
# headers are optional
headers:
# The first column header always applies to the frequency label;
# you can hide this (or any other column) by setting the header to null
- Rarity
- Color
- Notes
# frequencies are optional; by default distribution will be uniform
frequencies:
# multiple distributions may be specified besides 'default'
default:
common: 0.5
uncommon: 0.3
rare: 0.15
wondrous: 0.05
# 'common' is the text label for the frequency distribution
common:
# each time a 'common' value is selected for the table, it will be
# chosen at random from the following values
- red
- orange
- yellow
uncommon:
- green
- blue
rare:
- indigo
- violet
wondrous:
# choices can be definitions; both key and the value will be added as columns
- octarine: the color of magic

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = 'dnd-rolltable'
version = '0.9'
version = '1.0'
license = 'The Unlicense'
authors = ['Greg Boyington <evilchili@gmail.com>']
description = 'Generate roll tables using weighted random distributions'

View File

@ -2,6 +2,8 @@ from rolltable import tables
import typer
from rich import print
from rich.table import Table
from pathlib import Path
from typing import List
app = typer.Typer()
@ -9,28 +11,28 @@ app = typer.Typer()
@app.command("roll-table")
def create(
source: str = typer.Argument(
sources: List[Path] = typer.Argument(
...,
help="Path to the yaml-formatted source file."),
help="Path to one or more yaml-formatted source file."),
frequency: str = typer.Option(
'default',
help='use the specified frequency from the source file'),
die: int = typer.Option(
20,
help='The size of the die for which to create a table'),
collapse: bool = typer.Option(
collapsed: bool = typer.Option(
True,
help='If True, collapse multiple die values with the same option.')
):
"""
CLI for creating roll tables.
"""
with open(source, 'r') as src:
rt = tables.RollTable(source=src, frequency=frequency, die=die,
collapsed=collapse)
rt.load_source()
table = Table(*rt.rows[0])
for row in rt.rows[1:]:
rt = tables.RollTable([Path(s).read_text() for s in sources], frequency=frequency, die=die)
rows = rt.rows if collapsed else rt.expanded_rows
table = Table(*rows[0])
for row in rows[1:]:
table.add_row(*row)
print(table)

View File

@ -1,7 +1,62 @@
import yaml
import random
from collections.abc import Generator
from typing import Optional, Mapping, List, IO
from typing import Optional, List, IO
class DataSource:
"""
Represents a yaml data source used to generate roll tables.
Attributes:
source - the IO source to parse
frequency - the frequency distribution to apply
headers - an array of header strings
data - The parsed YAML data
Methods:
load_source - Read and parse the source, populating the attributes
"""
def __init__(self, source: IO, frequency: str = 'default') -> None:
"""
Initialize a DataSource instance.
Args:
source - an IO object to read source from
frequency - the name of the frequency distribution to use; must
be defined in the source file's metadata.
"""
self.source = source
self.frequency = frequency
self.headers = []
self.frequencies = None
self.data = None
self.load_source()
def load_source(self) -> None:
"""
Cache the yaml source and the parsed or generated metadata.
"""
if self.data:
return
self.data = yaml.safe_load(self.source)
metadata = self.data.pop('metadata', {})
num_keys = len(self.data.keys())
default_freq = num_keys / 100
if 'headers' in metadata:
self.headers = metadata['headers']
frequencies = {
'default': dict([(k, default_freq) for k in self.data.keys()])
}
if 'frequencies' in metadata:
frequencies.update(**metadata['frequencies'])
self.frequencies = frequencies[self.frequency]
class RollTable:
@ -10,53 +65,33 @@ class RollTable:
Instance Attributes:
data - The parsed source data, minus any metadata
die - the size of the die for which to create a table (default: 20)
frequencies - frequency distribution applied when selecting random values
headers - array of column headers (default: do not print headers)
(default: uniform across all options)
rows - An array of table rows derived from the values
values - An array of randomly-selected values for each die roll
sources - One or more yaml strings to parse as data sources
frequency - The frequency distribution to apply when populating the table
die - The size of the die for which to create a table (default: 20)
headers - An array of header strings
rows - An array of table headers and rows
expanded_rows - An array of table headers and rows, one per die roll value
Instance Methods:
Usage:
load_source - Read and parse the source. Will be called automatically when necessary.
table = RollTable(['source.yaml'], die=4)
print(table)
>>> Roll Item
d1 Foo
d2-d4 Bar
"""
def __init__(self, source: IO, frequency: str = 'default',
die: Optional[int] = 20, collapsed: bool = True):
"""
Initialize a RollTable instance.
Args:
source - an IO object to read source from
frequency - the name of the frequency distribution to use; must
be defined in the source file's metadata.
die - specify a die size
collapsed - If True, collapse multiple die values with the same
options into a single line.
"""
def __init__(self, sources: List[str], frequency: str = 'default',
die: Optional[int] = 20) -> None:
self._sources = sources
self._frequency = frequency
self._die = die
self._collapsed = collapsed
self._headers = None
self._frequencies = None
self._source = source
self._data = None
self._values = None
self._rows = None
@property
def frequencies(self):
if not self._data:
self.load_source()
return self._frequencies
@property
def data(self) -> Mapping:
if not self._data:
self.load_source()
return self._data
self._headers = None
self._header_excludes = None
self._generated_values = None
self._config()
@property
def die(self) -> int:
@ -64,163 +99,98 @@ class RollTable:
@property
def headers(self) -> List:
if not self._data:
self.load_source()
return self._headers
@property
def values(self) -> List:
if not self._values:
weights = []
options = []
for (option, weight) in self.frequencies.items():
weights.append(weight)
options.append(option)
freqs = random.choices(options, weights=weights, k=self.die)
self._values = []
for option in freqs:
self._values += [(option, random.choice(self.data[option]))]
if hasattr(self._values[0][1], 'keys'):
self._values = sorted(self._values, key=lambda val: list(val[1].keys())[0])
else:
self._values = sorted(self._values)
return self._values
def _values(self) -> List:
if not self._generated_values:
def values_from_datasource(ds):
weights = []
options = []
for (option, weight) in ds.frequencies.items():
weights.append(weight)
options.append(option)
freqs = random.choices(options, weights=weights, k=self.die)
values = []
for option in freqs:
choice = random.choice(ds.data[option])
if hasattr(choice, 'keys'):
c = [option]
for (k, v) in choice.items():
c.extend([k, v])
values.append(c)
else:
values.append([option, choice])
return sorted(values)
ds_values = [values_from_datasource(t) 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
@property
def rows(self) -> List:
if not self._rows:
rows = []
if self.headers:
rows.append(['Roll'] + self.headers)
if self._collapsed:
for line in self._collapsed_rows():
rows.append(line)
else:
for (i, item) in enumerate(self.values):
(cat, option) = item
if hasattr(option, 'items'):
(k, v) = list(option.items())[0]
rows.append([f'd{i+1}', cat, k, v])
else:
rows.append([f'd{i+1}', cat, option])
self._rows = rows
def formatted(lastrow, offset, row, i):
fmt = f'd{i}' if offset + 1 == i else f'd{offset+1}-d{i}'
return self._column_filter([fmt] + lastrow)
lastrow = None
offset = 0
self._rows = [self._column_filter(['Roll'] + self.headers)]
for face in range(self._die):
row = self._values[face]
if not lastrow:
lastrow = row
offset = face
continue
if row != lastrow:
self._rows.append(formatted(lastrow, offset, row, face))
lastrow = row
offset = face
self._rows.append(formatted(lastrow, offset, row, face+1))
return self._rows
def load_source(self) -> None:
@property
def expanded_rows(self) -> List:
self._rows = [self._column_filter(['Roll'] + self.headers)]
for face in range(self._die):
row = self._values[face]
self._rows.append(self._column_filter([f'd{face+1}'] + row))
return self._rows
def _config(self):
"""
Cache the yaml source and the parsed or generated metadata.
Parse data sources, generate headers, and create the column filters
"""
if self._data:
return
self._data = yaml.safe_load(self._source)
metadata = self._data.pop('metadata', {})
# create the datasource objects
self._data = []
for src in self._sources:
ds = DataSource(src, frequency=self._frequency)
ds.load_source()
self._data.append(ds)
num_keys = len(self._data.keys())
default_freq = num_keys / 100
# merge the headers
self._headers = []
for ds in self._data:
self._headers += ds.headers
if 'headers' in metadata:
self._headers = metadata['headers']
# identify which columsn to hide in the output by recording where a
# None header appears
self._header_excludes = []
for i in range(len(self._headers)):
if self.headers[i] is None:
self._header_excludes.append(i+1) # +1 to account for the 'Roll' column
frequencies = {
'default': dict([(k, default_freq) for k in self._data.keys()])
}
if 'frequencies' in metadata:
frequencies.update(**metadata['frequencies'])
self._frequencies = frequencies[self._frequency]
def _collapsed_rows(self) -> Generator[list]:
"""
Generate an array of column values for each row of the table but
sort the values and squash multiple rows with the same values into one,
with a range for the die roll instead of a single die. That is,
d1 foo bar baz
d2 foo bar baz
becomes
d1-d2 foo bar baz
"""
def collapsed(last_val, offset, val, i):
(cat, option) = last_val
if hasattr(option, 'items'):
(k, v) = list(*option.items())
else:
k = option
v = ''
if offset + 1 == i:
return [f'd{i}', cat, k, v]
else:
return [f'd{offset+1}-d{i}', cat, k, v]
last_val = None
offset = 0
for (i, val) in enumerate(self.values):
if not last_val:
last_val = val
offset = i
continue
if val != last_val:
yield collapsed(last_val, offset, val, i)
last_val = val
offset = i
yield collapsed(last_val, offset, val, i+1)
def _column_filter(self, row):
return [col for (pos, col) in enumerate(row) if pos not in self._header_excludes]
def __repr__(self) -> str:
"""
Return the rows as a single string.
"""
rows = list(self.rows)
str_format = '\t'.join(['{:10s}'] * len(rows[0]))
return "\n".join([str_format.format(*row) for row in rows])
class CombinedTable(RollTable):
"""
Create a table that is a union of other tables.
"""
def __init__(self, tables: List[str], die: Optional[int] = 20):
self._die = die
self._tables = tables
self._rows = None
self._headers = None
# reset any cached values
for t in self._tables:
t._rows = None
t._values = None
t._collapsed = False
t._die = self._die
@property
def tables(self) -> List:
return self._tables
@property
def rows(self) -> List:
"""
Compute the rows of the table by concatenating the rows of the individual tables.
"""
if not self._rows:
# if one table has headers, they must all have them, so fill with empty strings.
if sum([1 for t in self.tables if t.headers]) < len(self.tables):
for t in self.tables:
if not t.headers:
t._headers = ['.'] * len(t.values[0])
self._rows = []
for i in range(self._die):
row = [self.tables[0].rows[i][0]]
for x in range(len(self.tables)):
for col in self.tables[x].rows[i][1:]:
row.append(col)
self._rows.append(row)
return self._rows
if __name__ == '__main__':
import sys
print(RollTable(path=sys.argv[1], die=int(sys.argv[2])))

View File

@ -46,6 +46,10 @@ option 1:
"""
fixture_no_descriptions = """
metadata:
headers:
- option
- choice
option 1:
- choice 1
"""
@ -80,45 +84,41 @@ B3:
def test_combined_tables():
tA = tables.RollTable(fixture_combined_A)
tB = tables.RollTable(fixture_combined_B)
combined = tables.CombinedTable(tables=[tA, tB], die=6)
assert 'A1' in str(combined)
assert 'B1' in str(combined)
combined = tables.RollTable([fixture_combined_A, fixture_combined_B], die=6)
assert str(combined)
def test_table_end_to_end():
assert str(tables.RollTable(fixture_source))
assert str(tables.RollTable([fixture_source]))
def test_table_end_to_end_with_metadata():
assert str(tables.RollTable(fixture_metadata + fixture_source))
assert str(tables.RollTable([fixture_metadata + fixture_source]))
def test_table_frequency():
t = tables.RollTable(fixture_metadata + fixture_source, frequency='nondefault')
assert t.frequencies['Option 1'] == 0.0
assert t.frequencies['Option 2'] == 0.1
assert t.frequencies['Option 3'] == 0.9
t = tables.RollTable([fixture_metadata + fixture_source], frequency='nondefault')
assert t._data[0].frequencies['Option 1'] == 0.0
assert t._data[0].frequencies['Option 2'] == 0.1
assert t._data[0].frequencies['Option 3'] == 0.9
def test_one_option():
t = tables.RollTable(fixture_one_choice, die=1)
assert t.values == [('option 1', {'choice 1': 'description 1'})]
t = tables.RollTable([fixture_one_choice], die=1)
assert t._values == [['option 1', 'choice 1', 'description 1']]
def test_collapsed():
t = tables.RollTable(fixture_repeated_choices, die=6, collapsed=True)
assert len(list(t.rows)) == 1
t = tables.RollTable([fixture_repeated_choices], die=6)
assert len(list(t.rows)) == 2 # (+1 for headers)
def test_not_collapsed():
t = tables.RollTable(fixture_repeated_choices, die=6, collapsed=False)
assert len(list(t.rows)) == 6
t = tables.RollTable([fixture_repeated_choices], die=6)
assert len(list(t.expanded_rows)) == 7 # (+1 for headers)
def test_no_descriptions():
t = tables.RollTable(fixture_no_descriptions, die=1)
t = tables.RollTable([fixture_no_descriptions], die=1)
assert 'd1' in str(t)
assert 'option 1' in str(t)