├── 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} [