Initial import of dmsh
This commit is contained in:
parent
0d865eb851
commit
c5dd24871d
0
dmsh/__init__.py
Normal file
0
dmsh/__init__.py
Normal file
83
dmsh/campaign.py
Normal file
83
dmsh/campaign.py
Normal file
|
@ -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)
|
32
dmsh/cli.py
Normal file
32
dmsh/cli.py
Normal file
|
@ -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()
|
154
dmsh/console.py
Normal file
154
dmsh/console.py
Normal file
|
@ -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)
|
99
dmsh/hoppers.py
Normal file
99
dmsh/hoppers.py
Normal file
|
@ -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
|
187
dmsh/jobs.py
Normal file
187
dmsh/jobs.py
Normal file
|
@ -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())
|
1
dmsh/shell/__init__.py
Normal file
1
dmsh/shell/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .base import BasePrompt
|
133
dmsh/shell/base.py
Normal file
133
dmsh/shell/base.py
Normal file
|
@ -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
|
392
dmsh/shell/interactive_shell.py
Normal file
392
dmsh/shell/interactive_shell.py
Normal file
|
@ -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("<sigint>")
|
||||||
|
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|<ENTER>[/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))
|
85
dmsh/striders.py
Normal file
85
dmsh/striders.py
Normal file
|
@ -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))
|
54
pyproject.toml
Normal file
54
pyproject.toml
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = 'dnd'
|
||||||
|
version = '1.0'
|
||||||
|
license = 'The Unlicense'
|
||||||
|
authors = ['Greg Boyington <evilchili@gmail.com>']
|
||||||
|
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
|
Loading…
Reference in New Issue
Block a user