2022-07-30 09:33:03 -07:00
|
|
|
import yaml
|
|
|
|
import random
|
2023-04-12 10:37:31 -07:00
|
|
|
from csv2md.table import Table
|
2022-08-13 12:32:52 -07:00
|
|
|
from collections.abc import Iterable
|
2022-07-31 15:03:19 -07:00
|
|
|
from typing import Optional, List, IO
|
2022-07-30 09:33:03 -07:00
|
|
|
|
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
class DataSource:
|
2022-07-30 09:33:03 -07:00
|
|
|
"""
|
2022-07-31 15:03:19 -07:00
|
|
|
Represents a yaml data source used to generate roll tables.
|
2022-07-30 14:20:26 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
Attributes:
|
2022-07-30 14:20:26 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
source - the IO source to parse
|
|
|
|
frequency - the frequency distribution to apply
|
|
|
|
headers - an array of header strings
|
|
|
|
data - The parsed YAML data
|
2022-07-30 14:20:26 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
Methods:
|
2022-07-30 14:20:26 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
load_source - Read and parse the source, populating the attributes
|
2022-07-30 09:33:03 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
"""
|
|
|
|
def __init__(self, source: IO, frequency: str = 'default') -> None:
|
2022-07-30 09:33:03 -07:00
|
|
|
"""
|
2022-07-31 15:03:19 -07:00
|
|
|
Initialize a DataSource instance.
|
2022-07-30 09:33:03 -07:00
|
|
|
|
|
|
|
Args:
|
2022-07-30 14:20:26 -07:00
|
|
|
source - an IO object to read source from
|
2022-07-30 09:33:03 -07:00
|
|
|
frequency - the name of the frequency distribution to use; must
|
|
|
|
be defined in the source file's metadata.
|
|
|
|
"""
|
2022-07-31 15:03:19 -07:00
|
|
|
self.source = source
|
|
|
|
self.frequency = frequency
|
|
|
|
self.headers = []
|
|
|
|
self.frequencies = None
|
|
|
|
self.data = None
|
2023-04-12 11:26:17 -07:00
|
|
|
self.metadata = None
|
2022-07-31 15:03:19 -07:00
|
|
|
self.load_source()
|
2022-07-30 09:33:03 -07:00
|
|
|
|
2022-07-30 14:20:26 -07:00
|
|
|
def load_source(self) -> None:
|
2022-07-30 09:33:03 -07:00
|
|
|
"""
|
2022-07-30 14:20:26 -07:00
|
|
|
Cache the yaml source and the parsed or generated metadata.
|
2022-07-30 09:33:03 -07:00
|
|
|
"""
|
2022-07-31 15:03:19 -07:00
|
|
|
if self.data:
|
2022-07-30 09:33:03 -07:00
|
|
|
return
|
2022-07-30 14:20:26 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
self.data = yaml.safe_load(self.source)
|
2023-04-12 11:26:17 -07:00
|
|
|
self.metadata = self.data.pop('metadata', {})
|
2022-07-30 14:20:26 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
num_keys = len(self.data.keys())
|
2022-07-30 14:20:26 -07:00
|
|
|
default_freq = num_keys / 100
|
|
|
|
|
2023-04-12 11:26:17 -07:00
|
|
|
if 'headers' in self.metadata:
|
|
|
|
self.headers = self.metadata['headers']
|
2022-07-30 14:20:26 -07:00
|
|
|
|
|
|
|
frequencies = {
|
2022-07-31 15:03:19 -07:00
|
|
|
'default': dict([(k, default_freq) for k in self.data.keys()])
|
2022-07-30 14:20:26 -07:00
|
|
|
}
|
2023-04-12 11:26:17 -07:00
|
|
|
if 'frequencies' in self.metadata:
|
|
|
|
frequencies.update(**self.metadata['frequencies'])
|
2022-07-31 15:03:19 -07:00
|
|
|
self.frequencies = frequencies[self.frequency]
|
2022-07-30 09:33:03 -07:00
|
|
|
|
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
class RollTable:
|
|
|
|
"""
|
|
|
|
Generate a roll table using weighted distributions of random options.
|
2022-07-30 09:33:03 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
Instance Attributes:
|
2022-07-30 09:33:03 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
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
|
2022-07-30 09:33:03 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
Usage:
|
2022-07-30 09:33:03 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
table = RollTable(['source.yaml'], die=4)
|
|
|
|
print(table)
|
|
|
|
>>> Roll Item
|
|
|
|
d1 Foo
|
|
|
|
d2-d4 Bar
|
2022-07-30 20:44:16 -07:00
|
|
|
"""
|
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
def __init__(self, sources: List[str], frequency: str = 'default',
|
2023-04-12 10:37:31 -07:00
|
|
|
die: Optional[int] = 20, hide_rolls: bool = False) -> None:
|
2022-07-31 15:03:19 -07:00
|
|
|
self._sources = sources
|
|
|
|
self._frequency = frequency
|
2022-07-30 20:44:16 -07:00
|
|
|
self._die = die
|
2023-04-12 10:37:31 -07:00
|
|
|
self._hide_rolls = hide_rolls
|
2022-07-31 15:03:19 -07:00
|
|
|
self._data = None
|
2022-07-30 20:44:16 -07:00
|
|
|
self._rows = None
|
|
|
|
self._headers = None
|
2022-07-31 15:03:19 -07:00
|
|
|
self._header_excludes = None
|
|
|
|
self._generated_values = None
|
|
|
|
self._config()
|
2022-07-30 20:44:16 -07:00
|
|
|
|
2022-08-06 21:19:59 -07:00
|
|
|
def as_yaml(self, expanded=False) -> dict:
|
|
|
|
struct = {}
|
|
|
|
for row in self.rows[1:]:
|
|
|
|
struct[row[0]] = {}
|
2022-08-13 12:32:52 -07:00
|
|
|
# pad rows with empty cols as necessary
|
|
|
|
cols = row[1:] + [''] * (len(self.headers) - len(row[1:]))
|
|
|
|
for idx, col in enumerate(cols):
|
|
|
|
struct[row[0]][self.headers[idx] if idx < len(self.headers) else '_'] = col
|
2022-08-13 12:56:58 -07:00
|
|
|
return yaml.dump(struct, sort_keys=False)
|
2022-08-06 21:19:59 -07:00
|
|
|
|
2023-04-12 11:26:17 -07:00
|
|
|
@property
|
|
|
|
def datasources(self) -> List:
|
|
|
|
return self._data
|
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
@property
|
|
|
|
def die(self) -> int:
|
|
|
|
return self._die
|
2022-07-30 20:44:16 -07:00
|
|
|
|
|
|
|
@property
|
2022-07-31 15:03:19 -07:00
|
|
|
def headers(self) -> List:
|
|
|
|
return self._headers
|
|
|
|
|
|
|
|
@property
|
|
|
|
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:
|
2022-08-06 17:33:54 -07:00
|
|
|
if not ds.data[option]:
|
|
|
|
values.append([option])
|
|
|
|
continue
|
2023-12-08 14:28:07 -08:00
|
|
|
if hasattr(ds.data[option], 'keys'):
|
2023-12-08 14:52:40 -08:00
|
|
|
k, v = random.choice(list(ds.data[option].items()))
|
|
|
|
choice = [k] + v
|
2023-12-08 14:28:07 -08:00
|
|
|
else:
|
|
|
|
choice = random.choice(ds.data[option])
|
2022-07-31 15:03:19 -07:00
|
|
|
if hasattr(choice, 'keys'):
|
|
|
|
c = [option]
|
|
|
|
for (k, v) in choice.items():
|
2023-12-08 14:52:40 -08:00
|
|
|
if type(v) is list:
|
|
|
|
c.extend([k, *v])
|
|
|
|
else:
|
|
|
|
c.extend([k, v])
|
2022-07-31 15:03:19 -07:00
|
|
|
values.append(c)
|
|
|
|
else:
|
2023-12-08 14:52:40 -08:00
|
|
|
if type(choice) is list:
|
|
|
|
values.append([option, *choice])
|
|
|
|
else:
|
|
|
|
values.append([option, choice])
|
2022-07-31 15:03:19 -07:00
|
|
|
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
|
2022-07-30 20:44:16 -07:00
|
|
|
|
|
|
|
@property
|
|
|
|
def rows(self) -> List:
|
2022-07-31 15:03:19 -07:00
|
|
|
def formatted(lastrow, offset, row, i):
|
2022-08-13 12:32:52 -07:00
|
|
|
thisrow = [f'd{i}' if offset + 1 == i else f'd{offset+1}-d{i}']
|
|
|
|
thisrow += self._flatten(lastrow)
|
|
|
|
return self._column_filter(thisrow)
|
2022-07-31 15:03:19 -07:00
|
|
|
|
|
|
|
lastrow = None
|
|
|
|
offset = 0
|
|
|
|
self._rows = [self._column_filter(['Roll'] + self.headers)]
|
2023-04-12 10:37:31 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
2022-08-06 13:32:34 -07:00
|
|
|
@property
|
|
|
|
def as_markdown(self) -> str:
|
2023-04-12 10:37:31 -07:00
|
|
|
return Table(self.rows).markdown()
|
2022-08-06 13:32:34 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
def _config(self):
|
2022-07-30 20:44:16 -07:00
|
|
|
"""
|
2022-07-31 15:03:19 -07:00
|
|
|
Parse data sources, generate headers, and create the column filters
|
2022-07-30 20:44:16 -07:00
|
|
|
"""
|
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
# create the datasource objects
|
|
|
|
self._data = []
|
|
|
|
for src in self._sources:
|
|
|
|
ds = DataSource(src, frequency=self._frequency)
|
|
|
|
ds.load_source()
|
|
|
|
self._data.append(ds)
|
|
|
|
|
|
|
|
# merge the headers
|
|
|
|
self._headers = []
|
|
|
|
for ds in self._data:
|
|
|
|
self._headers += ds.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:
|
2023-04-12 10:37:31 -07:00
|
|
|
self._header_excludes.append(i)
|
2022-07-31 15:03:19 -07:00
|
|
|
|
|
|
|
def _column_filter(self, row):
|
2023-04-12 10:37:31 -07:00
|
|
|
cols = [col or '' for (pos, col) in enumerate(row) if pos not in self._header_excludes]
|
2022-08-13 12:32:52 -07:00
|
|
|
# pad the row with empty columns if there are more headers than columns
|
2023-04-12 10:37:31 -07:00
|
|
|
cols = cols + [''] * (1 + len(self.headers) - len(row))
|
|
|
|
# strip the leading column if we're hiding the dice rolls
|
|
|
|
return cols[1:] if self._hide_rolls else cols
|
2022-08-13 12:32:52 -07:00
|
|
|
|
|
|
|
def _flatten(self, obj: List) -> List:
|
|
|
|
for member in obj:
|
|
|
|
if isinstance(member, Iterable) and not isinstance(member, (str, bytes)):
|
|
|
|
yield from self._flatten(member)
|
|
|
|
else:
|
|
|
|
yield member
|
2022-07-30 20:44:16 -07:00
|
|
|
|
2022-07-31 15:03:19 -07:00
|
|
|
def __repr__(self) -> str:
|
|
|
|
rows = list(self.rows)
|
|
|
|
str_format = '\t'.join(['{:10s}'] * len(rows[0]))
|
2023-04-12 10:37:31 -07:00
|
|
|
return "\n".join([str_format.format(*[r or '' for r in row]) for row in rows])
|