├── dndme ├── __init__.py ├── http_api │ ├── static │ │ ├── content │ │ └── campaigns │ ├── __init__.py │ └── templates │ │ └── player-view.html ├── writers.py ├── commands │ ├── quit.py │ ├── list_commands.py │ ├── refresh_player_view.py │ ├── message.py │ ├── roll_dice.py │ ├── help.py │ ├── alias_combatant.py │ ├── latitude.py │ ├── reveal_combatant.py │ ├── unalias_combatant.py │ ├── conceal_combatant.py │ ├── disposition_hostile.py │ ├── disposition_neutral.py │ ├── disposition_friendly.py │ ├── move_combatant.py │ ├── swap_combatants.py │ ├── defeat_monster.py │ ├── unset_condition.py │ ├── show_moon.py │ ├── switch_combat.py │ ├── next_turn.py │ ├── stash_combatant.py │ ├── show_sun.py │ ├── heal_combatant.py │ ├── remove_combatant.py │ ├── previous_turn.py │ ├── adjust_date.py │ ├── show_calendar.py │ ├── unstash_combatant.py │ ├── log.py │ ├── damage_combatant.py │ ├── reorder_initiative.py │ ├── add_sidekick.py │ ├── end_combat.py │ ├── save.py │ ├── start_combat.py │ ├── set_condition.py │ ├── adjust_clock.py │ ├── image.py │ ├── rest.py │ ├── __init__.py │ ├── cast_spell.py │ ├── split_combat.py │ ├── alter_combatant.py │ ├── join_combat.py │ ├── show.py │ ├── combatant_details.py │ └── load.py ├── new_campaign.py ├── new_content.py ├── check_data.py ├── dice.py ├── player_view.py ├── initiative.py ├── models.py ├── shell.py ├── gametime.py └── loaders.py ├── tests ├── __init__.py ├── test_dndme.py └── test_encounter_loader.py ├── content └── example │ ├── images │ ├── .placeholder │ └── monsters │ │ └── .placeholder │ ├── encounters │ ├── lmop3.0.1.toml │ ├── lmop3.1.1.toml │ ├── test_max_hp.toml │ ├── test_counts.toml │ └── lmop1.1.1.toml │ └── monsters │ ├── goblin.toml │ ├── skeleton.toml │ ├── evil_mage.toml │ └── young_green_dragon.toml ├── setup.cfg ├── MANIFEST.in ├── templates ├── encounter.toml ├── settings.toml ├── party.toml ├── calendar.toml └── monster.toml ├── .pre-commit-config.yaml ├── campaigns └── example │ ├── settings.toml │ ├── log.md │ └── party.toml ├── requirements.txt ├── tox.ini ├── pyproject.toml ├── LICENSE.md ├── .travis.yml ├── setup.py ├── calendars ├── gregorian.toml ├── forgotten_realms.toml └── forgotten_realms_post_1488.toml ├── .gitignore └── README.md /dndme/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /content/example/images/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /content/example/images/monsters/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dndme/http_api/static/content: -------------------------------------------------------------------------------- 1 | ../../../content -------------------------------------------------------------------------------- /dndme/http_api/static/campaigns: -------------------------------------------------------------------------------- 1 | ../../../campaigns -------------------------------------------------------------------------------- /tests/test_dndme.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_pytest_integration(): 5 | assert True 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft calendars 2 | graft campaigns 3 | graft content 4 | graft templates 5 | graft tests 6 | -------------------------------------------------------------------------------- /templates/encounter.toml: -------------------------------------------------------------------------------- 1 | name = "" 2 | location = "" 3 | 4 | notes = """ 5 | """ 6 | 7 | [groups.group1] 8 | monster = "" 9 | count = 1 10 | max_hp = [1] 11 | -------------------------------------------------------------------------------- /templates/settings.toml: -------------------------------------------------------------------------------- 1 | calendar_file = "calendars/forgotten_realms.toml" 2 | log_file = "campaigns/CAMPAIGN/log.md" 3 | party_file = "campaigns/CAMPAIGN/party.toml" 4 | encounters = "content/example/encounters" 5 | images = "content/example/images" 6 | -------------------------------------------------------------------------------- /content/example/encounters/lmop3.0.1.toml: -------------------------------------------------------------------------------- 1 | name = "LMoP 3.0.1: Random - Goblins (Day: 5/6, Night: 5)" 2 | location = "Wilderness" 3 | 4 | notes = """ 5 | Uh-oh, it's a goblin hunting party! 6 | """ 7 | 8 | [groups.goblins] 9 | monster = "goblin" 10 | count = "1d6+3" 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 21.10b0 6 | hooks: 7 | - id: black 8 | language_version: python3 9 | -------------------------------------------------------------------------------- /campaigns/example/settings.toml: -------------------------------------------------------------------------------- 1 | calendar_file = "calendars/forgotten_realms.toml" 2 | #log_file = "campaigns/example/log.md" 3 | party_file = "campaigns/example/party.toml" 4 | encounters = "content/example/encounters" 5 | #encounters = "content/pota/encounters" 6 | images = "content/pota/images" 7 | -------------------------------------------------------------------------------- /content/example/encounters/lmop3.1.1.toml: -------------------------------------------------------------------------------- 1 | name = "LMoP 3.1.1: Old Owl Well" 2 | location = "Old Owl Well" 3 | 4 | notes = """ 5 | A necromancer, oh no! 6 | """ 7 | 8 | [groups.evil_mage] 9 | monster = "evil_mage" 10 | name = "Mr_Necromancer" 11 | alias = "Mr. Necromancer" 12 | count = 1 13 | -------------------------------------------------------------------------------- /templates/party.toml: -------------------------------------------------------------------------------- 1 | [Character] 2 | name = "name" 3 | species = "species" 4 | cclass = "class" 5 | level = 1 6 | pronouns = "they/them" 7 | max_hp = 10 8 | cur_hp = 10 9 | ac = 10 10 | initiative_mod = 0 11 | image_url = "" 12 | 13 | [Character.senses] 14 | darkvision = 0 15 | perception = 10 16 | -------------------------------------------------------------------------------- /dndme/writers.py: -------------------------------------------------------------------------------- 1 | import pytoml as toml 2 | 3 | 4 | class PartyWriter: 5 | def __init__(self, filename): 6 | self.filename = filename 7 | 8 | def write(self, party): 9 | # print(toml.dumps(party)) 10 | with open(self.filename, "w") as fout: 11 | toml.dump(party, fout) 12 | -------------------------------------------------------------------------------- /dndme/commands/quit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from dndme.commands import Command 4 | 5 | 6 | class Quit(Command): 7 | 8 | keywords = ["quit", "exit"] 9 | help_text = """{keyword} 10 | {divider} 11 | Summary: Quit the shell 12 | 13 | Usage: {keyword} 14 | """ 15 | 16 | def do_command(self, *args): 17 | self.player_view.stop() 18 | 19 | print("Goodbye!") 20 | sys.exit(0) 21 | -------------------------------------------------------------------------------- /campaigns/example/log.md: -------------------------------------------------------------------------------- 1 | Session started 2018-11-18 10:07:19 2 | * Hello world 3 | * adventure 4 | * excitement 5 | * really wild things 6 | * 14 Eleasis 1489 20:00 47.0° 7 | 8 | Session started 2018-11-18 10:09:04 9 | * hey hey hey 10 | * and then they did some stuff 11 | * Session ended on 1 Hammer 1488 at 00:00 at 45° 12 | 13 | Session started 2018-11-18 10:09:40 14 | * Session ended on Midsummer 1488 at 13:37 at 50.0° 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file requirements.txt setup.py 6 | # 7 | attrs==19.3.0 8 | click==6.7 9 | flask==1.0.2 10 | itsdangerous==1.1.0 # via flask 11 | jinja2==2.11.3 # via flask 12 | markupsafe==1.1.0 # via jinja2 13 | prompt-toolkit==2.0.6 14 | pytoml==0.1.14 15 | six==1.11.0 16 | wcwidth==0.1.7 17 | werkzeug==0.15.3 # via flask 18 | -------------------------------------------------------------------------------- /dndme/commands/list_commands.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class ListCommands(Command): 5 | 6 | keywords = ["commands"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: List available commands 10 | 11 | Usage: {keyword} 12 | """ 13 | 14 | def do_command(self, *args): 15 | self.print("Available commands:\n") 16 | for keyword in list(sorted(self.game.commands.keys())): 17 | print("*", keyword) 18 | -------------------------------------------------------------------------------- /content/example/encounters/test_max_hp.toml: -------------------------------------------------------------------------------- 1 | name = "Test Max HP Overrides" 2 | location = "PyCon Sprints Hallway" 3 | 4 | notes = """ 5 | This encounter tests overriding max hp of monster groups 6 | """ 7 | 8 | [groups.goblins] 9 | monster = "goblin" 10 | count = "1d4+2" 11 | max_hp = "2d6+3" 12 | 13 | [groups.evil_mage] 14 | monster = "evil_mage" 15 | count = 2 16 | max_hp = [10, 11] 17 | 18 | [groups.dragon] 19 | monster = "young_green_dragon" 20 | count = 1 21 | max_hp = 150 22 | -------------------------------------------------------------------------------- /dndme/commands/refresh_player_view.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class RefreshPlayerView(Command): 5 | 6 | keywords = ["refresh"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Force a refresh of the data that drives the player view, 10 | in case it's stuck or otherwise out of date. 11 | 12 | Usage: {keyword} 13 | """ 14 | 15 | def do_command(self, *args): 16 | self.player_view.update() 17 | print("Okay; refreshed player view") 18 | -------------------------------------------------------------------------------- /content/example/encounters/test_counts.toml: -------------------------------------------------------------------------------- 1 | name = "Test Monster Counts" 2 | location = "PyCon Sprints Hallway" 3 | 4 | notes = """ 5 | This encounter tests monster group counts that depend on the counts of other 6 | monster groups. 7 | """ 8 | 9 | [groups.goblins] 10 | monster = "goblin" 11 | count = "1d4+2" 12 | 13 | [groups.evil_mage] 14 | monster = "evil_mage" 15 | count = "goblins" 16 | 17 | [groups.young_green_dragon] 18 | monster = "young_green_dragon" 19 | count = "goblins + evil_mage" 20 | 21 | [groups.skeletons] 22 | monster = "skeleton" 23 | count = "players + 2" 24 | -------------------------------------------------------------------------------- /content/example/encounters/lmop1.1.1.toml: -------------------------------------------------------------------------------- 1 | name = "LMoP 1.1.1: Goblin Ambush" 2 | location = "Triboar Trail" 3 | 4 | notes = """ 5 | Four goblins are hlding in the woods, two on each side of the road. They wait 6 | until someone approaches the bodies and then attack. 7 | 8 | When the time comes for the goblins to act, two of them rush forward and make 9 | melee attacks while two goblins stand 30 feet away from the party and make 10 | ranged attacks. 11 | 12 | When three goblins are defeated, the last goblin attempts to flee, heading for 13 | the goblin trail. 14 | """ 15 | 16 | [groups.goblins] 17 | monster = "goblin" 18 | count = 4 19 | max_hp = [6, 5, 7, 4] 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py36,py37 8 | skip_missing_interpreters=True 9 | 10 | [testenv] 11 | install_command = 12 | pip install .[test] {opts} {packages} 13 | commands = 14 | python setup.py test 15 | pytest --cov=dndme --cov-report=term 16 | 17 | [testenv:pylint] 18 | install_command = 19 | pip install .[test] {opts} {packages} 20 | 21 | commands = 22 | pylint -E dndme tests 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dndme" 3 | version = "0.0.6" 4 | description = "Tools for helping the DM run Dungeons & Dragons sessions" 5 | authors = ["Mike Pirnat "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | attrs = "^21.2.0" 11 | click = "^8.0.3" 12 | Flask = "^2.0.2" 13 | prompt-toolkit = "^3.0.22" 14 | pytoml = "^0.1.21" 15 | 16 | [tool.poetry.dev-dependencies] 17 | black = "^21.10b0" 18 | tox = "^3.24.4" 19 | pre-commit = "^2.15.0" 20 | pytest = "^6.2.5" 21 | 22 | [tool.poetry.scripts] 23 | dndme = "dndme.shell:main_loop" 24 | dndme-new-campaign = "dndme.new_campaign:main" 25 | dndme-new-content = "dndme.new_content:main" 26 | 27 | [build-system] 28 | requires = ["poetry-core>=1.0.0"] 29 | build-backend = "poetry.core.masonry.api" 30 | -------------------------------------------------------------------------------- /templates/calendar.toml: -------------------------------------------------------------------------------- 1 | name = "Calendar" 2 | hours_in_day = 24 3 | minutes_in_hour = 60 4 | leap_year_rule = "year % 4 == 0" 5 | axial_tilt = 20 6 | solar_days_in_year = 60.25 7 | 8 | default_day = 1 9 | default_month = "Month1" 10 | default_year = 1000 11 | 12 | [months.month1] 13 | name = "Month1" 14 | days = 60 15 | leap_year_days = 61 16 | 17 | [seasons.spring_equinox] 18 | name = "Spring Equinox" 19 | month = "Month1" 20 | day = 15 21 | 22 | [seasons.summer_solstice] 23 | name = "Summer Solstice" 24 | month = "Month1" 25 | day = 30 26 | 27 | [seasons.autumn_equinox] 28 | name = "Autumn Equinox" 29 | month = "Month1" 30 | day = 45 31 | 32 | [seasons.winter_solstice] 33 | name = "Winter Solstice" 34 | month = "Month1" 35 | day = 60 36 | 37 | [moons.moon1] 38 | name = "Moon1" 39 | period = "30" 40 | full_on = "1 Month1 1000" -------------------------------------------------------------------------------- /tests/test_encounter_loader.py: -------------------------------------------------------------------------------- 1 | from attr import attrib 2 | import pytest 3 | 4 | from dndme.loaders import EncounterLoader 5 | from dndme.models import Combat 6 | 7 | 8 | @pytest.fixture 9 | def encounter_loader(): 10 | """Create a testing encounter loader""" 11 | return EncounterLoader( 12 | base_dir="content/example/encounters", monster_loader=None, combat=Combat() 13 | ) 14 | 15 | 16 | def test_get_available_encounters(encounter_loader): 17 | available_encounters = encounter_loader.get_available_encounters() 18 | 19 | print(available_encounters) 20 | assert len(available_encounters) == 5 21 | assert available_encounters[0].name == "LMoP 1.1.1: Goblin Ambush" 22 | assert "goblins" in available_encounters[0].groups 23 | assert available_encounters[0].groups["goblins"]["count"] == 4 24 | -------------------------------------------------------------------------------- /dndme/commands/message.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class Message(Command): 5 | 6 | keywords = ["message"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Send a message to be displayed in the player view or, with no message 10 | specified, clear the message in the player view. 11 | 12 | Usage: 13 | 14 | {keyword} [] 15 | {keyword} 16 | 17 | Examples: 18 | 19 | {keyword} Hello world! 20 | {keyword} 21 | """ 22 | 23 | def do_command(self, *args): 24 | orig_message = self.game.player_message 25 | if args: 26 | self.game.player_message = " ".join(args) 27 | print("Okay, message sent.") 28 | else: 29 | self.game.player_message = "" 30 | print("Okay, message cleared.") 31 | if self.game.player_message != orig_message: 32 | self.game.changed = True 33 | -------------------------------------------------------------------------------- /dndme/commands/roll_dice.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.dice import roll_dice, roll_dice_expr 3 | 4 | 5 | class RollDice(Command): 6 | 7 | keywords = ["roll", "dice"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Roll dice using a dice expression. Use multiple dice expressions to 11 | get multiple, separate results. 12 | 13 | Usage: {keyword} [ ...] 14 | 15 | Examples: 16 | 17 | {keyword} 3d6 18 | {keyword} 1d20+2 19 | {keyword} 2d4-1 20 | {keyword} 1d20 1d20 21 | """ 22 | 23 | def do_command(self, *args): 24 | results = [] 25 | for dice_expr in args: 26 | try: 27 | results.append(str(roll_dice_expr(dice_expr))) 28 | except ValueError: 29 | print(f"Invalid dice expression: {dice_expr}") 30 | return 31 | print(", ".join(results)) 32 | -------------------------------------------------------------------------------- /dndme/commands/help.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands.list_commands import ListCommands 3 | 4 | 5 | class Help(Command): 6 | 7 | keywords = ["help"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Get help for a command. 11 | 12 | Usage: {keyword} 13 | """ 14 | 15 | def get_suggestions(self, words): 16 | return list(sorted(self.game.commands.keys())) 17 | 18 | def do_command(self, *args): 19 | if not args: 20 | self.show_help_text("help") 21 | return 22 | 23 | keyword = args[0] 24 | command = self.game.commands.get(keyword) 25 | if not command: 26 | print(f"Unknown command: {keyword}") 27 | return 28 | command.show_help_text(keyword) 29 | 30 | def show_help_text(self, keyword): 31 | super().show_help_text(keyword) 32 | ListCommands.do_command(self, *[]) 33 | -------------------------------------------------------------------------------- /templates/monster.toml: -------------------------------------------------------------------------------- 1 | name = "" 2 | size = "" 3 | mtype = "" 4 | species = "" 5 | alignment = "" 6 | ac = 10 7 | armor = "" 8 | max_hp = "1d8" 9 | avg_hp = 5 10 | speed = 30 11 | str = 10 12 | dex = 10 13 | con = 10 14 | int = 10 15 | wis = 10 16 | cha = 10 17 | vulnerable = "" 18 | resist = "" 19 | immune = "" 20 | languages = "" 21 | cr = 1 22 | xp = 1 23 | pb = 0 24 | image_url = "" 25 | notes = """ 26 | """ 27 | 28 | [skills] 29 | skill = 1 30 | 31 | [senses] 32 | perception = 10 33 | 34 | [traits.feature] 35 | name = "" 36 | description = """ 37 | """ 38 | 39 | [traits.spellcasting] 40 | name = "Spellcasting" 41 | description = """ 42 | """ 43 | cantrips = [] 44 | spells = [ 45 | [], 46 | [] 47 | ] 48 | slots = [0, 0] 49 | slots_used = [0, 0] 50 | 51 | [actions.action] 52 | name = "" 53 | description = """ 54 | """ 55 | 56 | [legendary_actions.action] 57 | name = "" 58 | description = """ 59 | """ 60 | 61 | [reactions.reaction] 62 | name = "" 63 | description = """ 64 | """ 65 | -------------------------------------------------------------------------------- /dndme/http_api/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | from flask import Flask 6 | from flask import render_template 7 | 8 | app = Flask(__name__) 9 | 10 | base_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), "../..")) 11 | 12 | # Don't clog the dndme shell with logging 13 | if "FLASK_SUPPRESS_LOGGING" in os.environ: 14 | log = logging.getLogger("werkzeug") 15 | log.disabled = True 16 | app.logger.disabled = True 17 | 18 | 19 | @app.route("/api/player-view") 20 | def player_view_api(): 21 | try: 22 | with open(base_dir + "/player_view.json", "r") as f: 23 | return f.read() 24 | except IOError: 25 | content = json.dumps({"error": "Unable to read player_view.json"}) 26 | return (content, 404, {}) 27 | except Exception as e: 28 | content = json.dumps({"error": str(e)}) 29 | return (content, 500, {}) 30 | 31 | 32 | @app.route("/player-view") 33 | def player_view(): 34 | return render_template("player-view.html") 35 | -------------------------------------------------------------------------------- /dndme/commands/alias_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class AliasCombatant(Command): 5 | 6 | keywords = ["alias"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Override the combatant name that shows on the player view. 10 | 11 | Usage: {keyword} 12 | 13 | Example: 14 | 15 | {keyword} Frodo Underhill 16 | """ 17 | 18 | def get_suggestions(self, words): 19 | combat = self.game.combat 20 | if len(words) == 2: 21 | return combat.combatant_names 22 | 23 | def do_command(self, *args): 24 | if len(args) < 2: 25 | print("Need a combatant and alias.") 26 | return 27 | 28 | target_name = args[0] 29 | alias = " ".join(args[1:]) 30 | 31 | combat = self.game.combat 32 | 33 | target = combat.get_target(target_name) 34 | if not target: 35 | print(f"Invalid target: {target_name}") 36 | return 37 | 38 | target.alias = alias 39 | print(f"Okay; set alias '{alias}' on {target_name}.") 40 | self.game.changed = True 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mike Pirnat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /dndme/commands/latitude.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class Latitude(Command): 5 | 6 | keywords = ["latitude", "lat"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: check or set the current latitude that will be used for calculating 10 | the times for dawn, sunrise, sunset, and dusk. 11 | 12 | Northern latitudes are specified in positive numbers, with southern latitudes 13 | specified in negative numbers. 14 | 15 | Usage: {keyword} [] 16 | 17 | Examples: 18 | 19 | {keyword} 20 | 21 | {keyword} 51 22 | 23 | {keyword} -45 24 | """ 25 | 26 | def do_command(self, *args): 27 | if not args: 28 | print(f"The current latitude is {self.game.latitude}") 29 | return 30 | 31 | try: 32 | new_latitude = float(args[0]) 33 | if new_latitude < -90 or new_latitude > 90: 34 | raise ValueError() 35 | except ValueError: 36 | print(f"Invalid latitude: {args[0]}") 37 | return 38 | 39 | self.game.latitude = new_latitude 40 | print(f"Okay; the latitude is now {new_latitude}") 41 | -------------------------------------------------------------------------------- /dndme/commands/reveal_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class RevealCombatant(Command): 5 | 6 | keywords = ["reveal"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Mark one or more combatants as visible in the player view. 10 | 11 | Usage: {keyword} [ ...] 12 | 13 | Example: 14 | 15 | {keyword} goblin 16 | {keyword} goblin1 goblin2 goblin3 17 | {keyword} goblin* 18 | """ 19 | 20 | def get_suggestions(self, words): 21 | combat = self.game.combat 22 | names_already_chosen = words[1:] 23 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 24 | 25 | def do_command(self, *args): 26 | if not args: 27 | print("Need a combatant.") 28 | return 29 | 30 | combat = self.game.combat 31 | targets = combat.get_targets(args) 32 | if not targets: 33 | print(f"No targets found from `{args}`") 34 | 35 | for target in targets: 36 | target.visible_in_player_view = True 37 | print(f"Okay; {target.name} is visible in the player view.") 38 | self.game.changed = True 39 | -------------------------------------------------------------------------------- /dndme/commands/unalias_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class UnaliasCombatant(Command): 5 | 6 | keywords = ["unalias"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Remove the alias set on one or more combatants 10 | 11 | Usage: {keyword} 12 | 13 | Example: 14 | 15 | {keyword} Frodo 16 | {keyword} bandit1 bandit2 bandit3 17 | """ 18 | 19 | def get_suggestions(self, words): 20 | combat = self.game.combat 21 | names_already_chosen = words[1:] 22 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 23 | 24 | def do_command(self, *args): 25 | if len(args) < 1: 26 | print("Need a combatant and alias.") 27 | return 28 | 29 | target_names = args 30 | 31 | combat = self.game.combat 32 | 33 | for target_name in target_names: 34 | target = combat.get_target(target_name) 35 | if not target: 36 | print(f"Invalid target: {target_name}") 37 | 38 | target.alias = "" 39 | print(f"Okay; removed alias on {target_name}.") 40 | self.game.changed = True 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: required 3 | language: python 4 | python: 5 | - '3.6' 6 | - '3.7' 7 | install: pip install tox-travis 8 | script: tox 9 | notifications: 10 | slack: 11 | rooms: 12 | secure: gWW6WrhKw+vRrqx/StxxLhw+LFkW77l/QC5Zr1GZR/rYtdu1o3mRRSufU60te2hNyh6lsCNJBVfuyy1HWR0viDQSa54GNdEgs39mEZRnUDjVmyrYDR0yP4YbyOH89eg94vwAXCb2xb4DyKzdSoJsMwS1htneJWdlckwio0AxBFNCpRqokcKScatOOeO9wt38bI5DAePEBrKUKJAG4fagyvOAWfNpoeSu6XYEj7zo+la3LhEYuemPGN7QsA78/CaVUsimxnr8bqFKntViBELTpjvWQwZ3vKpMboh9yAq7GlWJ2KG9+xoTZX6Rh/dxwtWLgK1bAKZ4414ofSiPtP7VGIaZNLa8nrXP6FLxtKegZrMmlhydK/dkAaskVf7j83Srrp1A31Dpc99b353HdpJFIwRXBgGq+pcenAwqFyDjqdF1lKi+lmnxUtseoPBQS0+cn+EtgaaPhLtyyX1vBNgmRGrqKV2PILAW0jOhcBO2AAE08o5jdrb5AXD6pOjeFsqQZ4ovwMkBfd6YOpUUC9m10cDWTTkSY++dNAwzOIlLhrkif0mM3iPMEiIUYt9LuLdhBL+Ro9HWYLUgqh2pVOtnhF1w1949nNzHJQXTDNuu5fbJVgxuICPpbwEErpPa9DQQQdWG9DXoNu/Akb2OsX3PTB5BlFS12/R9jSOnG7s44zw= 13 | on_success: always 14 | on_failure: always 15 | template: 16 | - Repo `%{repository_slug}` *%{result}* build (<%{build_url}|#%{build_number}>) 17 | for commit (<%{compare_url}|%{commit}>) on branch `%{branch}`. 18 | - 'Execution time: *%{duration}*' 19 | - 'Message: %{message}' 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | import setuptools 5 | 6 | 7 | setup( 8 | name="dndme", 9 | version="0.0.6", 10 | description="Tools for helping the DM run Dungeons & Dragons sessions.", 11 | author="Mike Pirnat", 12 | packages=setuptools.find_packages(), 13 | install_requires=[ 14 | "attrs", 15 | "click", 16 | "prompt-toolkit", 17 | "pytoml", 18 | "six", 19 | "wcwidth", 20 | "flask", 21 | ], 22 | extras_require={ 23 | "test": [ 24 | "coverage", 25 | "pytest", 26 | "pytest-cov", 27 | "pytest-runner", 28 | "tox", 29 | "pylint", 30 | ], 31 | "dev": [ 32 | "pip-tools", 33 | "pre-commit", 34 | ], 35 | }, 36 | tests_require=["pytest"], 37 | setup_requires=["pytest-runner"], 38 | entry_points={ 39 | "console_scripts": [ 40 | "dndme = dndme.shell:main_loop", 41 | "dndme-new-campaign = dndme.new_campaign:main", 42 | "dndme-new-content = dndme.new_content:main", 43 | ], 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /dndme/commands/conceal_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class ConcealCombatant(Command): 5 | 6 | keywords = ["conceal"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Conceal one or more combatants from the player view. 10 | 11 | Usage: {keyword} [...] 12 | 13 | Example: 14 | 15 | {keyword} goblin 16 | {keyword} goblin1 goblin2 goblin3 17 | {keyword} goblin* 18 | """ 19 | 20 | def get_suggestions(self, words): 21 | combat = self.game.combat 22 | names_already_chosen = words[1:] 23 | 24 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 25 | 26 | def do_command(self, *args): 27 | if not args: 28 | print("Need a combatant.") 29 | return 30 | 31 | combat = self.game.combat 32 | targets = combat.get_targets(args) 33 | if not targets: 34 | print(f"No targets found from `{args}`") 35 | return 36 | 37 | for target in targets: 38 | target.visible_in_player_view = False 39 | print(f"Okay; {target.name} is hidden from the player view.") 40 | self.game.changed = True 41 | -------------------------------------------------------------------------------- /dndme/commands/disposition_hostile.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class DispositionHostile(Command): 5 | 6 | keywords = ["hostile"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Mark one or more combatants as hostile in the player view. 10 | 11 | Usage: {keyword} [ ...] 12 | 13 | Example: 14 | 15 | {keyword} goblin 16 | {keyword} goblin1 goblin2 goblin3 17 | {keyword} goblin* 18 | """ 19 | 20 | def get_suggestions(self, words): 21 | combat = self.game.combat 22 | names_already_chosen = words[1:] 23 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 24 | 25 | def do_command(self, *args): 26 | if not args: 27 | print("Need a combatant.") 28 | return 29 | 30 | combat = self.game.combat 31 | targets = combat.get_targets(args) 32 | if not targets: 33 | print(f"No targets found from `{args}`") 34 | return 35 | 36 | for target in targets: 37 | target.disposition = "hostile" 38 | print(f"Okay; {target.name} is hostile in the player view.") 39 | self.game.changed = True 40 | -------------------------------------------------------------------------------- /dndme/commands/disposition_neutral.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class DispositionNeutral(Command): 5 | 6 | keywords = ["neutral"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Mark one or more combatants as neutral in the player view. 10 | 11 | Usage: {keyword} [ ...] 12 | 13 | Example: 14 | 15 | {keyword} goblin 16 | {keyword} goblin1 goblin2 goblin3 17 | {keyword} goblin* 18 | """ 19 | 20 | def get_suggestions(self, words): 21 | combat = self.game.combat 22 | names_already_chosen = words[1:] 23 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 24 | 25 | def do_command(self, *args): 26 | if not args: 27 | print("Need a combatant.") 28 | return 29 | 30 | combat = self.game.combat 31 | targets = combat.get_targets(args) 32 | if not targets: 33 | print(f"No targets found from `{args}`") 34 | return 35 | 36 | for target in targets: 37 | target.disposition = "neutral" 38 | print(f"Okay; {target.name} is neutral in the player view.") 39 | self.game.changed = True 40 | -------------------------------------------------------------------------------- /dndme/commands/disposition_friendly.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class DispositionFriendly(Command): 5 | 6 | keywords = ["friendly"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Mark one or more combatants as friendly in the player view. 10 | 11 | Usage: {keyword} [ ...] 12 | 13 | Example: 14 | 15 | {keyword} goblin 16 | {keyword} goblin1 goblin2 goblin3 17 | {keyword} goblin* 18 | """ 19 | 20 | def get_suggestions(self, words): 21 | combat = self.game.combat 22 | names_already_chosen = words[1:] 23 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 24 | 25 | def do_command(self, *args): 26 | if not args: 27 | print("Need a combatant.") 28 | return 29 | 30 | combat = self.game.combat 31 | targets = combat.get_targets(args) 32 | if not targets: 33 | print(f"No targets found from `{args}`") 34 | return 35 | 36 | for target in targets: 37 | target.disposition = "friendly" 38 | print(f"Okay; {target.name} is friendly in the player view.") 39 | self.game.changed = True 40 | -------------------------------------------------------------------------------- /content/example/monsters/goblin.toml: -------------------------------------------------------------------------------- 1 | name = "goblin" 2 | size = "small" 3 | mtype = "humanoid:goblinoid" 4 | race = "goblin" 5 | alignment = "neutral evil" 6 | ac = 15 7 | armor = "leather armor, shield" 8 | max_hp = "2d6" 9 | speed = 30 10 | str = 8 11 | dex = 14 12 | con = 10 13 | int = 10 14 | wis = 8 15 | cha = 8 16 | vulnerable = "" 17 | resist = "" 18 | immune = "" 19 | languages = "Common, Goblin" 20 | cr = 0.25 21 | xp = 50 22 | image_url = "" 23 | notes = """ 24 | Goblins are black-hearted, gather in overwhelming numbers, and crave power, 25 | which they abuse. 26 | """ 27 | 28 | [skills] 29 | stealth = 6 30 | 31 | [senses] 32 | darkvision = 60 33 | perception = 9 34 | 35 | [features.nimbleescape] 36 | name = "Nimble Escape" 37 | description = """ 38 | The goblin can take the Disengage or Hide action as a bonus action on each 39 | of its turns. 40 | """ 41 | 42 | [actions.scimitar] 43 | name = "Scimitar" 44 | description = """ 45 | Melee Weapon Attack: +4 to hit, reach 5 ft., one target. 46 | Hit: 5 (ld6 + 2) slashing damage. 47 | """ 48 | 49 | [actions.shortbow] 50 | name = "Short Bow" 51 | description = """ 52 | Ranged Weapon Attack: +4 to hit, range 80 ft./320 ft., one target. 53 | Hit: 5 (1d6 + 2) piercing damage. 54 | """ 55 | -------------------------------------------------------------------------------- /dndme/commands/move_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class MoveCombatant(Command): 5 | 6 | keywords = ["move"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Move a combatant to a different initiative value. 10 | 11 | Usage: {keyword} 12 | 13 | Example: {keyword} Frodo 12 14 | """ 15 | 16 | def get_suggestions(self, words): 17 | if len(words) == 2: 18 | combat = self.game.combat 19 | return combat.combatant_names 20 | 21 | def do_command(self, *args): 22 | if len(args) != 2: 23 | print("Need a combatant and an initiative value.") 24 | return 25 | 26 | combat = self.game.combat 27 | 28 | name = args[0] 29 | target = combat.get_target(name) 30 | 31 | if not target: 32 | print(f"Invalid target: {name}") 33 | return 34 | 35 | try: 36 | new_initiative = int(args[1]) 37 | except ValueError: 38 | print("Invalid initiative value") 39 | return 40 | 41 | combat.tm.move(target, new_initiative) 42 | print(f"Okay; moved {name} to {new_initiative}.") 43 | self.game.changed = True 44 | -------------------------------------------------------------------------------- /dndme/commands/swap_combatants.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class SwapCombatants(Command): 5 | 6 | keywords = ["swap"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Swap two combatants in turn order. 10 | 11 | Usage: {keyword} 12 | 13 | Example: {keyword} Sam Frodo 14 | """ 15 | 16 | def get_suggestions(self, words): 17 | combat = self.game.combat 18 | if len(words) in (2, 3): 19 | return combat.combatant_names 20 | 21 | def do_command(self, *args): 22 | if len(args) != 2: 23 | print("Need two combatants to swap.") 24 | return 25 | 26 | name1 = args[0] 27 | name2 = args[1] 28 | 29 | combat = self.game.combat 30 | 31 | combatant1 = combat.get_target(name1) 32 | combatant2 = combat.get_target(name2) 33 | 34 | if not combatant1: 35 | print(f"Invalid target: {name1}") 36 | return 37 | 38 | if not combatant2: 39 | print(f"Invalid target: {name2}") 40 | return 41 | 42 | combat.tm.swap(combatant1, combatant2) 43 | print(f"Okay; swapped {name1} and {name2}.") 44 | self.game.changed = True 45 | -------------------------------------------------------------------------------- /content/example/monsters/skeleton.toml: -------------------------------------------------------------------------------- 1 | name = "skeleton" 2 | size = "medium" 3 | mtype = "undead" 4 | race = "skeleton" 5 | alignment = "lawful evil" 6 | ac = 13 7 | armor = "" 8 | max_hp = "2d8+4" #13 9 | speed = 30 10 | str = 10 11 | dex = 14 12 | con = 15 13 | int = 6 14 | wis = 8 15 | cha = 5 16 | vulnerable = "bludgeoning" 17 | resist = "" 18 | immune = "poison, exhauted, poisoned" 19 | languages = "understands all languages it knew in life but can't speak" 20 | cr = 0.25 21 | xp = 50 22 | image_url = "" 23 | notes = """ 24 | Skeletons arise when animated by dark magic. They heed the summons of 25 | spellcasters who call them from their stony tombs and ancient battlefields, or 26 | rise of their own accord in places saturated with death and loss, awakened by 27 | stirrings of nycromantic energy or the presence of corrupting evil. 28 | """ 29 | 30 | [senses] 31 | darkvision = 60 32 | perception = 9 33 | 34 | [actions.shortsword] 35 | name = "Shortsword" 36 | description = """ 37 | Melee Weapon Attack: +4 to hit, reach 5 ft., one target. 38 | Hit: 5 (1d6 + 2) piercing damage. 39 | """ 40 | 41 | [actions.shortbow] 42 | name = "Shortbow" 43 | description = """ 44 | Ranged Weapon Attack: +4 to hit, range 80-320 ft., one target. 45 | Hit: 5 (1d6 + 2) piercing damage. 46 | """ 47 | -------------------------------------------------------------------------------- /dndme/new_campaign.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | import click 6 | import pytoml as toml 7 | 8 | base_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) 9 | 10 | 11 | @click.command() 12 | @click.argument("campaign") 13 | def main(campaign): 14 | create_campaign_dir(campaign) 15 | create_settings_file(campaign) 16 | create_party_file(campaign) 17 | 18 | 19 | def create_campaign_dir(campaign): 20 | campaign_dir = f"{base_dir}/campaigns/{campaign}" 21 | if os.path.exists(campaign_dir): 22 | print(f"Campaign {campaign} already exists") 23 | sys.exit(1) 24 | 25 | os.mkdir(campaign_dir) 26 | 27 | 28 | def create_settings_file(campaign): 29 | template_file = f"{base_dir}/templates/settings.toml" 30 | settings_file = f"{base_dir}/campaigns/{campaign}/settings.toml" 31 | 32 | with open(template_file, "r") as f_in, open(settings_file, "w") as f_out: 33 | f_out.write(f_in.read().replace("CAMPAIGN", campaign)) 34 | 35 | 36 | def create_party_file(campaign): 37 | template_file = f"{base_dir}/templates/party.toml" 38 | party_file = f"{base_dir}/campaigns/{campaign}/party.toml" 39 | shutil.copyfile(template_file, party_file) 40 | 41 | 42 | if __name__ == "__main__": 43 | main(sys.argv[-1]) 44 | -------------------------------------------------------------------------------- /dndme/commands/defeat_monster.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class DefeatMonster(Command): 5 | 6 | keywords = ["defeat"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Mark one or more monsters as defeated; the monster will be removed 10 | from combat and its experience point value will be available to credit to 11 | players when combat has concluded. 12 | 13 | Usage: {keyword} [ ...] 14 | 15 | Examples: 16 | 17 | {keyword} balrog 18 | {keyword} orc_1 orc_2 orc_3 19 | {keyword} orc* 20 | """ 21 | 22 | def get_suggestions(self, words): 23 | combat = self.game.combat 24 | names_already_chosen = words[1:] 25 | return sorted(set(combat.monsters.keys()) - set(names_already_chosen)) 26 | 27 | def do_command(self, *args): 28 | combat = self.game.combat 29 | targets = combat.get_targets(args) 30 | if not targets: 31 | print(f"No targets found from `{args}`") 32 | return 33 | 34 | for target in targets: 35 | if combat.tm: 36 | combat.tm.remove_combatant(target) 37 | combat.monsters.pop(target.name) 38 | combat.defeated.append(target) 39 | print(f"Defeated {target.name}") 40 | self.game.changed = True 41 | -------------------------------------------------------------------------------- /content/example/monsters/evil_mage.toml: -------------------------------------------------------------------------------- 1 | name = "evil_mage" 2 | size = "medium" 3 | mtype = "humanoid:human" 4 | race = "human" 5 | alignment = "lawful evil" 6 | ac = 12 7 | armor = "" 8 | max_hp = "5d8" 9 | xp = 200 10 | str = 9 11 | dex = 14 12 | con = 11 13 | int = 17 14 | wis = 12 15 | cha = 11 16 | vulnerable = "" 17 | resist = "" 18 | immune = "" 19 | languages = "Common, Draconic, Dwarvish, Elvish" 20 | cr = 1 21 | speed = 30 22 | image_url = "" 23 | notes = """ 24 | Evil mages hunger for arcane power and dwell in isolated places, where 25 | they can perform terrible magical experiments without interference. 26 | """ 27 | 28 | [skills] 29 | stealth = 6 30 | 31 | [senses] 32 | darkvision = 0 33 | perception = 11 34 | 35 | [features.spellcasting] 36 | name = "Spellcasting" 37 | description = """ 38 | The mage is a 4th·level spellcaster that uses Intelligence as its 39 | spellcasting ability (spell save DC 13; +5 to hit with spell attacks). 40 | """ 41 | cantrips = ["Light", "Mage Hand", "Shocking Grasp"] 42 | spells = [ 43 | ["Charm Person", "Magic Missile"], 44 | ["Hold Person", "Misty Step"] 45 | ] 46 | slots = [4, 3] 47 | slots_used = [0, 0] 48 | 49 | [actions.quarterstaff] 50 | name = "Quarterstaff" 51 | description = """ 52 | Melee Weapon Attack: +1 to hit, reach 5 ft., one target. 53 | Hit: 3 (ld8 - 1) bludgeoning damage. 54 | """ 55 | -------------------------------------------------------------------------------- /dndme/commands/unset_condition.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class UnsetCondition(Command): 5 | 6 | keywords = ["unset"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Remove a condition from a target 10 | 11 | Usage: {keyword} 12 | 13 | Example: {keyword} Frodo prone 14 | """ 15 | 16 | def get_suggestions(self, words): 17 | combat = self.game.combat 18 | if len(words) == 2: 19 | return combat.combatant_names 20 | elif len(words) == 3: 21 | target_name = words[1] 22 | target = combat.get_target(target_name) 23 | if not target: 24 | return [] 25 | return list(sorted(target.conditions.keys())) 26 | 27 | def do_command(self, *args): 28 | if len(args) < 2: 29 | print("Need a combatant and a condition.") 30 | return 31 | 32 | target_name = args[0] 33 | condition = args[1] 34 | 35 | combat = self.game.combat 36 | 37 | target = combat.get_target(target_name) 38 | if not target: 39 | print(f"Invalid target: {target_name}") 40 | return 41 | 42 | target.unset_condition(condition) 43 | print(f"Okay; removed condition '{condition}' from {target_name}.") 44 | self.game.changed = True 45 | -------------------------------------------------------------------------------- /dndme/commands/show_moon.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dndme.commands import Command 3 | from dndme.gametime import Date 4 | 5 | 6 | class ShowMoon(Command): 7 | 8 | keywords = ["moon", "moons"] 9 | help_text = """{keyword} 10 | {divider} 11 | Summary: show the current phases of all moons, or the phases of all moons 12 | on a specific date. 13 | 14 | Usage: {keyword} [] 15 | 16 | Examples: 17 | 18 | moon 19 | moon 1 Hammer 1488 20 | """ 21 | 22 | def do_command(self, *args): 23 | almanac = self.game.almanac 24 | calendar = self.game.calendar 25 | latitude = self.game.latitude 26 | 27 | date = None 28 | data = " ".join(args) 29 | 30 | if not data: 31 | date = calendar.date 32 | else: 33 | m_date = re.match("(\d+) (\w+) *(\d*)", data) 34 | if m_date: 35 | date = Date( 36 | int(m_date.groups()[0]), 37 | m_date.groups()[1], 38 | int(m_date.groups()[2] or calendar.date.year), 39 | ) 40 | 41 | if date: 42 | for moon_key, moon_info in calendar.cal_data["moons"].items(): 43 | phase, _ = almanac.moon_phase(moon_key, date) 44 | self.print(f"{moon_info['name']}: {phase}") 45 | return 46 | 47 | print(f"Invalid date: {data}") 48 | -------------------------------------------------------------------------------- /dndme/commands/switch_combat.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands.show import Show 3 | 4 | 5 | class SwitchCombat(Command): 6 | 7 | keywords = ["switch"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Pause one combat group and switch to another. By itself, 'switch' 11 | just cycles through combat groups in order of their creation. You can instead 12 | specify a particular group to jump to. 13 | 14 | Usage: {keyword} [] 15 | 16 | Examples: 17 | 18 | {keyword} 19 | {keyword} 2 20 | """ 21 | 22 | def get_suggestions(self, words): 23 | if len(words) == 2: 24 | return [ 25 | f"{i} - {', '.join([x for x in combat.characters])}" 26 | for i, combat in enumerate(self.game.combats, 1) 27 | ] 28 | 29 | def do_command(self, *args): 30 | switch_to = int(args[0]) if args else None 31 | if switch_to and 1 <= switch_to <= len(self.game.combats): 32 | switch_to -= 1 33 | self.game.combat = self.game.combats[switch_to] 34 | else: 35 | switch_to = self.game.combats.index(self.game.combat) + 1 36 | if switch_to >= len(self.game.combats): 37 | switch_to = 0 38 | self.game.combat = self.game.combats[switch_to] 39 | 40 | print(f"Okay; switched to combat {switch_to + 1}") 41 | Show.show_party(self) 42 | self.game.changed = True 43 | -------------------------------------------------------------------------------- /dndme/commands/next_turn.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands.show import Show 3 | 4 | 5 | class NextTurn(Command): 6 | 7 | keywords = ["next"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Advance to the next turn of combat. 11 | 12 | Usage: {keyword} 13 | """ 14 | 15 | def do_command(self, *args): 16 | combat = self.game.combat 17 | if not combat.tm: 18 | print("Combat hasn't started yet.") 19 | return 20 | 21 | num_turns = int(args[0]) if args else 1 22 | 23 | for i in range(num_turns): 24 | turn = combat.tm.cur_turn 25 | conditions_removed = [] 26 | if turn: 27 | combatant = turn[-1] 28 | conditions_removed = combatant.decrement_condition_durations() 29 | if conditions_removed: 30 | self.print( 31 | f"{combatant.name} conditions removed: " 32 | f"{', '.join(conditions_removed)}" 33 | ) 34 | 35 | combat.tm.previous_turns.append((turn, conditions_removed)) 36 | 37 | if combat.tm.next_turns: 38 | new_turn, _ = combat.tm.next_turns.pop() 39 | else: 40 | new_turn = next(combat.tm.turns) 41 | 42 | combat.tm.cur_turn = new_turn 43 | Show.show_turn(self) 44 | self.game.changed = True 45 | -------------------------------------------------------------------------------- /calendars/gregorian.toml: -------------------------------------------------------------------------------- 1 | name = "Gregorian" 2 | hours_in_day = 24 3 | minutes_in_hour = 60 4 | leap_year_rule = "(year % 4 == 0) and (year % 100 != 0) or (year % 400 == 0)" 5 | axial_tilt = 23.44 6 | solar_days_in_year = 365.25 7 | 8 | default_day = 1 9 | default_month = "January" 10 | default_year = 1970 11 | 12 | [months.january] 13 | name = "January" 14 | days = 31 15 | 16 | [months.february] 17 | name = "February" 18 | days = 28 19 | leap_year_days = 29 20 | 21 | [months.march] 22 | name = "March" 23 | days = 31 24 | 25 | [months.april] 26 | name = "April" 27 | days = 30 28 | 29 | [months.may] 30 | name = "May" 31 | days = 31 32 | 33 | [months.june] 34 | name = "June" 35 | days = 30 36 | 37 | [months.july] 38 | name = "July" 39 | days = 31 40 | 41 | [months.august] 42 | name = "August" 43 | days = 31 44 | 45 | [months.september] 46 | name = "September" 47 | days = 30 48 | 49 | [months.october] 50 | name = "October" 51 | days = 31 52 | 53 | [months.november] 54 | name = "November" 55 | days = 30 56 | 57 | [months.december] 58 | name = "December" 59 | days = 31 60 | 61 | [seasons.spring_equinox] 62 | name = "Spring Equinox" 63 | month = "March" 64 | day = 20 65 | 66 | [seasons.summer_solstice] 67 | name = "Summer Solstice" 68 | month = "June" 69 | day = 21 70 | 71 | [seasons.autumn_equinox] 72 | name = "Autumn Equinox" 73 | month = "September" 74 | day = 22 75 | 76 | [seasons.winter_solstice] 77 | name = "Winter Solstice" 78 | month = "December" 79 | day = 21 80 | 81 | [moons.luna] 82 | name = "Luna" 83 | period = "29.53" 84 | full_on = "1 January 2018" -------------------------------------------------------------------------------- /dndme/commands/stash_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class StashCombatant(Command): 5 | 6 | keywords = ["stash"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Remove a combatant from a combat group and place them into a 'stash' 10 | for temporary storage. This might be used to handle monsters who have 11 | retreated and might later rejoin combat, or any other cases where you want 12 | to have a combatant "waiting in the wings". 13 | 14 | Use 'unstash' to move them back into a combat group. 15 | 16 | Usage: {keyword} [ ...] 17 | 18 | Examples: 19 | 20 | {keyword} Gandalf 21 | {keyword} Frodo Sam 22 | {keyword} orc* 23 | """ 24 | 25 | def get_suggestions(self, words): 26 | combat = self.game.combat 27 | names_already_chosen = words[1:] 28 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 29 | 30 | def do_command(self, *args): 31 | combat = self.game.combat 32 | targets = combat.get_targets(args) 33 | if not targets: 34 | print(f"No targets found from `{args}`") 35 | return 36 | 37 | for target in targets: 38 | if combat.tm: 39 | combat.tm.remove_combatant(target) 40 | if target.name in combat.monsters: 41 | combat.monsters.pop(target.name) 42 | else: 43 | combat.characters.pop(target.name) 44 | 45 | self.game.stash[target.name] = target 46 | print(f"Stashed {target.name}") 47 | self.game.changed = True 48 | -------------------------------------------------------------------------------- /dndme/commands/show_sun.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class ShowSun(Command): 5 | 6 | keywords = ["sun", "times"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: show the times of the day's notable solar events, 10 | on the current date, at the current latitude. 11 | 12 | Usage: {keyword} 13 | """ 14 | 15 | def do_command(self, *args): 16 | almanac = self.game.almanac 17 | calendar = self.game.calendar 18 | latitude = self.game.latitude 19 | 20 | dawn, _ = almanac.dawn(calendar.date, latitude) 21 | sunrise, _ = almanac.sunrise(calendar.date, latitude) 22 | sunset, _ = almanac.sunset(calendar.date, latitude) 23 | dusk, _ = almanac.dusk(calendar.date, latitude) 24 | 25 | self.print(f"Dawn: {dawn.hour:2}:{dawn.minute:02}") 26 | self.print(f"Sunrise: {sunrise.hour:2}:{sunrise.minute:02}") 27 | self.print(f"Sunset: {sunset.hour:2}:{sunset.minute:02}") 28 | self.print(f"Dusk: {dusk.hour:2}:{dusk.minute:02}") 29 | 30 | minutes_in_hour = calendar.cal_data["minutes_in_hour"] 31 | sunrise_hours = sunrise.hour + (sunrise.minute / minutes_in_hour) 32 | sunset_hours = sunset.hour + (sunset.minute / minutes_in_hour) 33 | daylight_hours = int(sunset_hours - sunrise_hours) 34 | daylight_minutes = round( 35 | (sunset_hours - sunrise_hours - daylight_hours) * minutes_in_hour 36 | ) 37 | 38 | self.print( 39 | f"Daylight: {daylight_hours} hours, {daylight_minutes} minutes" 40 | ) 41 | -------------------------------------------------------------------------------- /dndme/new_content.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | import click 6 | import pytoml as toml 7 | 8 | base_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) 9 | 10 | 11 | @click.command() 12 | @click.argument("name") 13 | def main(name): 14 | content_dir = create_content_dir(name) 15 | create_encounters_dir(content_dir) 16 | create_monsters_dir(content_dir) 17 | create_images_dirs(content_dir) 18 | 19 | 20 | def create_content_dir(name): 21 | content_dir = f"{base_dir}/content/{name}" 22 | if os.path.exists(content_dir): 23 | print(f"Content package {name} already exists") 24 | sys.exit(1) 25 | 26 | os.mkdir(content_dir) 27 | return content_dir 28 | 29 | 30 | def create_encounters_dir(content_dir): 31 | encounters_dir = f"{content_dir}/encounters" 32 | os.mkdir(encounters_dir) 33 | 34 | template_file = f"{base_dir}/templates/encounter.toml" 35 | destination_file = f"{encounters_dir}/TEMPLATE" 36 | shutil.copyfile(template_file, destination_file) 37 | 38 | 39 | def create_monsters_dir(content_dir): 40 | monsters_dir = f"{content_dir}/monsters" 41 | os.mkdir(monsters_dir) 42 | 43 | template_file = f"{base_dir}/templates/monster.toml" 44 | destination_file = f"{monsters_dir}/TEMPLATE" 45 | shutil.copyfile(template_file, destination_file) 46 | 47 | 48 | def create_images_dirs(content_dir): 49 | images_dir = f"{content_dir}/images" 50 | os.mkdir(images_dir) 51 | 52 | monster_images_dir = f"{images_dir}/monsters" 53 | os.mkdir(monster_images_dir) 54 | 55 | 56 | if __name__ == "__main__": 57 | main(sys.argv[-1]) 58 | -------------------------------------------------------------------------------- /campaigns/example/party.toml: -------------------------------------------------------------------------------- 1 | [Sariel] 2 | name = "Sariel" 3 | race = "Elf" 4 | cclass = "Ranger" 5 | level = 4 6 | pronouns = "" 7 | max_hp = 32 8 | cur_hp = 32 9 | temp_hp = 0 10 | ac = 16 11 | initiative_mod = 4 12 | image_url = "" 13 | 14 | [Sariel.senses] 15 | perception = 15 16 | darkvision = 60 17 | 18 | [Lander] 19 | name = "Lander" 20 | race = "Human" 21 | cclass = "Fighter" 22 | level = 4 23 | pronouns = "" 24 | max_hp = 36 25 | cur_hp = 36 26 | temp_hp = 0 27 | ac = 17 28 | initiative_mod = 0 29 | image_url = "" 30 | 31 | [Lander.senses] 32 | perception = 14 33 | 34 | [Armek] 35 | name = "Armek" 36 | race = "Dwarf" 37 | cclass = "Cleric" 38 | level = 4 39 | pronouns = "" 40 | max_hp = 38 41 | cur_hp = 38 42 | temp_hp = 0 43 | ac = 18 44 | initiative_mod = 0 45 | image_url = "" 46 | 47 | [Armek.senses] 48 | perception = 13 49 | darkvision = 60 50 | 51 | [Pip] 52 | name = "Pip" 53 | race = "Halfling" 54 | cclass = "Rogue" 55 | level = 4 56 | pronouns = "" 57 | max_hp = 29 58 | cur_hp = 29 59 | temp_hp = 0 60 | ac = 16 61 | initiative_mod = 0 62 | image_url = "" 63 | 64 | [Pip.senses] 65 | perception = 10 66 | 67 | [Dewain] 68 | name = "Dewain" 69 | race = "Elf" 70 | cclass = "Wizard" 71 | level = 4 72 | pronouns = "" 73 | max_hp = 30 74 | cur_hp = 30 75 | temp_hp = 0 76 | ac = 14 77 | initiative_mod = 0 78 | image_url = "" 79 | 80 | [Dewain.senses] 81 | perception = 13 82 | darkvision = 60 83 | 84 | [Elwing] 85 | name = "Elwing" 86 | race = "Human" 87 | cclass = "Fighter" 88 | level = 4 89 | pronouns = "" 90 | max_hp = 38 91 | cur_hp = 38 92 | temp_hp = 0 93 | ac = 16 94 | initiative_mod = 0 95 | image_url = "" 96 | 97 | [Elwing.senses] 98 | perception = 13 99 | -------------------------------------------------------------------------------- /dndme/commands/heal_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class HealCombatant(Command): 5 | 6 | keywords = ["heal"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Heal one or more combatants. 10 | 11 | Usage: {keyword} [ ...] 12 | 13 | Examples: 14 | 15 | {keyword} Frodo 10 16 | {keyword} Frodo Sam Gandalf 10 17 | {keyword} orc* 10 18 | """ 19 | 20 | def get_suggestions(self, words): 21 | combat = self.game.combat 22 | names_already_chosen = words[1:] 23 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 24 | 25 | def do_command(self, *args): 26 | if len(args) < 2: 27 | print("Need a target and an amount of HP.") 28 | return 29 | 30 | try: 31 | amount = int(args[-1]) 32 | except ValueError: 33 | print("Need an amount of HP.") 34 | return 35 | 36 | if len(args) < 2: 37 | print("Need a target and an amount of HP.") 38 | return 39 | 40 | combat = self.game.combat 41 | targets = combat.get_targets(args[:-1]) 42 | if not targets: 43 | print(f"No targets found from `{args[:-1]}`") 44 | return 45 | 46 | for target in targets: 47 | if "dead" in target.conditions: 48 | print(f"Cannot heal {target.name} (dead)") 49 | continue 50 | 51 | target.cur_hp += amount 52 | print( 53 | f"Okay; healed {target.name}. " f"Now: {target.cur_hp}/{target.max_hp}" 54 | ) 55 | self.game.changed = True 56 | -------------------------------------------------------------------------------- /dndme/commands/remove_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class RemoveCombatant(Command): 5 | 6 | keywords = ["remove"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Remove one or more combatants from the game. They will not be marked 10 | as defeated, nor will any experience points be credited for them. 11 | 12 | Usage: {keyword} [ ...] 13 | 14 | Examples: 15 | 16 | {keyword} orc 17 | {keyword} orc_1 orc_2 orc_3 18 | {keyword} orc* 19 | """ 20 | 21 | def get_suggestions(self, words): 22 | combat = self.game.combat 23 | names_already_chosen = words[1:] 24 | return sorted( 25 | set(list(combat.monsters.keys()) + list(self.game.stashed_monster_names)) 26 | - set(names_already_chosen) 27 | ) 28 | 29 | def do_command(self, *args): 30 | combat = self.game.combat 31 | targets = combat.get_targets(args) 32 | if not targets: 33 | print(f"No targets found from `{args}`") 34 | return 35 | 36 | for target in targets: 37 | if target and hasattr(target, "mtype"): 38 | if combat.tm: 39 | combat.tm.remove_combatant(target) 40 | combat.monsters.pop(target.name) 41 | print(f"Removed {target.name}") 42 | self.game.changed = True 43 | elif target.name in self.game.stash and hasattr( 44 | self.game.stash[target.name], "mtype" 45 | ): 46 | self.game.stash.pop(target.name) 47 | print(f"Removed {target.name} from stash") 48 | self.game.changed = True 49 | -------------------------------------------------------------------------------- /dndme/commands/previous_turn.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands.show import Show 3 | 4 | 5 | class PreviousTurn(Command): 6 | 7 | keywords = ["prev", "previous"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Roll back to the previous turn of combat. 11 | 12 | Usage: {keyword} 13 | """ 14 | 15 | def do_command(self, *args): 16 | combat = self.game.combat 17 | if not combat.tm: 18 | print("Combat hasn't started yet.") 19 | return 20 | 21 | num_turns = int(args[0]) if args else 1 22 | 23 | for i in range(num_turns): 24 | turn = combat.tm.cur_turn 25 | if not turn: 26 | print("Combat hasn't started yet.") 27 | return 28 | 29 | if not combat.tm.previous_turns: 30 | print("Already at the first turn of combat.") 31 | return 32 | prev_turn = combat.tm.previous_turns.pop() 33 | 34 | combat.tm.next_turns.append((turn, [])) 35 | combat.tm.cur_turn = prev_turn[0] 36 | turn, conditions_to_add = prev_turn 37 | combatant = turn[-1] 38 | 39 | combatant.increment_condition_durations() 40 | if conditions_to_add: 41 | for condition in conditions_to_add: 42 | combatant.set_condition(condition, duration=1) 43 | self.print( 44 | f"{combatant.name} conditions added: " 45 | f"{', '.join(conditions_to_add)}" 46 | ) 47 | 48 | self.print(f"Reverted turn to {combatant.name}") 49 | Show.show_turn(self) 50 | self.game.changed = True 51 | -------------------------------------------------------------------------------- /dndme/commands/adjust_date.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dndme.commands import Command 3 | from dndme.gametime import Date 4 | 5 | 6 | class AdjustDate(Command): 7 | 8 | keywords = ["date"] 9 | help_text = """{keyword} 10 | {divider} 11 | Summary: Query, set, or adjust the in-game date using the calendar 12 | specified at startup. 13 | 14 | Usage: 15 | 16 | {keyword} 17 | {keyword} [] 18 | {keyword} [+|-] 19 | 20 | Examples: 21 | 22 | {keyword} 23 | {keyword} 20 July 24 | {keyword} 20 July 1969 25 | {keyword} +7 26 | {keyword} -10 27 | """ 28 | 29 | def get_suggestions(self, words): 30 | calendar = self.game.calendar 31 | if len(words) == 3: 32 | return [month["name"] for month in calendar.cal_data["months"].values()] 33 | 34 | def do_command(self, *args): 35 | calendar = self.game.calendar 36 | data = " ".join(args) 37 | 38 | if not data: 39 | print(f"The date is {calendar}") 40 | return 41 | 42 | m_adjustment = re.match("([+-]\d+)", data) 43 | if m_adjustment: 44 | days = int(m_adjustment.groups()[0]) 45 | calendar.adjust_date(days) 46 | print(f"The date is now {calendar}") 47 | self.game.changed = True 48 | return 49 | 50 | m_set = re.match("(\d+) (\w+) *(\d*)", data) 51 | if m_set: 52 | day, month, year = m_set.groups() 53 | day = int(day) 54 | year = int(year) if year else calendar.date.year 55 | 56 | calendar.set_date(Date(day, month, year)) 57 | print(f"The date is now {calendar}") 58 | self.game.changed = True 59 | return 60 | 61 | print(f"Invalid date: {data}") 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # Intellij 105 | *.iml 106 | .idea/ 107 | 108 | # vim 109 | *.swp 110 | 111 | # VSCode 112 | .vscode -------------------------------------------------------------------------------- /content/example/monsters/young_green_dragon.toml: -------------------------------------------------------------------------------- 1 | name = "young_green_dragon" 2 | size = "large" 3 | mtype = "dragon" 4 | race = "dragon" 5 | alignment = "lawful evil" 6 | ac = 18 7 | armor = "natural armor" 8 | max_hp = "16d10+48" 9 | speed = "land: 40, fly: 60, swim: 40" 10 | str = 19 11 | dex = 12 12 | con = 17 13 | int = 16 14 | wis = 13 15 | cha = 15 16 | vulnerable = "" 17 | resist = "" 18 | immune = "poison, poisoned" 19 | languages = "Common, Draconic" 20 | cr = 8 21 | xp = 3900 22 | image_url = "" 23 | notes = """ 24 | Thoroughly evil, green dragons delight in subverting and corrupting the 25 | good-hearted. They prefer to dwell in ancient forests. 26 | """ 27 | 28 | [skills] 29 | deception = 5 30 | stealth = 4 31 | dex_save = 4 32 | con_save = 6 33 | wis_save = 4 34 | cha_save = 5 35 | 36 | [senses] 37 | darkvision = 120 38 | blindsight = 30 39 | perception = 17 40 | 41 | [features.amphibious] 42 | name = "Amphibious" 43 | description = "The dragon can breathe air and water." 44 | 45 | [actions.multiattack] 46 | name = "Multiattack" 47 | description = """ 48 | The dragon makes three attacks, one with its bite and two with its claws. 49 | """ 50 | 51 | [actions.bite] 52 | name = "Bite" 53 | description = """ 54 | Melee Weapon Attack: +7 to hit, reach 10 ft., one target. 55 | Hit: 15 (2dl0 + 4) piercing damage plus 7 (2d6) poison damage. 56 | """ 57 | 58 | [actions.claw] 59 | name = "Claw" 60 | description = """ 61 | Melee Weapon Attack: +7 to hit, reach 5 ft., one target. 62 | Hit: 11 (2d6 + 4) slashing damage. 63 | """ 64 | 65 | [actions.breath] 66 | name = "Poison Breath (Recharge 5-6)" 67 | description = """ 68 | The dragon breathes poisonous gas in a 30-foot cone. Each creature in the 69 | cone must make a DC 16 Constitution saving throw, taking 42 (12d6) poison 70 | damage on a failed save, or half as much damage on a successful one. 71 | """ 72 | -------------------------------------------------------------------------------- /dndme/commands/show_calendar.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dndme.commands import Command 3 | 4 | 5 | class ShowCalendar(Command): 6 | 7 | keywords = ["calendar", "cal"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Show an overview of the calendar for the current year, 11 | or a given year. 12 | 13 | Usage: 14 | 15 | {keyword} [year] 16 | 17 | Examples: 18 | 19 | {keyword} 20 | {keyword} 1488 21 | """ 22 | 23 | def do_command(self, *args): 24 | calendar = self.game.calendar 25 | 26 | if args: 27 | try: 28 | year = int(args[0]) 29 | except ValueError: 30 | print(f"Invalid year: {year}") 31 | return 32 | else: 33 | year = calendar.date.year 34 | 35 | self.print(f"{year}") 36 | print("-" * 60) 37 | for key, month in calendar.cal_data["months"].items(): 38 | days_in_month = calendar.days_in_month(key, year) 39 | alt_name = f"\t{month.get('alt_name')}" if month.get("alt_name") else "" 40 | sdates = calendar.seasonal_dates_in_month(key) 41 | if sdates: 42 | if days_in_month > 1: 43 | sdates = ", ".join( 44 | [f"{x['name']}: {x['day']}" for x in sdates] 45 | ) 46 | else: 47 | sdates = ", ".join([f"{x['name']}" for x in sdates]) 48 | sdates = f"\t{sdates}" if sdates else "" 49 | 50 | if days_in_month == 0: 51 | continue 52 | elif days_in_month == 1: 53 | self.print(f" {month['name']:12}{alt_name}{sdates}") 54 | else: 55 | self.print( 56 | f"1-{days_in_month} {month['name']:12}{alt_name:25}{sdates}" 57 | ) 58 | -------------------------------------------------------------------------------- /dndme/commands/unstash_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands import convert_to_int, convert_to_int_or_dice_expr 3 | 4 | 5 | class UnstashCombatant(Command): 6 | 7 | keywords = ["unstash"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Move one or more stashed combatants back into the current combat 11 | group. 12 | 13 | Usage: {keyword} [ ...] 14 | 15 | Examples: 16 | 17 | {keyword} Gandalf 18 | {keyword} Sam Frodo 19 | """ 20 | 21 | def get_suggestions(self, words): 22 | names_already_chosen = words[1:] 23 | return sorted(set(self.game.stash.keys()) - set(names_already_chosen)) 24 | 25 | def do_command(self, *args): 26 | combat = self.game.combat 27 | 28 | for target_name in args: 29 | if target_name not in self.game.stash: 30 | print(f"Invalid target: {target_name}") 31 | continue 32 | 33 | target = self.game.stash.pop(target_name) 34 | 35 | if hasattr(target, "mtype"): 36 | combat.monsters[target_name] = target 37 | else: 38 | combat.characters[target_name] = target 39 | 40 | print(f"Unstashed {target_name}") 41 | self.game.changed = True 42 | 43 | if combat.tm: 44 | roll_advice = ( 45 | f"1d20{target.initiative_mod:+}" 46 | if target.initiative_mod 47 | else "1d20" 48 | ) 49 | roll = self.safe_input( 50 | f"Initiative for {target.name}", 51 | default=roll_advice, 52 | converter=convert_to_int_or_dice_expr, 53 | ) 54 | combat.tm.add_combatant(target, roll) 55 | print(f"Added to turn order in {roll}") 56 | -------------------------------------------------------------------------------- /dndme/commands/log.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import datetime 3 | import os 4 | import sys 5 | from dndme.commands import Command 6 | 7 | 8 | class Log(Command): 9 | 10 | keywords = ["log"] 11 | help_text = """{keyword} 12 | {divider} 13 | Summary: With some text, write an entry in the campaign log for later 14 | reference. Without any data, read back all entries from the current session. 15 | 16 | Usage: {keyword} [] 17 | 18 | Examples: 19 | 20 | {keyword} Bilbo separated from dwarves 21 | {keyword} Bilbo found a magic ring 22 | {keyword} 23 | """ 24 | 25 | def __init__(self, *args): 26 | super().__init__(*args) 27 | 28 | now = datetime.datetime.now() 29 | self.log_buf = [] 30 | self.log_file = self.game.log_file 31 | self.log_message( 32 | f"Session started {now:%Y-%m-%d %H:%M:%S}", 33 | with_leading_newline=os.path.exists(self.log_file or ""), 34 | ) 35 | 36 | def sign_off(): 37 | self.do_command( 38 | "Session ended on", 39 | str(self.game.calendar), 40 | "at", 41 | str(self.game.clock), 42 | "at", 43 | f"{self.game.latitude}°", 44 | ) 45 | 46 | atexit.register(sign_off) 47 | 48 | def do_command(self, *args): 49 | if args: 50 | self.log_message(" ".join(args), with_bullet=True) 51 | else: 52 | print("\n".join(self.log_buf)) 53 | 54 | def log_message(self, message, with_leading_newline=False, with_bullet=False): 55 | if with_bullet: 56 | message = "* " + message 57 | 58 | self.log_buf.append(message) 59 | 60 | if with_leading_newline: 61 | message = "\n" + message 62 | 63 | if self.log_file: 64 | with open(self.game.log_file, "a") as f: 65 | f.write(message + "\n") 66 | -------------------------------------------------------------------------------- /dndme/commands/damage_combatant.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands.defeat_monster import DefeatMonster 3 | 4 | 5 | class DamageCombatant(Command): 6 | 7 | keywords = ["damage", "hurt", "hit"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Apply damage to one or more combatants. 11 | 12 | Usage: {keyword} [ ...] 13 | 14 | Examples: 15 | 16 | {keyword} Frodo 10 17 | {keyword} Frodo Merry Pippin 10 18 | {keyword} orc* 5 19 | """ 20 | 21 | def get_suggestions(self, words): 22 | combat = self.game.combat 23 | names_already_chosen = words[1:] 24 | return sorted(set(combat.combatant_names) - set(names_already_chosen)) 25 | 26 | def do_command(self, *args): 27 | if len(args) < 2: 28 | print("Need a target and an amount of HP.") 29 | return 30 | 31 | try: 32 | amount = int(args[-1]) 33 | except ValueError: 34 | print("Need an amount of HP.") 35 | return 36 | 37 | combat = self.game.combat 38 | targets = combat.get_targets(args[:-1]) 39 | if not targets: 40 | print(f"No targets found from `{args[:-1]}`") 41 | return 42 | 43 | for target in targets: 44 | target.cur_hp -= amount 45 | print( 46 | f"Okay; damaged {target.name}. " f"Now: {target.cur_hp}/{target.max_hp}" 47 | ) 48 | self.game.changed = True 49 | 50 | if target.name in combat.monsters and target.cur_hp == 0: 51 | if ( 52 | self.session.prompt( 53 | f"{target.name} reduced to 0 HP--" "mark as defeated? [Y]: " 54 | ) 55 | or "y" 56 | ).lower() != "y": 57 | continue 58 | DefeatMonster.do_command(self, target.name) 59 | -------------------------------------------------------------------------------- /dndme/commands/reorder_initiative.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | 3 | 4 | class ReorderInitiative(Command): 5 | 6 | keywords = ["reorder"] 7 | help_text = """{keyword} 8 | {divider} 9 | Summary: Reorder the combatants with a particular initiative value. 10 | 11 | Usage: {keyword} [ ...] 12 | 13 | Example: {keyword} 17 Frodo Sam Gandalf 14 | """ 15 | 16 | def get_suggestions(self, words): 17 | combat = self.game.combat 18 | 19 | if len(words) > 2: 20 | try: 21 | initiative_value = int(words[1]) 22 | except ValueError: 23 | return [] 24 | 25 | combatant_names = [x.name for x in combat.tm.initiative[initiative_value]] 26 | names_already_chosen = words[2:] 27 | return list(set(combatant_names) - set(names_already_chosen)) 28 | 29 | def do_command(self, *args): 30 | if len(args) < 2: 31 | print("Need an initiative and combatants to reorder.") 32 | return 33 | 34 | combat = self.game.combat 35 | 36 | if not combat.tm: 37 | print("No encounter in progress.") 38 | return 39 | 40 | try: 41 | i = int(args[0]) 42 | except ValueError: 43 | print("Invalid initiative value") 44 | return 45 | 46 | names = args[1:] 47 | old_initiative = combat.tm.initiative[i] 48 | new_initiative = [combat.get_target(x) for x in names] 49 | 50 | if set(names) != set([x.name for x in new_initiative if x]): 51 | print("Could not reorder: couldn't find all combatants specified.") 52 | return 53 | 54 | elif set(names) != set([x.name for x in old_initiative]): 55 | print("Could not reorder: not all original combatants specified.") 56 | return 57 | 58 | combat.tm.initiative[i] = new_initiative 59 | print( 60 | f"Okay; updated {i}: " 61 | f"{', '.join([x.name for x in combat.tm.initiative[i]])}" 62 | ) 63 | self.game.changed = True 64 | -------------------------------------------------------------------------------- /dndme/commands/add_sidekick.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command, convert_to_int, convert_to_int_or_dice_expr 2 | from dndme.models import Character 3 | 4 | 5 | class AddSidekick(Command): 6 | 7 | keywords = ["sidekick"] 8 | 9 | help_text = """{keyword} 10 | {divider} 11 | Summary: Add a sidekick to the party. A series of interactive prompts will 12 | ask for all necessary data about the sidekick. 13 | 14 | Usage: {keyword} 15 | """ 16 | 17 | def do_command(self, *args): 18 | name = self.safe_input("Sidekick name") 19 | level = self.safe_input("Level", default=1, converter=convert_to_int) 20 | race = self.safe_input("Race", default="Human") 21 | cclass = self.safe_input("Type", default="Warrior") 22 | ac = self.safe_input("Armor class", default=10, converter=convert_to_int) 23 | max_hp = self.safe_input("Max HP", default=10, converter=convert_to_int) 24 | cur_hp = max_hp 25 | perception = self.safe_input( 26 | "Passive perception", default=10, converter=convert_to_int 27 | ) 28 | 29 | data = { 30 | "name": name, 31 | "level": level, 32 | "race": race, 33 | "cclass": cclass, 34 | "ctype": "sidekick", 35 | "ac": ac, 36 | "max_hp": max_hp, 37 | "cur_hp": cur_hp, 38 | "senses": {"perception": perception}, 39 | } 40 | 41 | if not all(data.values()): 42 | print("Sorry; couldn't add incomplete sidekick.") 43 | return 44 | 45 | sidekick = Character(**data) 46 | print(f"Added sidekick {sidekick.name} to the party!") 47 | 48 | self.game.combat.characters[name] = sidekick 49 | 50 | # Add them to the turn order if combat is in progress 51 | if not self.game.combat.tm: 52 | return 53 | 54 | roll = self.safe_input( 55 | f"Initiative for {sidekick.name}", 56 | default=roll_advice, 57 | converter=convert_to_int_or_dice_expr, 58 | ) 59 | print(f"Adding to turn order at: {roll}") 60 | self.game.combat.tm.add_combatant(sidekick, roll) 61 | -------------------------------------------------------------------------------- /dndme/check_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | import pytoml as toml 5 | 6 | from dndme.models import Game, Monster 7 | from dndme.loaders import ImageLoader, MonsterLoader 8 | 9 | 10 | class Checker: 11 | def __init__(self, filenames=None): 12 | self.monster_loader = self.get_monster_loader() 13 | self.filenames = filenames or [] 14 | self.counts = { 15 | "files_checked": 0, 16 | "files_ok": 0, 17 | "files_bad": 0, 18 | } 19 | self.errors = {} 20 | 21 | def get_monster_loader(self): 22 | # Fake what we need to get a MonsterLoader 23 | game = Game( 24 | base_dir=None, 25 | encounters_dir=None, 26 | party_file=None, 27 | log_file=None, 28 | calendar=None, 29 | clock=None, 30 | almanac=None, 31 | latitude=None, 32 | ) 33 | image_loader = ImageLoader(game) 34 | monster_loader = MonsterLoader(image_loader) 35 | return monster_loader 36 | 37 | def check_files(self, filenames=None): 38 | filenames = filenames or self.filenames 39 | for filename in filenames: 40 | self.counts["files_checked"] += 1 41 | try: 42 | print(f"Trying {filename}", end="") 43 | self.load_monster(filename) 44 | print(" ✅") 45 | self.counts["files_ok"] += 1 46 | except Exception as e: 47 | print(" ❌") 48 | self.counts["files_bad"] += 1 49 | self.errors[filename] = str(e) 50 | continue 51 | 52 | return self.counts 53 | 54 | def load_monster(self, filename): 55 | monster_data = self.monster_loader.load_from_file(filename) 56 | monster = Monster(**monster_data) 57 | return monster 58 | 59 | 60 | if __name__ == "__main__": 61 | filenames = sys.argv[1:] 62 | checker = Checker() 63 | results = checker.check_files(filenames) 64 | print(f"\n{results}") 65 | if checker.errors: 66 | print() 67 | for file, error in checker.errors.items(): 68 | print(f"❌ {file}:\n {error}") 69 | -------------------------------------------------------------------------------- /dndme/commands/end_combat.py: -------------------------------------------------------------------------------- 1 | import math 2 | from prompt_toolkit.completion import WordCompleter 3 | from dndme.commands import Command 4 | from dndme.commands.remove_combatant import RemoveCombatant 5 | from dndme.commands.show import Show 6 | from dndme.commands.stash_combatant import StashCombatant 7 | 8 | 9 | class EndCombat(Command): 10 | 11 | keywords = ["end"] 12 | help_text = """{keyword} 13 | {divider} 14 | Summary: End the current combat and distribute experience points. 15 | 16 | Usage: {keyword} 17 | """ 18 | 19 | def do_command(self, *args): 20 | combat = self.game.combat 21 | if not combat.tm: 22 | print("Combat hasn't started yet.") 23 | return 24 | 25 | cur_turn = combat.tm.cur_turn 26 | 27 | combat.tm = None 28 | Show.show_defeated(self) 29 | combat.defeated = [] 30 | 31 | # Allow some leftover monsters to remain in the combat group; 32 | # perhaps some are friendly NPCs along for the ride? 33 | choices = WordCompleter(["keep", "remove", "stash"]) 34 | for monster in list(combat.monsters.values()): 35 | choice = self.session.prompt( 36 | f"What should we do with {monster.name}? " 37 | "[R]emove [S]tash [K]eep (default: Keep) ", 38 | completer=choices, 39 | ).lower() 40 | if choice in ("r", "remove"): 41 | RemoveCombatant.do_command(self, monster.name) 42 | elif choice in ("s", "stash"): 43 | StashCombatant.do_command(self, monster.name) 44 | else: 45 | print(f"Okay, keeping {monster.name}") 46 | 47 | if cur_turn: 48 | rounds = cur_turn[0] 49 | duration_sec = cur_turn[0] * 6 50 | else: 51 | rounds = 0 52 | duration_sec = 0 53 | 54 | if duration_sec > 60: 55 | duration = f"{duration_sec // 60} min {duration_sec % 60} sec" 56 | else: 57 | duration = f"{duration_sec} sec" 58 | 59 | print(f"Combat ended in {rounds} rounds ({duration})") 60 | 61 | self.game.clock.adjust_time(minutes=math.ceil(duration_sec / 60)) 62 | print(f"Game time is now {self.game.clock}") 63 | 64 | self.game.changed = True 65 | -------------------------------------------------------------------------------- /dndme/commands/save.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | from dndme.commands import Command 3 | from dndme.writers import PartyWriter 4 | 5 | 6 | class Save(Command): 7 | 8 | keywords = ["save"] 9 | 10 | help_text = """{keyword} 11 | {divider} 12 | Summary: Save the party data back to the party toml file. 13 | 14 | Usage: {keyword} 15 | """ 16 | 17 | def __init__(self, *args): 18 | super().__init__(*args) 19 | 20 | def sign_off(): 21 | self.do_command() 22 | 23 | atexit.register(sign_off) 24 | 25 | def do_command(self, *args): 26 | characters = {} 27 | 28 | # In case the party was split... 29 | for combat in self.game.combats: 30 | characters.update(combat.characters) 31 | 32 | # In case there are stashed party members... 33 | characters.update( 34 | { 35 | name: combatant 36 | for name, combatant in self.game.stash.items() 37 | if hasattr(combatant, "ctype") 38 | } 39 | ) 40 | 41 | # Convert data classes to dicts 42 | party_data = {} 43 | for character in characters.values(): 44 | party_data[character.name] = { 45 | "name": character.name, 46 | "species": character.species, 47 | "cclass": character.cclass, 48 | "level": character.level, 49 | "pronouns": character.pronouns, 50 | "max_hp": character._max_hp, 51 | "cur_hp": character._cur_hp, 52 | "temp_hp": character.temp_hp, 53 | "ac": character.ac, 54 | "initiative_mod": character.initiative_mod, 55 | "image_url": character.image_url, 56 | "senses": character.senses, 57 | } 58 | if character.max_hp_override is not None: 59 | party_data[character.name][ 60 | "max_hp_override" 61 | ] = character.max_hp_override 62 | if character.exhaustion: 63 | party_data[character.name]["exhaustion"] = character.exhaustion 64 | if character.ctype != "player": 65 | party_data[character.name]["ctype"] = character.ctype 66 | 67 | if party_data: 68 | writer = PartyWriter(self.game.party_file) 69 | writer.write(party_data) 70 | print("OK; saved party data") 71 | -------------------------------------------------------------------------------- /dndme/commands/start_combat.py: -------------------------------------------------------------------------------- 1 | from dndme.commands import Command 2 | from dndme.commands import convert_to_int, convert_to_int_or_dice_expr 3 | from dndme.initiative import TurnManager 4 | 5 | 6 | class StartCombat(Command): 7 | 8 | keywords = ["start"] 9 | help_text = """{keyword} 10 | {divider} 11 | Summary: Begin combat turn management and prompt for initiative for all 12 | combatants. 13 | 14 | Usage: {keyword} 15 | """ 16 | 17 | def do_command(self, *args): 18 | combat = self.game.combat 19 | 20 | combat.tm = TurnManager() 21 | 22 | print("Enter initiative rolls or press enter to 'roll' automatically.") 23 | for monster in combat.monsters.values(): 24 | default_roll = 10 + monster.initiative_mod 25 | use_default = self.safe_input( 26 | f"Use default initiative ({default_roll}) for {monster.name} Y/N?", 27 | default="Y", 28 | ) 29 | if use_default.upper() == "Y": 30 | roll = default_roll 31 | else: 32 | roll_advice = ( 33 | f"1d20{monster.initiative_mod:+}" 34 | if monster.initiative_mod 35 | else "1d20" 36 | ) 37 | roll = self.safe_input( 38 | f"Initiative for {monster.name}", 39 | default=roll_advice, 40 | converter=convert_to_int_or_dice_expr, 41 | ) 42 | combat.tm.add_combatant(monster, roll) 43 | print(f"Added to turn order in {roll}\n") 44 | 45 | for character in combat.characters.values(): 46 | roll_advice = ( 47 | f"1d20{character.initiative_mod:+}" 48 | if character.initiative_mod 49 | else "1d20" 50 | ) 51 | roll = self.safe_input( 52 | f"Initiative for {character.name}", 53 | default=roll_advice, 54 | converter=convert_to_int_or_dice_expr, 55 | ) 56 | combat.tm.add_combatant(character, roll) 57 | print(f"Added to turn order in {roll}\n") 58 | 59 | print("\nBeginning combat with: ") 60 | for roll, combatants in combat.tm.turn_order: 61 | print(f"{roll}: {', '.join([x.name for x in combatants])}") 62 | 63 | combat.tm.turns = combat.tm.generate_turns() 64 | self.game.changed = True 65 | -------------------------------------------------------------------------------- /calendars/forgotten_realms.toml: -------------------------------------------------------------------------------- 1 | name = "Forgotten Realms" 2 | hours_in_day = 24 3 | minutes_in_hour = 60 4 | leap_year_rule = "year % 4 == 0" 5 | axial_tilt = 28.883333 6 | solar_days_in_year = 365.25 7 | 8 | default_day = 1 9 | default_month = "Hammer" 10 | default_year = 1488 11 | 12 | [months.hammer] 13 | name = "Hammer" 14 | alt_name = "Deepwinter" 15 | days = 30 16 | 17 | [months.midwinter] 18 | name = "Midwinter" 19 | days = 1 20 | 21 | [months.alturiak] 22 | name = "Alturiak" 23 | alt_name = "The Claw of Winter" 24 | days = 30 25 | 26 | [months.ches] 27 | name = "Ches" 28 | alt_name = "The Claw of the Sunsets" 29 | days = 30 30 | 31 | [months.tarsakh] 32 | name = "Tarsakh" 33 | alt_name = "The Claw of the Storms" 34 | days = 30 35 | 36 | [months.greengrass] 37 | name = "Greengrass" 38 | days = 1 39 | 40 | [months.mirtul] 41 | name = "Mirtul" 42 | alt_name = "The Melting" 43 | days = 30 44 | 45 | [months.kythorn] 46 | name = "Kythorn" 47 | alt_name = "The Time of Flowers" 48 | days = 30 49 | 50 | [months.flamerule] 51 | name = "Flamerule" 52 | alt_name = "Summertide" 53 | days = 30 54 | 55 | [months.midsummer] 56 | name = "Midsummer" 57 | days = 1 58 | 59 | [months.shieldmeet] 60 | name = "Shieldmeet" 61 | days = 0 62 | leap_year_days = 1 63 | 64 | [months.eleasis] 65 | name = "Eleasis" 66 | alt_name = "Highsun" 67 | days = 30 68 | 69 | [months.eleint] 70 | name = "Eleint" 71 | alt_name = "The Fading" 72 | days = 30 73 | 74 | [months.highharvestide] 75 | name = "Highharvestide" 76 | days = 1 77 | 78 | [months.marpenoth] 79 | name = "Marpenoth" 80 | alt_name = "Leaffall" 81 | days = 30 82 | 83 | [months.uktar] 84 | name = "Uktar" 85 | alt_name = "The Rotting" 86 | days = 30 87 | 88 | [months.moonfeast] 89 | name = "Moonfeast" 90 | alt_name = "The Feast of the Moon" 91 | days = 1 92 | 93 | [months.nightal] 94 | name = "Nightal" 95 | alt_name = "The Drawing Down" 96 | days = 30 97 | 98 | [seasons.spring_equinox] 99 | name = "Spring Equinox" 100 | month = "Ches" 101 | day = 19 102 | 103 | [seasons.summer_solstice] 104 | name = "Summer Solstice" 105 | month = "Kythorn" 106 | day = 20 107 | 108 | [seasons.autumn_equinox] 109 | name = "Autumn Equinox" 110 | month = "Eleint" 111 | day = 21 112 | 113 | [seasons.winter_solstice] 114 | name = "Winter Solstice" 115 | month = "Nightal" 116 | day = 20 117 | 118 | [moons.selune] 119 | name = "Selûne" 120 | period = 30.4375 121 | full_on = "1 Hammer 1488" -------------------------------------------------------------------------------- /calendars/forgotten_realms_post_1488.toml: -------------------------------------------------------------------------------- 1 | name = "Forgotten Realms (post 1488)" 2 | hours_in_day = 24 3 | minutes_in_hour = 60 4 | leap_year_rule = "year % 4 == 0" 5 | axial_tilt = 28.883333 6 | solar_days_in_year = 365.25 7 | 8 | default_day = 1 9 | default_month = "Hammer" 10 | default_year = 1488 11 | 12 | [months.hammer] 13 | name = "Hammer" 14 | alt_name = "Deepwinter" 15 | days = 30 16 | 17 | [months.midwinter] 18 | name = "Midwinter" 19 | days = 1 20 | 21 | [months.alturiak] 22 | name = "Alturiak" 23 | alt_name = "The Claw of Winter" 24 | days = 30 25 | 26 | [months.ches] 27 | name = "Ches" 28 | alt_name = "The Claw of the Sunsets" 29 | days = 30 30 | 31 | [months.tarsakh] 32 | name = "Tarsakh" 33 | alt_name = "The Claw of the Storms" 34 | days = 30 35 | 36 | [months.greengrass] 37 | name = "Greengrass" 38 | days = 1 39 | 40 | [months.mirtul] 41 | name = "Mirtul" 42 | alt_name = "The Melting" 43 | days = 30 44 | 45 | [months.kythorn] 46 | name = "Kythorn" 47 | alt_name = "The Time of Flowers" 48 | days = 30 49 | 50 | [months.flamerule] 51 | name = "Flamerule" 52 | alt_name = "Summertide" 53 | days = 30 54 | 55 | [months.midsummer] 56 | name = "Midsummer" 57 | days = 1 58 | 59 | [months.shieldmeet] 60 | name = "Shieldmeet" 61 | days = 0 62 | leap_year_days = 1 63 | 64 | [months.eleasis] 65 | name = "Eleasis" 66 | alt_name = "Highsun" 67 | days = 30 68 | 69 | [months.eleint] 70 | name = "Eleint" 71 | alt_name = "The Fading" 72 | days = 30 73 | 74 | [months.highharvestide] 75 | name = "Highharvestide" 76 | days = 1 77 | 78 | [months.marpenoth] 79 | name = "Marpenoth" 80 | alt_name = "Leaffall" 81 | days = 30 82 | 83 | [months.uktar] 84 | name = "Uktar" 85 | alt_name = "The Rotting" 86 | days = 30 87 | 88 | [months.moonfeast] 89 | name = "Moonfeast" 90 | alt_name = "The Feast of the Moon" 91 | days = 1 92 | 93 | [months.nightal] 94 | name = "Nightal" 95 | alt_name = "The Drawing Down" 96 | days = 30 97 | 98 | [seasons.spring_equinox] 99 | name = "Spring Equinox" 100 | month = "Greengrass" 101 | day = 1 102 | 103 | [seasons.summer_solstice] 104 | name = "Summer Solstice" 105 | month = "Midsummer" 106 | day = 1 107 | 108 | [seasons.autumn_equinox] 109 | name = "Autumn Equinox" 110 | month = "Marpenoth" 111 | day = 30 112 | 113 | [seasons.winter_solstice] 114 | name = "Winter Solstice" 115 | month = "Midwinter" 116 | day = 1 117 | 118 | [moons.selune] 119 | name = "Selûne" 120 | period = 30.4375 121 | full_on = "1 Hammer 1488" -------------------------------------------------------------------------------- /dndme/commands/set_condition.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | from dndme.commands import Command 3 | 4 | 5 | class SetCondition(Command): 6 | 7 | keywords = ["set"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Set a condition on a target, optionally for a duration 11 | 12 | Usage: {keyword} [ []] 13 | 14 | Examples: 15 | 16 | {keyword} Frodo prone 17 | {keyword} Aragorn smolder 3 18 | {keyword} Gandalf concentrating 1 minute 19 | {keyword} Gollum lucid 5 minutes 20 | """ 21 | conditions = [ 22 | "blinded", 23 | "charmed", 24 | "concentrating", 25 | "deafened", 26 | "dead", 27 | "exhausted", 28 | "frightened", 29 | "grappled", 30 | "incapacitated", 31 | "invisible", 32 | "paralyzed", 33 | "petrified", 34 | "poisoned", 35 | "prone", 36 | "restrained", 37 | "stunned", 38 | "unconscious", 39 | ] 40 | 41 | multipliers = { 42 | "turn": 1, 43 | "turns": 1, 44 | "round": 1, 45 | "rounds": 1, 46 | "minute": 10, 47 | "minutes": 10, 48 | "min": 10, 49 | } 50 | 51 | def get_suggestions(self, words): 52 | combat = self.game.combat 53 | if len(words) == 2: 54 | return combat.combatant_names 55 | elif len(words) == 3: 56 | return self.conditions 57 | elif len(words) == 5: 58 | return sorted(self.multipliers.keys()) 59 | 60 | def do_command(self, *args): 61 | if len(args) < 2: 62 | print("Need a combatant and condition.") 63 | return 64 | 65 | target_name = args[0] 66 | condition = args[1] 67 | duration = inf 68 | 69 | if len(args) >= 3: 70 | duration = int(args[2]) 71 | 72 | if len(args) >= 4: 73 | units = args[3] 74 | duration *= self.multipliers.get(units, 1) 75 | 76 | combat = self.game.combat 77 | 78 | target = combat.get_target(target_name) 79 | if not target: 80 | print(f"Invalid target: {target_name}") 81 | return 82 | 83 | if hasattr(target, "immune") and condition in target.immune: 84 | print( 85 | f"Cannot set condition '{condition}' on {target_name};" 86 | " target is immune." 87 | ) 88 | return 89 | 90 | target.set_condition(condition, duration=duration) 91 | print(f"Okay; set condition '{condition}' on {target_name}.") 92 | self.game.changed = True 93 | -------------------------------------------------------------------------------- /dndme/dice.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | 4 | dice_expr = re.compile(r"^(\d+)d(\d+)\+?(\-?\d+)?$") 5 | 6 | 7 | def roll_dice(times, sides, modifier=0, dice_mult=1, total_mult=1): 8 | """ 9 | Simulate a dice roll of XdY + Z. 10 | 11 | "Rolls" a die of Y sides X times, gets the sum, and adjusts it by an 12 | optional modifier. Pass either a dice multiplier or total multiplier 13 | to support critical hit damage for 5E or 2E/3E rules. 14 | 15 | Example usage: 16 | 17 | # Stats: 3d6 18 | >>> roll_dice(3, 6) 19 | # Saving throw: 1d20 20 | >>> roll_dice(1, 20) 21 | # Damage (longsword +1): 1d8 + 1 22 | >>> roll_dice(1, 8, modifier=1) 23 | # Damage (cursed longsword - 2): 1d8 - 2 24 | >>> roll_dice(1, 8, modifier=-2) 25 | # Damage (crit, 5E) 26 | >>> roll_dice(1, 8, dice_mult=2) 27 | # Damage (crit, 2E) 28 | >>> roll_dice(1, 8, total_mult=2) 29 | """ 30 | randint = random.randint 31 | dice_result = sum(map(lambda x: randint(1, sides), range(times))) 32 | return total_mult * (dice_mult * dice_result + modifier) 33 | 34 | 35 | def roll_dice_expr(value): 36 | """ 37 | Get a dice roll from a dice expression; i.e. a string like 38 | "3d6" or "1d8+1" 39 | """ 40 | m = dice_expr.match(value) 41 | 42 | if not m: 43 | raise ValueError(f"Invalid dice expression '{value}'") 44 | 45 | times, sides, modifier = m.groups() 46 | times = int(times) 47 | sides = int(sides) 48 | modifier = int(modifier or 0) 49 | return roll_dice(times, sides, modifier=modifier) 50 | 51 | 52 | def max_dice_expr(value, floor=None): 53 | """ 54 | Get the maximum value of a dice expression. 55 | """ 56 | m = dice_expr.match(value) 57 | 58 | if not m: 59 | raise ValueError(f"Invalid dice expression '{value}'") 60 | 61 | times, sides, modifier = m.groups() 62 | times = int(times) 63 | sides = int(sides) 64 | modifier = int(modifier or 0) 65 | calculated = (times * sides) + modifier 66 | if floor is not None: 67 | return max(calculated, floor) 68 | return calculated 69 | 70 | 71 | def min_dice_expr(value, floor=None): 72 | """ 73 | Get the minimum value of a dice expression. 74 | """ 75 | m = dice_expr.match(value) 76 | 77 | if not m: 78 | raise ValueError(f"Invalid dice expression '{value}'") 79 | 80 | times, sides, modifier = m.groups() 81 | times = int(times) 82 | sides = int(sides) 83 | modifier = int(modifier or 0) 84 | calculated = times + modifier 85 | if floor is not None: 86 | return max(calculated, floor) 87 | return calculated 88 | -------------------------------------------------------------------------------- /dndme/commands/adjust_clock.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dndme.commands import Command 3 | 4 | 5 | class AdjustClock(Command): 6 | 7 | keywords = ["clock", "time"] 8 | help_text = """{keyword} 9 | {divider} 10 | Summary: Set, adjust, or check the in-game time. 11 | 12 | Usage: {keyword} [