diff --git a/deadsands/content/images/tanos_edge_main_street.png b/deadsands/content/images/tanos_edge_main_street.png new file mode 100644 index 0000000..390f22d Binary files /dev/null and b/deadsands/content/images/tanos_edge_main_street.png differ diff --git a/deadsands/pyproject.toml b/deadsands/pyproject.toml index ba3bcc5..fa28d53 100644 --- a/deadsands/pyproject.toml +++ b/deadsands/pyproject.toml @@ -32,10 +32,13 @@ dnd-npcs = { file = "../../dnd-npcs/dist/dnd_npcs-0.2.0-py3-none-any.whl" } elethis-cipher= { git = "https://github.com/evilchili/elethis-cipher", branch = 'main' } #dnd-rolltable = { git = "https://github.com/evilchili/dnd-rolltable", branch = 'main' } dnd-rolltable = { file = "../../dnd-rolltable/dist/dnd_rolltable-1.1.9-py3-none-any.whl" } + +dnd-calendar = { path = "../../dnd-calendar" } + prompt-toolkit = "^3.0.38" [tool.poetry.scripts] -site = "site_tools.cli:app" +site = "site_tools.cli:site_app" roll-table = "rolltable.cli:app" pelican = "site_tools.tasks:pelican_main" dmsh = "site_tools.cli:dmsh" diff --git a/deadsands/site_tools/build_system.py b/deadsands/site_tools/build_system.py index de0151e..dea757c 100644 --- a/deadsands/site_tools/build_system.py +++ b/deadsands/site_tools/build_system.py @@ -37,6 +37,11 @@ CONFIG.update( "production_host": "deadsands.froghat.club", # where to find roll table sources "table_sources_path": "sources", + # where to store campaign state + "campaign_save_path": '~/.dnd', + "campaign_name": "deadsands", + # campaign start date + "campaign_start_date": "2.1125.5.25", } ) diff --git a/deadsands/site_tools/campaign.py b/deadsands/site_tools/campaign.py new file mode 100644 index 0000000..f191937 --- /dev/null +++ b/deadsands/site_tools/campaign.py @@ -0,0 +1,82 @@ +from collections import defaultdict +from pathlib import Path + +import shutil +import yaml + +from telisar.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 + + 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/deadsands/site_tools/shell.py b/deadsands/site_tools/dmsh.py similarity index 100% rename from deadsands/site_tools/shell.py rename to deadsands/site_tools/dmsh.py diff --git a/deadsands/site_tools/shell/interactive_shell.py b/deadsands/site_tools/shell/interactive_shell.py index cdb0cb1..fd8a622 100644 --- a/deadsands/site_tools/shell/interactive_shell.py +++ b/deadsands/site_tools/shell/interactive_shell.py @@ -7,8 +7,10 @@ from rich.table import Table from rolltable.tables import RollTable from site_tools.shell.base import BasePrompt, command +from site_tools import campaign from npc.generator.base import generate_npc +from reckoning.calendar import TelisaranCalendar BINDINGS = KeyBindings() @@ -23,12 +25,22 @@ class DMShell(BasePrompt): self._register_subshells() self._register_keybindings() + self.cache['campaign'] = campaign.load( + path=self.cache['campaign_save_path'], + 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] NPC"), + ("", " [F4] Calendar"), + ("", " [F8] Save"), ("", " [^Q] Quit "), ] ) @@ -51,6 +63,61 @@ class DMShell(BasePrompt): def npc(event): self.npc() + @self.key_bindings.add("f4") + def calendar(event): + self.calendar() + + @self.key_bindings.add("f8") + def save(event): + self.save() + + @command(usage=""" + [title]Calendar[/title] + + Print the Telisaran calendar, including the current date. + + [title]calendar[/title] + + [link]> calendar [season][/link] + """, completer=WordCompleter( + [ + 'season', + ] + )) + def calendar(self, parts=[]): + + if not self.cache['calendar']: + self.cache['calendar'] = TelisaranCalendar(today=self._campaign['start_date']) + + if not parts: + self.console.print(self.cache['calendar'].__doc__) + self.console.print(f"Today is {self._campaign['date'].short}") + return + + if parts[0] == 'season': + self.console.print(self.cache['calendar'].season) + return + + @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.cache['campaign_save_path'], + name=self.cache['campaign_name'] + ) + self.console.print(f"Saved {path}; {count} backups exist.") + @command(usage=""" [title]NPC[/title] @@ -104,6 +171,7 @@ class DMShell(BasePrompt): """ Quit dmsh. """ + self.save() try: get_app().exit() finally: