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. # RollTables
Given source.yaml containing options such as:
option1: RollTables is a python library for generating tables suitable for selecting random options using dice rolls.
- key1: description
- key2: description
...
...
Generate a random table: ## Quick Start
>>> print(RollTable(path='source.yaml')) ```
d1 option6 key3 description # example.yaml
d2 option2 key2 description
d3 option3 key4 description
...
You can customize the frequency distribution, headers, and table size by # metadata is optional
defining metadata in your source file. 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: ### Library Use
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
...
```
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] [tool.poetry]
name = 'dnd-rolltable' name = 'dnd-rolltable'
version = '0.9' version = '1.0'
license = 'The Unlicense' license = 'The Unlicense'
authors = ['Greg Boyington <evilchili@gmail.com>'] authors = ['Greg Boyington <evilchili@gmail.com>']
description = 'Generate roll tables using weighted random distributions' description = 'Generate roll tables using weighted random distributions'

View File

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

View File

@ -1,7 +1,62 @@
import yaml import yaml
import random import random
from collections.abc import Generator from typing import Optional, List, IO
from typing import Optional, Mapping, 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: class RollTable:
@ -10,53 +65,33 @@ class RollTable:
Instance Attributes: Instance Attributes:
data - The parsed source data, minus any metadata sources - One or more yaml strings to parse as data sources
die - the size of the die for which to create a table (default: 20) frequency - The frequency distribution to apply when populating the table
frequencies - frequency distribution applied when selecting random values die - The size of the die for which to create a table (default: 20)
headers - array of column headers (default: do not print headers) headers - An array of header strings
(default: uniform across all options) rows - An array of table headers and rows
rows - An array of table rows derived from the values expanded_rows - An array of table headers and rows, one per die roll value
values - An array of randomly-selected values for each die roll
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', def __init__(self, sources: List[str], frequency: str = 'default',
die: Optional[int] = 20, collapsed: bool = True): die: Optional[int] = 20) -> None:
""" self._sources = sources
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.
"""
self._frequency = frequency self._frequency = frequency
self._die = die self._die = die
self._collapsed = collapsed
self._headers = None
self._frequencies = None
self._source = source
self._data = None self._data = None
self._values = None
self._rows = None self._rows = None
self._headers = None
@property self._header_excludes = None
def frequencies(self): self._generated_values = None
if not self._data: self._config()
self.load_source()
return self._frequencies
@property
def data(self) -> Mapping:
if not self._data:
self.load_source()
return self._data
@property @property
def die(self) -> int: def die(self) -> int:
@ -64,163 +99,98 @@ class RollTable:
@property @property
def headers(self) -> List: def headers(self) -> List:
if not self._data:
self.load_source()
return self._headers return self._headers
@property @property
def values(self) -> List: def _values(self) -> List:
if not self._values: if not self._generated_values:
weights = [] def values_from_datasource(ds):
options = [] weights = []
for (option, weight) in self.frequencies.items(): options = []
weights.append(weight) for (option, weight) in ds.frequencies.items():
options.append(option) weights.append(weight)
freqs = random.choices(options, weights=weights, k=self.die) options.append(option)
self._values = [] freqs = random.choices(options, weights=weights, k=self.die)
for option in freqs: values = []
self._values += [(option, random.choice(self.data[option]))] for option in freqs:
if hasattr(self._values[0][1], 'keys'): choice = random.choice(ds.data[option])
self._values = sorted(self._values, key=lambda val: list(val[1].keys())[0]) if hasattr(choice, 'keys'):
else: c = [option]
self._values = sorted(self._values) for (k, v) in choice.items():
return self._values 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 @property
def rows(self) -> List: def rows(self) -> List:
if not self._rows: def formatted(lastrow, offset, row, i):
rows = [] fmt = f'd{i}' if offset + 1 == i else f'd{offset+1}-d{i}'
if self.headers: return self._column_filter([fmt] + lastrow)
rows.append(['Roll'] + self.headers)
if self._collapsed: lastrow = None
for line in self._collapsed_rows(): offset = 0
rows.append(line) self._rows = [self._column_filter(['Roll'] + self.headers)]
else: for face in range(self._die):
for (i, item) in enumerate(self.values): row = self._values[face]
(cat, option) = item if not lastrow:
if hasattr(option, 'items'): lastrow = row
(k, v) = list(option.items())[0] offset = face
rows.append([f'd{i+1}', cat, k, v]) continue
else: if row != lastrow:
rows.append([f'd{i+1}', cat, option]) self._rows.append(formatted(lastrow, offset, row, face))
self._rows = rows lastrow = row
offset = face
self._rows.append(formatted(lastrow, offset, row, face+1))
return self._rows 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) # create the datasource objects
metadata = self._data.pop('metadata', {}) 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()) # merge the headers
default_freq = num_keys / 100 self._headers = []
for ds in self._data:
self._headers += ds.headers
if 'headers' in metadata: # identify which columsn to hide in the output by recording where a
self._headers = metadata['headers'] # 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 = { def _column_filter(self, row):
'default': dict([(k, default_freq) for k in self._data.keys()]) return [col for (pos, col) in enumerate(row) if pos not in self._header_excludes]
}
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 __repr__(self) -> str: def __repr__(self) -> str:
"""
Return the rows as a single string.
"""
rows = list(self.rows) rows = list(self.rows)
str_format = '\t'.join(['{:10s}'] * len(rows[0])) str_format = '\t'.join(['{:10s}'] * len(rows[0]))
return "\n".join([str_format.format(*row) for row in rows]) 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 = """ fixture_no_descriptions = """
metadata:
headers:
- option
- choice
option 1: option 1:
- choice 1 - choice 1
""" """
@ -80,45 +84,41 @@ B3:
def test_combined_tables(): def test_combined_tables():
tA = tables.RollTable(fixture_combined_A) combined = tables.RollTable([fixture_combined_A, fixture_combined_B], die=6)
tB = tables.RollTable(fixture_combined_B) assert str(combined)
combined = tables.CombinedTable(tables=[tA, tB], die=6)
assert 'A1' in str(combined)
assert 'B1' in str(combined)
def test_table_end_to_end(): 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(): 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(): def test_table_frequency():
t = tables.RollTable(fixture_metadata + fixture_source, frequency='nondefault') t = tables.RollTable([fixture_metadata + fixture_source], frequency='nondefault')
assert t.frequencies['Option 1'] == 0.0 assert t._data[0].frequencies['Option 1'] == 0.0
assert t.frequencies['Option 2'] == 0.1 assert t._data[0].frequencies['Option 2'] == 0.1
assert t.frequencies['Option 3'] == 0.9 assert t._data[0].frequencies['Option 3'] == 0.9
def test_one_option(): def test_one_option():
t = tables.RollTable(fixture_one_choice, die=1) t = tables.RollTable([fixture_one_choice], die=1)
assert t.values == [('option 1', {'choice 1': 'description 1'})] assert t._values == [['option 1', 'choice 1', 'description 1']]
def test_collapsed(): def test_collapsed():
t = tables.RollTable(fixture_repeated_choices, die=6, collapsed=True) t = tables.RollTable([fixture_repeated_choices], die=6)
assert len(list(t.rows)) == 1 assert len(list(t.rows)) == 2 # (+1 for headers)
def test_not_collapsed(): def test_not_collapsed():
t = tables.RollTable(fixture_repeated_choices, die=6, collapsed=False) t = tables.RollTable([fixture_repeated_choices], die=6)
assert len(list(t.rows)) == 6 assert len(list(t.expanded_rows)) == 7 # (+1 for headers)
def test_no_descriptions(): 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 'd1' in str(t)
assert 'option 1' in str(t) assert 'option 1' in str(t)