From 32c9832daa26ae27b3be22c7ac85f7f09dbc9a19 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sat, 30 Jul 2022 00:45:49 -0700 Subject: [PATCH] WIP of table generator --- deadsands/www/sources/weather.yaml | 72 +++++++++ deadsands/www/tables.py | 245 +++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 deadsands/www/sources/weather.yaml create mode 100644 deadsands/www/tables.py diff --git a/deadsands/www/sources/weather.yaml b/deadsands/www/sources/weather.yaml new file mode 100644 index 0000000..638f766 --- /dev/null +++ b/deadsands/www/sources/weather.yaml @@ -0,0 +1,72 @@ +metadata: + headers: + - Roll + - Frequency + - Description + - Effect + die: 20 + frequencies: + default: + Common: 0.5 + Uncommon: 0.3 + Rare: 0.15 + Weird: 0.05 + deadly: + Common: 0.05 + Uncommon: 0.15 + Rare: 0.3 + Weird: 0.5 +Common: + - Clear Skies: No effect +Uncommon: + - Scorching Temperatures: 2x water consumption + - Clinging Sand: Disadvantage on STR checks and saves + - Searing Light: Disadvantage on DEX checks and saves + - Ghostly Wailing: Disadvantage on CON checks for concentration checks + - Low Oxygen: Disadvantage on INT checks and saves + - Glowing Dust Storm: Disadvantge on WIS checks and saves + - Psychic Fog: Disadvantage on CHA checks and saves + - Sand Storm: + Heavily obscured; visibility 5ft, disadvantage on WIS + (Perception) checks and INT (Investigation) checks + - Burning Sands: + Ground travelers must succeed on a DC 15 CON save or take 1d6 + fire damage every half-day. + - Oppressive Quiet: Silence beyond 20ft; disadvantage on WIS checks involving sound + - Insect Swarms: 1d6 Piercing damage per half-day + - Air of Dread: Succeed on DC 15 WIS save or be frightened for half-day +Rare: + - Spontaneous Hail: No effect; counts as magical water if consumed + - Glowing Auras: Faerie Fire spell effect + - Heavy Gravity: Disadvantge on STR, DEX checks and saves + - Random Whirlwinds: + At the start of each round, whirlwinds 10ft in diameter and and + 100ft tall randomly form. Any creature within 10ft of a + whirlwind at the start of their turn must succeed on a DC 15 STR + (Athletics) save or be pulled 10ft towards it. A creature + occupying the same space as a whirlwind at the start of their + turn takes 1d8 bludgeoning damage. + - Tentacles: + At the start of each round, Black Tentacles is cast on a random area. Creatures starting their turn there must succeed on a DC 15 DEX saving throw or take 1d6 bludgeoning damage and be restrained. A creature who starts their turn already restrained takes 1d6 bludgeoning damage. + - Broken Time: + At the start of each round, the DM rolls a d20. Until the start + of the next round, anyone with initiative score greater than or + equal to the DM's roll is slowed and anyone lower is hasted. +Weird: + - Whispered Insanity: > + "Clear skies" (but you are cursed; the DM rolls for each traveler) + d1 - None of this is really happening. + d2 - We're invincible! + d3 - They're all turning against you. + d4 - You're being followed. + d5 - An object in your inventory is the key to your survival. + d6 - Your soul has fled your body. + d7 - One of your companions is an imposter. + d8 - You should take off your clothes and experience true freedom. + - Inverted Bubble Rain: No effect + - Hot Metal Storm: + Heat Metal spell effect (gearforged take 1pt exhaustion per half-day) + - Morphing Dust: DC 15 WIS saving throw or become a frog each half-day + - Ethereal Wind: Travelers are transported to the Border Ethereal. + - Arcane Mirages: Mirage Arcane spell effect; disadvantage on skill checks to Forage and Survey + - Magonic Field Resonance: All spell damage / effect rolls are maxed. diff --git a/deadsands/www/tables.py b/deadsands/www/tables.py new file mode 100644 index 0000000..c30e8d6 --- /dev/null +++ b/deadsands/www/tables.py @@ -0,0 +1,245 @@ +import yaml +import random +from collections.abc import Generator +from typing import Optional, Mapping, List + + +class RollTable: + """ + Generate a roll table using weighted distributions of random options. + + Usage: + + Given source.yaml containing options such as: + + option1: + - key1: description + - key2: description + ... + ... + + Generate a random table: + + >>> print(RollTable(path='source.yaml')) + d1 option6 key3 description + d2 option2 key2 description + d3 option3 key4 description + ... + + You can customize the frequency distribution, headers, and table size by + defining metadata in your source file. + + 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 + - Category + - 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 + ... + """ + + def __init__(self, path: str, frequency: str = 'default', + die: Optional[int] = None, collapsed: bool = True): + """ + Initialize a RollTable instance. + + Args: + path - the path to the source file + 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._path = path + self._frequency = frequency + self._die = die + self._collapsed = collapsed + self._metadata = None + self._source = None + self._values = None + + def _load_source(self) -> None: + """ + Cache the yaml source and parsed or generated the metadata. + """ + if self._source: + return + with open(self._path, 'r') as source: + self._source = yaml.safe_load(source) + + def _defaults(): + num_keys = len(self._source.keys()) + default_freq = num_keys/100 + return { + 'headers': [''] * num_keys, + 'die': self._die, + 'frequencies': { + 'default': [(k, default_freq) for k in self._source.keys()] + } + } + self._metadata = self._source.pop('metadata', _defaults()) + + def _collapsed_lines(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 + (k, v) = list(*option.items()) + 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) + + @property + def freqtable(self): + return self.metadata['frequencies'][self._frequency] + + @property + def source(self) -> Mapping: + """ + The parsed source data + """ + if not self._source: + self._load_source() + return self._source + + @property + def metadata(self) -> Mapping: + """ + The parsed or generated metadata + """ + if not self._metadata: + self._load_source() + return self._metadata + + @property + def values(self) -> List: + """ + Randomly pick values from the source data following the frequency + distrubtion of the options. + """ + if not self._values: + weights = [] + options = [] + for (option, weight) in self.freqtable.items(): + weights.append(weight) + options.append(option) + freqs = random.choices(options, weights=weights, + k=self._die or self.metadata['die']) + self._values = [] + for option in freqs: + self._values += [(option, random.choice(self.source[option]))] + return sorted(self._values, key=lambda val: list(val[1].values())[0]) + + @property + def lines(self) -> Generator[List]: + """ + Yield a list of table rows suitable for formatting as output. + """ + yield self.metadata['headers'] + + if self._collapsed: + for line in self._collapsed_lines(): + yield line + else: + for (i, item) in enumerate(self.values): + (cat, option) = item + (k, v) = list(option.items())[0] + yield [f'd{i+1}', cat, k, v] + + def __str__(self) -> str: + """ + Return the lines as a single string. + """ + return "\n".join([ + '{:10s}\t{:8s}\t{:20s}\t{:s}'.format(*line) for line in self.lines + ]) + + +if __name__ == '__main__': + import sys + print(RollTable(path=sys.argv[1], die=int(sys.argv[2])))