diff --git a/README.md b/README.md index 6330d70..7b48ef2 100644 --- a/README.md +++ b/README.md @@ -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) +``` diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..e9bd931 --- /dev/null +++ b/example.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 10599b4..ec08722 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'dnd-rolltable' -version = '0.9' +version = '1.0' license = 'The Unlicense' authors = ['Greg Boyington '] description = 'Generate roll tables using weighted random distributions' diff --git a/rolltable/cli.py b/rolltable/cli.py index 87894ad..33ffe75 100644 --- a/rolltable/cli.py +++ b/rolltable/cli.py @@ -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) diff --git a/rolltable/tables.py b/rolltable/tables.py index b940bd6..d91fc17 100644 --- a/rolltable/tables.py +++ b/rolltable/tables.py @@ -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]))) diff --git a/tests/test_tables.py b/tests/test_tables.py index 372e750..9723582 100644 --- a/tests/test_tables.py +++ b/tests/test_tables.py @@ -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)