From c5dd24871d16805c820d94fe176114feb1a6ca9b Mon Sep 17 00:00:00 2001 From: evilchili Date: Tue, 16 Jan 2024 18:30:06 -0800 Subject: [PATCH] Initial import of dmsh --- dmsh/__init__.py | 0 dmsh/campaign.py | 83 +++++++ dmsh/cli.py | 32 +++ dmsh/console.py | 154 +++++++++++++ dmsh/hoppers.py | 99 ++++++++ dmsh/jobs.py | 187 +++++++++++++++ dmsh/shell/__init__.py | 1 + dmsh/shell/base.py | 133 +++++++++++ dmsh/shell/interactive_shell.py | 392 ++++++++++++++++++++++++++++++++ dmsh/striders.py | 85 +++++++ pyproject.toml | 54 +++++ 11 files changed, 1220 insertions(+) create mode 100644 dmsh/__init__.py create mode 100644 dmsh/campaign.py create mode 100644 dmsh/cli.py create mode 100644 dmsh/console.py create mode 100644 dmsh/hoppers.py create mode 100644 dmsh/jobs.py create mode 100644 dmsh/shell/__init__.py create mode 100644 dmsh/shell/base.py create mode 100644 dmsh/shell/interactive_shell.py create mode 100644 dmsh/striders.py create mode 100644 pyproject.toml diff --git a/dmsh/__init__.py b/dmsh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dmsh/campaign.py b/dmsh/campaign.py new file mode 100644 index 0000000..99b644d --- /dev/null +++ b/dmsh/campaign.py @@ -0,0 +1,83 @@ +from collections import defaultdict +from pathlib import Path + +import shutil +import yaml + +from reckoning import telisaran + + +def string_to_date(date): + return telisaran.datetime.from_expression(f"on {date}", timeline={}) + + +def date_to_string(date): + return date.numeric + + +def _rotate_backups(path, max_backups=10): + + oldest = None + if not path.exists(): + return oldest + + # move file.000 to file.001, file.001 to file.002, etc... + for i in range(max_backups - 2, -1, -1): + source = Path(f"{path}.{i:03d}") + target = Path(f"{path}.{i+1:03d}") + if not source.exists(): + continue + if oldest is None: + oldest = i + if i == max_backups: + source.unlink() + shutil.move(source, target) + + return oldest + + +def save(campaign, path='.', name='dnd_campaign'): + savedir = Path(path).expanduser() + savepath = savedir / f"{name}.yaml" + + savedir.mkdir(exist_ok=True) + backup_count = _rotate_backups(savepath) + + if savepath.exists(): + target = Path(f"{savepath}.000") + shutil.move(savepath, target) + + campaign['date'] = date_to_string(campaign['date']) + campaign['start_date'] = date_to_string(campaign['start_date']) + savepath.write_text(yaml.safe_dump(dict(campaign))) + return savepath, (backup_count or 0) + 2 + + +def load(path=".", name='dnd_campaign', start_date='', backup=None, console=None): + ext = "" if backup is None else f".{backup:03d}" + + default_date = string_to_date(start_date) + campaign = defaultdict(str) + campaign['start_date'] = default_date + campaign['date'] = default_date + campaign['level'] = 1 + + if console: + console.print(f"Loading campaign {name} from {path}...") + try: + target = Path(path).expanduser() / f"{name}.yaml{ext}" + with open(target, 'rb') as f: + loaded = yaml.safe_load(f) + loaded['start_date'] = string_to_date(loaded['start_date']) + loaded['date'] = string_to_date(loaded['date']) + campaign.update(loaded) + if console: + console.print(f"Successfully loaded Campaign {name} from {target}!") + return campaign + except FileNotFoundError: + console.print(f"No existing campaigns found in {path}.") + return campaign + except yaml.parser.ParserError as e: + if console: + console.print(f"{e}\nWill try an older backup.") + return load(path, 0 if backup is None else backup+1) diff --git a/dmsh/cli.py b/dmsh/cli.py new file mode 100644 index 0000000..53f63a3 --- /dev/null +++ b/dmsh/cli.py @@ -0,0 +1,32 @@ +import asyncio +import sys +import termios + +import typer + +from dmsh.shell.interactive_shell import DMShell + +CONFIG = { + # where to find campaign data + "data_path": '~/.dnd', + "campaign_name": "deadsands", + + # campaign start date + "campaign_start_date": "2.1125.5.25", +} + + +app = typer.Typer() + + +@app.callback(invoke_without_command=True) +def dmsh(): + old_attrs = termios.tcgetattr(sys.stdin) + try: + asyncio.run(DMShell(CONFIG).start()) + finally: + termios.tcsetattr(sys.stdin, termios.TCSANOW, old_attrs) + + +if __name__ == "__main__": + app.main() diff --git a/dmsh/console.py b/dmsh/console.py new file mode 100644 index 0000000..7e31004 --- /dev/null +++ b/dmsh/console.py @@ -0,0 +1,154 @@ +import os +from configparser import ConfigParser +from pathlib import Path +from textwrap import dedent +from typing import List, Union + +import rich.repr +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.output import ColorDepth +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.styles import Style +from rich.console import Console as _Console +from rich.markdown import Markdown +from rich.table import Column, Table +from rich.theme import Theme + +BASE_STYLE = { + "help": "cyan", + "bright": "white", + "repr.str": "dim", + "repr.brace": "dim", + "repr.url": "blue", + "table.header": "white", + "toolbar.fg": "#888888", + "toolbar.bg": "#111111", + "toolbar.bold": "#FFFFFF", + "error": "red", +} + +def console_theme(theme_name: Union[str, None] = None) -> dict: + """ + Return a console theme as a dictionary. + + Args: + theme_name (str): + """ + cfg = ConfigParser() + cfg.read_dict({"styles": BASE_STYLE}) + + if theme_name: + theme_path = theme_name if theme_name else os.environ.get("DEFAULT_THEME", "blue_train") + cfg.read(Theme(Path(theme_path) / Path("console.cfg"))) + return cfg["styles"] + +@rich.repr.auto +class Console(_Console): + """ + SYNOPSIS + + Subclasses a rich.console.Console to provide an instance with a + reconfigured themes, and convenience methods and attributes. + + USAGE + + Console([ARGS]) + + ARGS + + theme The name of a theme to load. Defaults to DEFAULT_THEME. + + EXAMPLES + + Console().print("Can I kick it?") + >>> Can I kick it? + + INSTANCE ATTRIBUTES + + theme The current theme + + """ + + def __init__(self, *args, **kwargs): + self._console_theme = console_theme(kwargs.get("theme", None)) + self._overflow = "ellipsis" + kwargs["theme"] = Theme(self._console_theme, inherit=False) + super().__init__(*args, **kwargs) + + self._session = PromptSession() + @property + def theme(self) -> Theme: + return self._console_theme + def prompt(self, lines: List, **kwargs) -> str: + """ + Print a list of lines, using the final line as a prompt. + + Example: + + Console().prompt(["Can I kick it?", "[Y/n] ") + >>> Can I kick it? + [Y/n]> + + """ + + prompt_style = Style.from_dict( + { + # 'bottom-toolbar': f"{self.theme['toolbar.fg']} bg:{self.theme['toolbar.bg']}", + # 'toolbar-bold': f"{self.theme['toolbar.bold']}" + } + ) + + for line in lines[:-1]: + super().print(line) + with self.capture() as capture: + super().print(f"[prompt bold]{lines[-1]}>[/] ", end="") + text = ANSI(capture.get()) + + # This is required to intercept key bindings and not mess up the + # prompt. Both the prompt and bottom_toolbar params must be functions + # for this to correctly regenerate the prompt after the interrupt. + with patch_stdout(raw=True): + return self._session.prompt(lambda: text, style=prompt_style, color_depth=ColorDepth.TRUE_COLOR, **kwargs) + def mdprint(self, txt: str, **kwargs) -> None: + """ + Like print(), but support markdown. Text will be dedented. + """ + self.print(Markdown(dedent(txt), justify="left"), **kwargs) + def print(self, txt: str, **kwargs) -> None: + """ + Print text to the console, possibly truncated with an ellipsis. + """ + super().print(txt, overflow=self._overflow, **kwargs) + def debug(self, txt: str, **kwargs) -> None: + """ + Print text to the console with the current theme's debug style applied, if debugging is enabled. + """ + if os.environ.get("DEBUG", None): + self.print(dedent(txt), style="debug") + def error(self, txt: str, **kwargs) -> None: + """ + Print text to the console with the current theme's error style applied. + """ + self.print(dedent(txt), style="error") + def table(self, *cols: List[Column], **params) -> None: + """ + Print a rich table to the console with theme elements and styles applied. + parameters and keyword arguments are passed to rich.table.Table. + """ + background_style = f"on {self.theme['background']}" + params.update( + header_style=background_style, + title_style=background_style, + border_style=background_style, + row_styles=[background_style], + caption_style=background_style, + style=background_style, + ) + params["min_width"] = 80 + width = os.environ.get("CONSOLE_WIDTH", "auto") + if width == "expand": + params["expand"] = True + elif width != "auto": + params["width"] = int(width) + return Table(*cols, **params) diff --git a/dmsh/hoppers.py b/dmsh/hoppers.py new file mode 100644 index 0000000..5499c2e --- /dev/null +++ b/dmsh/hoppers.py @@ -0,0 +1,99 @@ +import npc + +from language import defaults, types +from language.languages import common +from random_sets.sets import equal_weights + + +# some old-west sounding names. The majority of NPC names will use one of these +# as a given name, but will mix in the occasional fully random name from the +# Common language base class to keep it consistent with Telisar as a whole. +given_names = equal_weights([ + 'Alonzo', 'Amos', 'Arthur', 'Art', 'Austin', 'Bart', 'William', 'Bill', + 'Will', 'Boone', 'Buck', 'Butch', 'Calvin', 'Carson', 'Cassidy', 'Charlie', + 'Chester', 'Clay', 'Clayton', 'Cole', 'Coleman', 'Colt', 'Cooper', 'Coop', + 'Earle', 'Edgar', 'Ed', 'Elijah', 'Eli', 'Ernest', 'Eugene', 'Gene', + 'Flynn', 'Frank', 'Gary', 'George', 'Harry', 'Henry', 'Holt', 'Homer', + 'Howard', 'Ike', 'James', 'Jasper', 'Jesse', 'John', 'Julian', 'Kit', + 'Lawrence', 'Levi', 'Logan', 'Louis', 'Morgan', 'Porter', 'Reid', 'Reuben', + 'Rufus', 'Samuel', 'Sam', 'Thomas', 'Tom', 'Tommy', 'Virgil', 'Walter', + 'Walt', 'Wayne', 'Wesley', 'Wyatt', 'Zane', 'Zeke', 'Adelaide', 'Alice', + 'Anna', 'Annie', 'Beatrice', 'Catherine', 'Cecily', 'Clara', 'Cora', + 'Dorothea', 'Dorothy', 'Edith', 'Eleanor', 'Eliza', 'Elizabeth', 'Beth', + 'Lizzie', 'Ella', 'Emma', 'Florence', 'Gertrude', 'Gertie', 'Harriet', + 'Hazel', 'Ida', 'Josephine', 'Letitia', 'Louise', 'Lucinda', 'Lydia', + 'Mary', 'Matilda', 'Tilly', 'Maude', 'Mercy', 'Minnie', 'Olivia', + 'Rosemary', 'Sarah', 'Sophia', 'Temperance', 'Teresa', 'Tess', 'Theodora', + 'Teddy', 'Virginia', 'Ginny', 'Winifred', 'Winnie', +], blank=False) + +initials = defaults.vowels + defaults.consonants + + +class HopperName(types.NameGenerator): + """ + A variant on the Common name generator. In the Dewa Q'Asos region, + nicknames are much more common, and while folks may still have multiple + given names, they tend to use initials, or omit their middle names + entirely. + """ + def __init__(self): + super().__init__( + language=common.Language, + templates=types.NameSet( + + # names without nicknames + (types.NameTemplate("given,surname"), 0.5), + (types.NameTemplate("given,initial,surname"), 0.75), + (types.NameTemplate("given,initial,initial,surname"), 0.5), + (types.NameTemplate("initial,given,surname"), 0.5), + (types.NameTemplate("initial,initial,surname"), 0.5), + + # names with nickknames + (types.NameTemplate("nickname,given,surname"), 0.75), + (types.NameTemplate("nickname,given,initial,surname"), 0.5), + (types.NameTemplate("nickname,given,initial,initial,surname"), 0.25), + (types.NameTemplate("nickname,name,surname"), 0.5), + (types.NameTemplate("nickname,name,initial,surname"), 0.25), + ), + + # these match the default Common language name generator + syllables=types.SyllableSet( + (types.Syllable(template="vowel|consonant"), 1.0), + (types.Syllable(template="consonant,vowel"), 1.0), + ), + suffixes=common.names.suffixes, + + # add the nicknames, for which we use the list of NPC personality traits. + nicknames=defaults.personality + defaults.adjectives, + ) + + def get_initial(self): + initial = '' + while not initial: + initial = initials.random() + return initial.capitalize() + '.' + + def get_given(self): + return given_names.random().capitalize() + + +Name = HopperName() +NobleName = Name + + +class Human(npc.types.NPC): + """ + A varianat human NPC type that generates names suitable for the dead sands. + """ + language = common + + @property + def name(self): + if not self._name: + self._name = Name.name()[0] + return self._name['fullname'] + + +# entrypoint for class discovery +NPC = Human diff --git a/dmsh/jobs.py b/dmsh/jobs.py new file mode 100644 index 0000000..851149c --- /dev/null +++ b/dmsh/jobs.py @@ -0,0 +1,187 @@ +import random +import collections +from pathlib import Path + +from rolltable.types import RollTable +from npc import random_npc + +Crime = collections.namedtuple('Crime', ['name', 'min_bounty', 'max_bounty']) + + +def generate_location(frequency='default'): + source = Path("sources/locations.yaml") + rt = RollTable([source.read_text()], hide_rolls=True, frequency=frequency) + return random.choice(rt.rows[1:])[1] + + +def nearest(value, step=50): + if value < step: + return step + remainder = value % step + if remainder > int(step / 2): + return value - remainder + step + return value - remainder + + +class BaseJob: + """ + The base class for random odd jobs. + """ + def __init__( + self, + name=None, + details=None, + reward=None, + contact=None, + location=None + ): + self._name = name + self._details = details + self._reward = reward + self._contact = contact or random_npc() + self._location = location + + @property + def name(self): + return self._name + + @property + def details(self): + if not self._details: + self._details = ( + f"Speak to {self.contact} in {self.location}. " + ) + return self._details + + @property + def reward(self): + return self._reward + + @property + def contact(self): + return self._contact + + @property + def location(self): + return self._location + + def __repr__(self): + return f"{self.__class__.__name__}: {self.name}\n{self.details}" + + +class Bounty(BaseJob): + """ + A Bounty job. + """ + + crimes = [ + Crime(name='theft', min_bounty=50, max_bounty=500), + Crime(name='overdue faction fees', min_bounty=50, max_bounty=500), + Crime(name='unpaid bar tab', min_bounty=50, max_bounty=100), + Crime(name="unpaid debt", min_bounty=50, max_bounty=200), + Crime(name='cattle rustling', min_bounty=200, max_bounty=1000), + Crime(name='murder', min_bounty=500, max_bounty=2000), + Crime(name='kidnapping', min_bounty=500, max_bounty=2000) + ] + + def __init__(self, target=None, crime=None, dead=None, alive=None, **kwargs): + super().__init__(**kwargs) + + self._target = target + + self._crime = crime + + dead_or_alive = [] + if dead is None: + dead = random.choice([True, False]) + if alive is None: + alive = True + if dead: + dead_or_alive.append('Dead') + if alive: + dead_or_alive.append('Alive') + self._dead_or_alive = ' or '.join(dead_or_alive) + + if not self._reward: + reward = nearest(random.randint(self.crime.min_bounty, self.crime.max_bounty)) + self._reward = f"{reward} Gold Pieces" + + if not self._name: + self._name = ( + f"{self.reward} for the capture of {self.target.full_name.upper()}, " + f"wanted for the crime of {self.crime.name.upper()}. " + f"Wanted {self._dead_or_alive.upper()}" + ) + + @property + def crime(self): + if not self._crime: + self._crime = random.choice(Bounty.crimes) + return self._crime + + @property + def details(self): + if not self._details: + self._details = f"{self.target.description}\nWhereabouts {self.target.whereabouts}." + return self._details + + @property + def target(self): + if not self._target: + self._target = random_npc() + return self._target + + +class Determinant(BaseJob): + """ + Hiring the services of a Determinant to resolve a dispute. + """ + def __init__(self, **kwargs): + super().__init__(**kwargs) + + +class Escort(BaseJob): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._contact = random_npc() + self._location = generate_location('settlements') + self._destination = generate_location('default') + self._reward = f"{nearest(random.randint(5, 20), step=5)} GP/day" + self._name = ( + f"Accompany {self.contact} from {self.location} to " + f"{self._destination}. {self.reward}" + ) + + +class Foraging(BaseJob): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + source = Path("sources/flora.yaml") + rt = RollTable([source.read_text()], hide_rolls=True) + + # [ rarity, name, descr, val ] + self._ingredient = random.choice(rt.rows) + + self._amount = nearest(random.randint(0, 300), step=25) + value = self._amount * int(self._ingredient[3].split(' ')[0]) + bonus = nearest(random.randint(0, 200)) + self._reward = f"{value} GP + {bonus} GP Bonus" + + self._name = f"{self.reward} for {self._amount} {self._ingredient[1]}" + self._contact = "Andok" + self._location = "Andok's Apothecary, Tano's Edge" + + +classes = BaseJob.__subclasses__() +job_types = [c.__name__ for c in classes] + + +def generate_job(): + return random.choice(classes)() + + +if __name__ == '__main__': + for i in range(10): + print(Escort()) diff --git a/dmsh/shell/__init__.py b/dmsh/shell/__init__.py new file mode 100644 index 0000000..dc414bd --- /dev/null +++ b/dmsh/shell/__init__.py @@ -0,0 +1 @@ +from .base import BasePrompt diff --git a/dmsh/shell/base.py b/dmsh/shell/base.py new file mode 100644 index 0000000..61d25ab --- /dev/null +++ b/dmsh/shell/base.py @@ -0,0 +1,133 @@ +import functools +from collections import defaultdict, namedtuple +from textwrap import dedent + +from prompt_toolkit.completion import NestedCompleter + +from dmsh.console import Console + +COMMANDS = defaultdict(dict) + +Command = namedtuple("Commmand", "prompt,handler,usage,completer") + + +def register_command(handler, usage, completer=None): + prompt = handler.__qualname__.split(".", -1)[0] + cmd = handler.__name__ + if cmd not in COMMANDS[prompt]: + COMMANDS[prompt][cmd] = Command( + prompt=prompt, + handler=handler, + usage=usage, + completer=completer, + ) + + +def command(usage, completer=None, binding=None): + def decorator(func): + register_command(func, usage, completer) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + return decorator + + +class BasePrompt(NestedCompleter): + def __init__(self, cache={}): + super(BasePrompt, self).__init__(self._nested_completer_map()) + self._prompt = "" + self._console = None + self._theme = None + self._toolbar = None + self._key_bindings = None + self._subshells = {} + self._cache = cache + self._name = "Interactive Shell" + + def _register_subshells(self): + for subclass in BasePrompt.__subclasses__(): + if subclass.__name__ == self.__class__.__name__: + continue + self._subshells[subclass.__name__] = subclass(parent=self) + + def _nested_completer_map(self): + return dict((cmd_name, cmd.completer) for (cmd_name, cmd) in COMMANDS[self.__class__.__name__].items()) + + def _get_help(self, cmd=None): + try: + return dedent(COMMANDS[self.__class__.__name__][cmd].usage) + except KeyError: + return self.usage + + @property + def name(self): + return self._name + + @property + def cache(self): + return self._cache + + @property + def key_bindings(self): + return self._key_bindings + + @property + def usage(self): + text = dedent(f""" + [title]{self.name}[/title] + + Available commands are listed below. Try 'help COMMAND' for detailed help. + + [title]COMMANDS[/title] + + """) + for name, cmd in sorted(self.commands.items()): + text += f" [b]{name:10s}[/b] {cmd.handler.__doc__.strip()}\n" + return text + + @property + def commands(self): + return COMMANDS[self.__class__.__name__] + + @property + def console(self): + if not self._console: + self._console = Console(color_system="truecolor") + return self._console + + @property + def prompt(self): + return self._prompt + + @property + def autocomplete_values(self): + return list(self.commands.keys()) + + @property + def toolbar(self): + return self._toolbar + + def help(self, parts): + attr = None + if parts: + attr = parts[0] + self.console.print(self._get_help(attr)) + return True + + def process(self, cmd, *parts): + if cmd in self.commands: + return self.commands[cmd].handler(self, parts) + self.console.error(f"Command {cmd} not understood; try 'help' for help.") + + def start(self, cmd=None): + while True: + if not cmd: + cmd = self.console.prompt( + self.prompt, completer=self, bottom_toolbar=self.toolbar, key_bindings=self.key_bindings + ) + if cmd: + cmd, *parts = cmd.split() + self.process(cmd, *parts) + cmd = None diff --git a/dmsh/shell/interactive_shell.py b/dmsh/shell/interactive_shell.py new file mode 100644 index 0000000..c5ef564 --- /dev/null +++ b/dmsh/shell/interactive_shell.py @@ -0,0 +1,392 @@ +from pathlib import Path + +from prompt_toolkit.application import get_app +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.key_binding import KeyBindings +from rolltable.types import RollTable + +from dmsh.shell.base import BasePrompt, command +from dmsh import campaign +from dmsh import jobs +from dmsh import striders +from dmsh import hoppers + +from reckoning.calendar import TelisaranCalendar +from reckoning.telisaran import Day +from reckoning.telisaran import ReckoningError + +import npc + +BINDINGS = KeyBindings() + +ANCESTRY_PACK, ANCESTRIES = npc.load_ancestry_pack() +ANCESTRIES['strider'] = striders +ANCESTRIES['hopper'] = hoppers + + +class DMShell(BasePrompt): + def __init__(self, cache={}): + super().__init__(cache) + self._name = "DM Shell" + self._prompt = ["dm"] + self._toolbar = [("class:bold", " DMSH ")] + self._key_bindings = BINDINGS + self._register_subshells() + self._register_keybindings() + + self._data_path = Path(self.cache['data_path']).expanduser() / Path(self.cache['campaign_name']) + + self.cache['campaign'] = campaign.load( + path=self._data_path / Path("saves"), + name=self.cache['campaign_name'], + start_date=self.cache['campaign_start_date'], + console=self.console + ) + self._campaign = self.cache['campaign'] + + def _register_keybindings(self): + self._toolbar.extend( + [ + ("", " [?] Help "), + ("", " [F2] Wild Magic Table "), + ("", " [F3] Trinkets"), + ("", " [F4] NPC"), + ("", " [F5] Date"), + ("", " [F6] Job"), + ("", " [F8] Save"), + ("", " [^Q] Quit "), + ] + ) + + @self.key_bindings.add("c-q") + @self.key_bindings.add("c-d") + @self.key_bindings.add("") + def quit(event): + self.quit() + + @self.key_bindings.add("?") + def help(event): + self.help() + + @self.key_bindings.add("f2") + def wmt(event): + self.wmt() + + @self.key_bindings.add("f3") + def trinkets(event): + self.trinkets() + + @self.key_bindings.add("f4") + def npc(event): + self.npc() + + @self.key_bindings.add("f5") + def date(event): + self.date() + + @self.key_bindings.add("f6") + def job(event): + self.job() + + @self.key_bindings.add("f8") + def save(event): + self.save() + + def _handler_date_season(self, *args): + self.console.print(self.cache['calendar'].season) + + def _handler_date_year(self, *args): + self.console.print(self.cache['calendar'].calendar) + + def _handler_date_inc(self, days): + offset = int(days or 1) * Day.length_in_seconds + self._campaign['date'] = self._campaign['date'] + offset + return self.date() + + def _handler_date_dec(self, days): + offset = int(days or 1) * Day.length_in_seconds + self._campaign['date'] = self._campaign['date'] - offset + return self.date() + + def _handler_date_set(self, new_date): + try: + self._campaign['date'] = campaign.string_to_date(new_date) + except ReckoningError as e: + self.console.error(str(e)) + self.console.error("Invalid date. Use numeric formats; see 'help date' for more.") + self.cache['calendar'] = TelisaranCalendar(today=self._campaign['date']) + return self.date() + + def _rolltable(self, source, frequency='default', die=20): + source_file = self._data_path / Path("sources") / Path(source) + return RollTable( + [source_file.read_text()], + frequency=frequency, + die=die + ).as_table() + + @command(usage=""" + [title]DATE[/title] + + Work with the Telisaran calendar, including the current campaign date. + + [title]USAGE[/title] + + [link]> date [COMMAND[, ARGS]][/link] + + COMMAND Description + + season Print the spans of the current season, highlighting today + year Print the full year's calendar, highlighting today. + inc N Increment the current date by N days; defaults to 1. + dec N Decrement the current date by N days; defaults to 1. + set DATE Set the current date to DATE, in numeric format, such as + [link]2.1125.1.45[/link]. + """, completer=WordCompleter( + [ + 'season', + 'year', + 'inc', + 'dec', + 'set', + ] + )) + def date(self, parts=[]): + """ + Date and calendaring tools. + """ + + if not self.cache['calendar']: + self.cache['calendar'] = TelisaranCalendar(today=self._campaign['date']) + + if not parts: + self.console.print(f"Today is {self._campaign['date'].short} ({self._campaign['date'].numeric})") + return + + cmd = parts[0] + try: + val = parts[1] + except IndexError: + val = None + + handler = getattr(self, f"_handler_date_{cmd}", None) + if not handler: + self.console.error(f"Unsupported command: {cmd}. Try 'help date'.") + return + + return handler(val) + + @command(usage=""" + [title]Save[/title] + + Save the campaign state. + + [title]USAGE[/title] + + [link]> save[/link] + """) + def save(self, parts=[]): + """ + Save the campaign state. + """ + path, count = campaign.save( + self.cache['campaign'], + path=self._data_path / Path("saves"), + name=self.cache['campaign_name'] + ) + self.console.print(f"Saved {path}; {count} backups exist.") + + @command(usage=""" + [title]NPC[/title] + + Generate a randomized NPC commoner. + + [title]USAGE[/title] + + [link]> npc \\[ANCESTRY\\][/link] + + [title]CLI[/title] + + [link]npc --ancestry ANCESTRY[/link] + """, completer=WordCompleter(list(ANCESTRIES.keys()))) + def npc(self, parts=[]): + """ + Generate an NPC commoner + """ + char = npc.random_npc([ANCESTRIES[parts[0]]] if parts else []) + self.console.print(char.description + "\n") + if char.personality: + self.console.print(f"Personality: {char.personality}\n") + if char.flaw: + self.console.print(f"Flaw: {char.flaw}\n") + if char.goal: + self.console.print(f"Goal: {char.goal}\n") + + @command(usage=""" + [title]QUIT[/title] + + The [b]quit[/b] command exits dmsh. + + [title]USAGE[/title] + + [link]> quit|^D|[/link] + """) + def quit(self, *parts): + """ + Quit dmsh. + """ + self.save() + try: + get_app().exit() + finally: + raise SystemExit("") + + @command(usage=""" + [title]HELP FOR THE HELP LORD[/title] + + The [b]help[/b] command will print usage information for whatever you're currently + doing. You can also ask for help on any command currently available. + + [title]USAGE[/title] + + [link]> help [COMMAND][/link] + """) + def help(self, parts=[]): + """ + Display the help message. + """ + super().help(parts) + return True + + @command( + usage=""" + [title]LOCATION[/title] + + [b]loc[/b] sets the party's location to the specified region of the Sahwat Desert. + + [title]USAGE[/title] + + [link]loc LOCATION[/link] + """, + completer=WordCompleter( + [ + "The Blooming Wastes", + "Dust River Canyon", + "Gopher Gulch", + "Calamity Ridge" + ] + ), + ) + def loc(self, parts=[]): + """ + Move the party to a new region of the Sahwat Desert. + """ + if parts: + self.cache["location"] = " ".join(parts) + self.console.print(f"The party is in {self.cache['location']}.") + + @command(usage=""" + [title]OVERLAND TRAVEL[/title] + + [b]ot[/b] + + [title]USAGE[/title] + + [link]ot in[/link] + """) + def ot(self, parts=[]): + """ + Increment the date by one day and record + """ + raise NotImplementedError() + + @command(usage=""" + [title]WILD MAGIC TABLE[/title] + + [b]wmt[/b] Generates a d20 wild magic surge roll table. The table will be cached for the cache. + + [title]USAGE[/title] + + [link]> wmt[/link] + + [title]CLI[/title] + + [link]roll-table \\ + sources/sahwat_magic_table.yaml \\ + --frequency default --die 20[/link] + """) + def wmt(self, parts=[], source="sahwat_magic_table.yaml"): + """ + Generate a Wild Magic Table for resolving spell effects. + """ + if "wmt" not in self.cache: + self.cache['wmt'] = self._rolltable(source) + self.console.print(self.cache['wmt']) + + @command(usage=""" + [title]TRINKET TABLE[/title] + + [b]trinkets[/b] Generates a d20 random trinket table. + + [title]USAGE[/title] + + [link]> trinkets[/link] + + [title]CLI[/title] + + [link]roll-table \\ + sources/trinkets.yaml \\ + --frequency default --die 20[/link] + """) + def trinkets(self, parts=[], source="trinkets.yaml"): + """ + Generate a trinkets roll table. + """ + self.console.print(self._rolltable(source)) + + @command(usage=""" + [title]LEVEL[/title] + + Get or set the current campaign's level. Used for generating loot tables. + + [title]USAGE[/title] + + [link]> level [LEVEL][/link] + + """) + def level(self, parts=[]): + """ + Get or set the current level of the party. + """ + if parts: + newlevel = int(parts[0]) + if newlevel > 20 or newlevel < 1: + self.console.error(f"Invalid level: {newlevel}. Levels must be between 1 and 20.") + self._campaign['level'] = newlevel + self.console.print(f"Party is currently at level {self._campaign['level']}.") + + @command(usage=""" + [title]JOB[/title] + + Generate a random job. + + [title]USAGE[/title] + + [link]> job[/link] + + """) + def job(self, parts=[]): + """ + Generate a random jobs table. + """ + self.console.print(jobs.generate_job()) + + @command(usage=""" + [title]PLACE[/title] + """) + def place(self, parts=[]): + """ + Select random place names. + """ + freq = parts[0] if parts else 'nodesert' + self.console.print(self._rolltable("locations.yaml", frequency=freq, die=4)) diff --git a/dmsh/striders.py b/dmsh/striders.py new file mode 100644 index 0000000..c484a64 --- /dev/null +++ b/dmsh/striders.py @@ -0,0 +1,85 @@ +import random +import textwrap + +from functools import cached_property + +import npc + +from language import types +from language.languages import lizardfolk + + +subspecies = [ + ('black', 'acid'), + ('red', 'fire'), + ('blue', 'lightning'), + ('green', 'poison'), + ('white', 'frost') +] + +ages = types.WeightedSet( + ('wyrmling', 0.2), + ('young', 1.0), + ('adult', 1.0), + ('ancient', 0.5) +) + + +class SandStriderNameGenerator(types.NameGenerator): + def __init__(self): + super().__init__( + language=lizardfolk.Language, + templates=types.NameSet( + (types.NameTemplate("name,name"), 1.0), + ), + ) + + +class NPC(npc.types.NPC): + + language = lizardfolk + + has_tail = True + has_horns = True + + def __init__(self): + super().__init__(self) + self._personality = '' + self._flaw = '' + self._goal = '' + + @cached_property + def name(self) -> str: + return str(SandStriderNameGenerator()) + + @cached_property + def fullname(self) -> str: + return self.name + + @cached_property + def age(self) -> str: + return ages.random() + + @cached_property + def subspecies(self) -> tuple: + return random.choice(subspecies) + + @cached_property + def color(self) -> str: + return self.subspecies[0] + + @cached_property + def spit(self) -> str: + return self.subspecies[1] + + @property + def description(self) -> str: + return ( + f"{self.name} is {npc.types.a_or_an(self.age)} {self.age} {self.color} sand strider " + f"with {self.horns} horns, {npc.types.a_or_an(self.nose)} {self.nose} snout, " + f"{self.body} body, and {self.tail} tail. {self.name} spits {self.spit}." + ) + + @property + def character_sheet(self) -> str: + return '\n'.join(textwrap.wrap(self.description, width=120)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a406f4d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[tool.poetry] +name = 'dnd' +version = '1.0' +license = 'The Unlicense' +authors = ['Greg Boyington '] +description = 'Dungeon Master SHell.' +packages = [ + { include = "dmsh" } +] + +[tool.poetry.dependencies] +python = "^3.10" + +# local wotsits +dnd-npcs= { git = "https://github.com/evilchili/dnd-npcs", branch = 'main' } +dnd-rolltable = { git = "https://github.com/evilchili/dnd-rolltable", branch = 'main' } +dnd-calendar = { git = "https://github.com/evilchili/dnd-calendar", branch = 'main' } +elethis-cipher= { git = "https://github.com/evilchili/elethis-cipher", branch = 'main' } + +prompt-toolkit = "^3.0.38" +typer = "^0.9.0" +rich = "^13.7.0" +pyyaml = "^6.0.1" + +[tool.poetry.scripts] +dmsh = "dmsh.cli:app" + + +[tool.poetry.dev-dependencies] +black = "^23.3.0" +isort = "^5.12.0" +pyproject-autoflake = "^1.0.2" + +[build-system] +requires = ['poetry-core~=1.0'] +build-backend = 'poetry.core.masonry.api' + +[tool.black] +line-length = 120 +target-version = ['py310'] + +[tool.isort] +multi_line_output = 3 +line_length = 120 +include_trailing_comma = true + +[tool.autoflake] +check = false # return error code if changes are needed +in-place = true # make changes to files instead of printing diffs +recursive = true # drill down directories recursively +remove-all-unused-imports = true # remove all unused imports (not just those from the standard library) +ignore-init-module-imports = true # exclude __init__.py when removing unused imports +remove-duplicate-keys = true # remove all duplicate keys in objects +remove-unused-variables = true # remove unused variables