Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Dungeons & Dragons & Python: Epic Adventures wi...

Dungeons & Dragons & Python: Epic Adventures with Prompt-Toolkit and Friends (PyTN 2020 Edition)

Embark on an epic adventure through the twisty passageways of a Python application developed to help Dungeon Masters run Dungeons & Dragons sessions. You’ll be joined in your quest by mighty allies such as Prompt-Toolkit, Attrs, Click, and TOML as you brave the perils of application structure, command completion, dynamic plugin discovery, data modeling, turn tracking, and maybe even some good old-fashioned dice rolling. Treasure and glory await!

Presented at PyTennessee 2020

Avatar for Mike Pirnat

Mike Pirnat

March 07, 2020
Tweet

More Decks by Mike Pirnat

Other Decks in Programming

Transcript

  1. DUNGEONS & DRAGONS & PYTHON Epic Adventures with Prompt-Toolkit and

    Friends BY MIKE PIRNAT Twitter: @mpirnat Email: [email protected] PyTN 2020
  2. Preparing Encounters • Read an encounter • Look up the

    monsters • Copy stat blocks to an encounter sheet • Painstaking, time-consuming, repetitive!
  3. Preparing Encounters • Read an encounter • Look up the

    monsters • Copy stat blocks to an encounter sheet • Painstaking, time-consuming, repetitive! • A lot of work even for a shorter adventure • Doesn’t scale to a big campaign • Especially if it’s a “sandbox”
  4. Running Encounters • Everyone rolls for initiative (turn order) •

    Copy names onto a numbered list • Play goes from highest to lowest number • High to low cycle repeats • Can be a lot of extra rolling for the DM • Easy – and embarrassing – to lose track of whose turn it is!
  5. Prompt-Toolkit • Library for creating interactive command line and terminal

    applications in Python • Supports many features... - Getting & validating input - Styled output, colors, syntax highlighting - Auto suggestion & completion - Customizable key bindings - Multiline input - And more!
  6. Getting Started from prompt_toolkit import prompt while True: user_input =

    prompt("> ") print(f"You entered '{user_input}'")
  7. Getting Started from prompt_toolkit import PromptSession session = PromptSession() while

    True: user_input = session.prompt("> ") print(f"You entered '{user_input}'")
  8. Auto Suggestion from prompt_toolkit import PromptSession from prompt_toolkit.auto_suggest import AutoSuggestFromHistory

    session = PromptSession( auto_suggest=AutoSuggestFromHistory() ) while True: user_input = session.prompt("> ") print(f"You entered '{user_input}'")
  9. Auto Completion from prompt_toolkit.completion import WordCompleter words = ["roll", "start",

    "next", "stop", "quit"] completer = WordCompleter(words) session = PromptSession( completer=completer )
  10. Bottom Toolbar def bottom_toolbar(): return "ctl-d or ctl-c to exit"

    session = PromptSession( bottom_toolbar=bottom_toolbar )
  11. Bottom Toolbar: Style from prompt_toolkit.styles import Style Style = Style.from_dict({

    "bottom-toolbar": "#333333 bg:#ffcc00", }) session = PromptSession( bottom_toolbar=bottom_toolbar, style=style )
  12. TOML • Tom’s Obvious Minimal Language • Data serialization language

    • Designed for minimal config file format with obvious semantics • Alternative to YAML and JSON • Lets us define things in data without having to hard code them • Because it’s plain text, we can keep it in git, version it, branch it, etc.
  13. [Sariel] name = "Sariel" race = "Elf" cclass = "Ranger"

    level = 4 max_hp = 32 cur_hp = 32 ac = 16 [Sariel.senses] perception = 15 darkvision = 60
  14. [Sariel] name = "Sariel" race = "Elf" cclass = "Ranger"

    level = 4 max_hp = 32 cur_hp = 32 ac = 16 [Sariel.senses] perception = 15 darkvision = 60 { "Sariel": { "name": "Sariel", "race": "Elf", "cclass": "Ranger", "level": 4 "max_hp": 32, "cur_hp": 32, "ac": 16, "senses": { "perception": 15, "darkvision": 60, } }
  15. name = "goblin" mtype = "humanoid:goblinoid" max_hp = "2d6" str

    = 8 dex = 14 # etc... notes = """ Goblins are black-hearted, ... """ [skills] stealth = 6 [features.nimbleescape] name = "Nimble Escape" description = """The goblin can...""" [actions.scimitar] name = "Scimitar" description = """Melee Weapon Attack: +4 to hit, ..."""
  16. name = "Goblin Ambush" location = "Triboar Trail" notes =

    """ Four goblins are hiding in the woods, ... """ [groups.goblins] monster = "goblin" count = 4
  17. Attrs • Makes it easy to create classes without boilerplate

    • Spares us from meaningless __init__ methods! • When you want a data class that’s a little bit more
  18. from attr import attrs, attrib @attrs class Character: name =

    attrib(default="Alice") race = attrib(default="Human") cclass = attrib(default="Fighter") level = attrib(default=1) max_hp = attrib(default=10) cur_hp = attrib(default=10) ac = attrib(default=0)
  19. import toml def load_party(party_file): with open(party_file, 'r') as file_in: party

    = toml.load(file_in) return {x["name"]: Character(**x) for x in party.values()}
  20. Click • Helps write beautiful command line interfaces • Makes

    it super easy to wire up well-behaved parameters • Makes nice help output • Well-covered in other talks – go check one of those out!
  21. def main_loop(): while True: user_input = session.prompt("> ") print(f"You entered

    '{user_input}'") if __name__ == "__main__": main_loop()
  22. import click @click.command() @click.option("--party", help="Party file to load") def main_loop(party):

    settings = {"party_file": party} if settings.get("party_file"): characters = load_party(settings["party_file"]) ...
  23. import click DEFAULT_MONSTERS_DIR = 'monsters' DEFAULT_ENCOUNTERS_DIR = 'encounters' @click.command() @click.option('--party',

    help="Party file to load") @click.option('--monsters', default=DEFAULT_MONSTERS_DIR, ...) @click.option('--encounters', default=DEFAULT_ENCOUNTERS_DIR, ...) def main_loop(party, monsters, encounters): settings = { 'party_file': party, 'monsters_dir': monsters, 'encounters_dir': encounters, } ...
  24. import random def roll_dice(times, sides, modifier=0): dice_result = 0 for

    i in range(times): dice_result += random.randint(1, sides)
  25. import random def roll_dice(times, sides, modifier=0): dice_result = 0 for

    i in range(times): dice_result += random.randint(1, sides) return dice_result + modifier
  26. import re DICE_EXPR = re.compile(r"^(\d+)d(\d+)\+?(\-?\d+)?$") def roll_dice_expr(value): m = DICE_EXPR.match(value)

    if not m: raise ValueError(f"Invalid dice expression '{value}'") times, sides, modifier = m.groups()
  27. import re DICE_EXPR = re.compile(r"^(\d+)d(\d+)\+?(\-?\d+)?$") def roll_dice_expr(value): m = DICE_EXPR.match(value)

    if not m: raise ValueError(f"Invalid dice expression '{value}'") times, sides, modifier = m.groups() times, sides = int(times), int(sides) modifier = int(modifier or 0)
  28. import re DICE_EXPR = re.compile(r"^(\d+)d(\d+)\+?(\-?\d+)?$") def roll_dice_expr(value): m = DICE_EXPR.match(value)

    if not m: raise ValueError(f"Invalid dice expression '{value}'") times, sides, modifier = m.groups() times, sides = int(times), int(sides) modifier = int(modifier or 0) return roll_dice(times, sides, modifier=modifier)
  29. def main_loop(): ... while True: user_input = session.prompt("> ").split() if

    not user_input: continue command, args = user_input[0], user_input[1:]
  30. def main_loop(): ... while True: user_input = session.prompt("> ").split() if

    not user_input: continue command, args = user_input[0], user_input[1:] if command == "roll":
  31. def main_loop(): ... while True: user_input = session.prompt("> ").split() if

    not user_input: continue command, args = user_input[0], user_input[1:] if command == "roll": results = [str(roll_dice_expr(dice_expr)) for dice_expr in args]
  32. def main_loop(): ... while True: user_input = session.prompt("> ").split() if

    not user_input: continue command, args = user_input[0], user_input[1:] if command == "roll": results = [str(roll_dice_expr(dice_expr)) for dice_expr in args] print(", ".join(results))
  33. def main_loop(): ... while True: user_input = session.prompt("> ").split() if

    not user_input: continue command, args = user_input[0], user_input[1:] if command == "roll": results = [str(roll_dice_expr(dice_expr)) for dice_expr in args] print(", ".join(results)) else: print(f"Unknown command: {command}")
  34. Taking Turns with “Initiative” • D&D uses a system called

    “initiative” to manage whose turn it is • When an encounter starts, everyone rolls a d20 • Play proceeds in rounds from highest to lowest, and then repeats
  35. 20: Sariel 13: goblin1, goblin3 10: Lander, goblin4, Pip 5:

    goblin2 { 20: ["Sariel"], 13: ["goblin1", "goblin3"], 10: ["Lander", "goblin4", "Pip"], 5: ["goblin2"], }
  36. def generate_turns(): while initiative: round_number += 1 turn_order = list(reversed(sorted(initiative.items())))

    for initiative_value, combatants in turn_order: for combatant in combatants:
  37. def generate_turns(): while initiative: round_number += 1 turn_order = list(reversed(sorted(initiative.items())))

    for initiative_value, combatants in turn_order: for combatant in combatants: yield round_number, initiative_value, combatant
  38. >>> turns = generate_turns() >>> for i in range(100): next(turns)

    (1, 20, "Sariel"), (1, 13, "goblin1"), (1, 13, "goblin3"), ... (2, 20, "Sariel"), ... (42, 20, "Sariel"), ...
  39. def generate_turns(): while initiative: round_number += 1 turn_order = list(reversed(sorted(initiative.items())))

    for initiative_value, combatants in turn_order: for combatant in combatants: yield round_number, initiative_value, combatant { 20: ["Sariel"], 13: ["goblin1", "goblin3"], 10: ["Lander", "goblin4", "Pip"], 5: ["goblin2"], } WTH?
  40. def generate_turns(): while initiative: round_number += 1 turn_order = list(reversed(sorted(initiative.items())))

    for initiative_value, combatants in turn_order: for combatant in combatants[:]: if combatant not in combatants: continue yield round_number, initiative_value, combatant { 20: ["Sariel"], 13: ["goblin1", "goblin3"], 10: ["Lander", "goblin4", "Pip"], 5: ["goblin2"], } Yay!
  41. class TurnManager: def __init__(self): self.initiative = defaultdict(list) ... def add_combatant(self,

    combatant, initiative_value): ... def remove_combatant(self, combatant): ... def swap_combatants(self, combatant1, combatant2): ... def move_combatant(self, combatant, initiative_roll): ... def generate_turns(self): ...
  42. def main_loop(): ... while True: user_input = session.prompt("> ").split() command,

    args = user_input[:1], user_input[1:] elif command == "next":
  43. def main_loop(): ... while True: user_input = session.prompt("> ").split() command,

    args = user_input[:1], user_input[1:] elif command == "next": round_num, initiative, combatant = next(turns)
  44. def main_loop(): ... while True: user_input = session.prompt("> ").split() command,

    args = user_input[:1], user_input[1:] elif command == "next": round_num, initiative, combatant = next(turns) print(f"Round: {round_num} Init: {initiative} " f"Name: {combatant.name}")
  45. while True: user_input = session.prompt("> ").split() command, args = user_input[:1],

    user_input[1:] if command == "roll": ... elif command == "load": ... elif command == "start": ... elif command == "next": ... elif command == "end": ... ...
  46. Organization dice.py ← put the dice-rolling code here initiative.py ←

    put the turn-tracking code here models.py ← attrs classes here ... commands/ __init__.py ← put command base class here do_thing.py ← put a "do thing" command here next_turn.py ← put the turn-advancing command here roll_dice.py ← put the dice-rolling command here
  47. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw] = self
  48. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw] = self print(f"Registered {self.__class__.__name__}")
  49. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw]= self print(f"Registered {self.__class__.__name__}") def do_command(self, *args):
  50. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw]= self print(f"Registered {self.__class__.__name__}") def do_command(self, *args): print("Nothing happens.")
  51. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw]= self print(f"Registered {self.__class__.__name__}") def do_command(self, *args): print("Nothing happens.") def show_help_text(self, keyword):
  52. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw]= self print(f"Registered {self.__class__.__name__}") def do_command(self, *args): print("Nothing happens.") def show_help_text(self, keyword): print(self.help_text.strip())
  53. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw]= self print(f"Registered {self.__class__.__name__}") def do_command(self, *args): print("Nothing happens.") def show_help_text(self, keyword): if getattr(self, "help_text", None): print(self.help_text.strip()) else: print(f"No help text available for '{keyword}'")
  54. from dndme.commands import Command from dndme.dice import roll_dice_expr class RollDice(Command):

    keywords = ["roll", "dice"] help_text = """...""" def do_command(self, *args):
  55. from dndme.commands import Command from dndme.dice import roll_dice_expr class RollDice(Command):

    keywords = ["roll", "dice"] help_text = """...""" def do_command(self, *args): results = [str(roll_dice_expr(dice_expr)) for dice_expr in args] print(", ".join(results))
  56. from dndme.commands import Command class NextTurn(Command): keywords = ["next"] help_text

    = """...""" def do_command(self, *args): turn = next(self.game.turn_manager.turns) ...
  57. Great, now we have lots of classes... • We could

    manually import all those commands and register them when our program starts up... - but that’s really tiresome - and easy to forget to do when adding a new command • Let’s find and register them dynamically!
  58. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules:
  59. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules: if mod_name in sys.modules: continue
  60. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules: if mod_name in sys.modules: continue loaded_mod = importlib.import_module( f"dndme.commands.{mod_name}")
  61. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules: if mod_name in sys.modules: continue loaded_mod = importlib.import_module( f"dndme.commands.{mod_name}") class_name = "".join(x.title() for x in mod_name.split("_"))
  62. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules: if mod_name in sys.modules: continue loaded_mod = importlib.import_module( f"dndme.commands.{mod_name}") class_name = "".join(x.title() for x in mod_name.split("_")) loaded_class = getattr(loaded_mod, class_name, None)
  63. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules: if mod_name in sys.modules: continue loaded_mod = importlib.import_module( f"dndme.commands.{mod_name}") class_name = "".join(x.title() for x in mod_name.split("_")) loaded_class = getattr(loaded_mod, class_name, None) if not loaded_class: continue
  64. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path]) for loader, mod_name, ispkg in modules: if mod_name in sys.modules: continue loaded_mod = importlib.import_module(f"dndme.commands.{mod_name}") class_name = "".join(x.title() for x in mod_name.split("_")) loaded_class = getattr(loaded_mod, class_name, None) if not loaded_class: continue instance = loaded_class(game)
  65. while True: user_input = session.prompt("> ").split() command, args = user_input[:1],

    user_input[1:] if command == "roll": ... elif command == "load": ... elif command == "start": ... elif command == "next": ... elif command == "end": ... ...
  66. load_commands(game) while True: user_input = session.prompt("> ").split() command_name, args =

    user_input[:1], user_input[1:] command = game.commands.get(command_name)
  67. load_commands(game) while True: user_input = session.prompt("> ").split() command_name, args =

    user_input[:1], user_input[1:] command = game.commands.get(command_name) if command: command.do_command(*args)
  68. load_commands(game) while True: user_input = session.prompt("> ").split() command_name, args =

    user_input[:1], user_input[1:] command = game.commands.get(command_name) if command: command.do_command(*args) else: print(f"Unknown command '{command_name}'")
  69. from prompt_toolkit import WordCompleter words = ["roll", "start", "next", "stop",

    "quit"] completer = WordCompleter(words, match_middle=True) session = PromptSession( completer=completer, ... )
  70. from prompt_toolkit import WordCompleter words = list(game.commands.keys()) completer = WordCompleter(words,

    match_middle=True) session = PromptSession( completer=completer, ... )
  71. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): def __init__(self, commands,

    ignore_case=False, match_middle=False): self.commands = commands self.base_commands = sorted(list(commands.keys())) ...
  72. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): ... def get_completions(self,

    document, complete_event): # 1. Find out what's been typed so far # 2. Find out what we completions we *might* suggest # 3. Figure out if a completion is valid # 4. Yield each valid completion
  73. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): ... def get_completions(self,

    document, complete_event): # 1. Find out what's been typed so far word_before_cursor = document.get_word_before_cursor()
  74. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): ... def get_completions(self,

    document, complete_event): # 1. Find out what's been typed so far word_before_cursor = document.get_word_before_cursor() if self.ignore_case: word_before_cursor = word_before_cursor.lower()
  75. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = []
  76. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = [] document_text_list = document.text.split(" ")
  77. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = [] document_text_list = document.text.split(" ") if len(document_text_list) < 2: suggestions = self.base_commands
  78. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = [] document_text_list = document.text.split(' ') if len(document_text_list) < 2: suggestions = self.base_commands elif document_text_list[0] in self.commands:
  79. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = [] document_text_list = document.text.split(' ') if len(document_text_list) < 2: suggestions = self.base_commands elif document_text_list[0] in self.commands: command = self.commands[document_text_list[0]]
  80. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = [] document_text_list = document.text.split(' ') if len(document_text_list) < 2: suggestions = self.base_commands elif document_text_list[0] in self.commands: command = self.commands[document_text_list[0]] suggestions = command.get_suggestions(document_text_list)
  81. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... #

    3. Figure out if a completion is valid def word_matches(suggestion):
  82. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... #

    3. Figure out if a completion is valid def word_matches(suggestion): if self.ignore_case: suggestion = suggestion.lower()
  83. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... #

    3. Figure out if a completion is valid def word_matches(suggestion): if self.ignore_case: suggestion = suggestion.lower() if self.match_middle: return word_before_cursor in suggestion
  84. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... #

    3. Figure out if a completion is valid def word_matches(suggestion): if self.ignore_case: suggestion = suggestion.lower() if self.match_middle: return word_before_cursor in suggestion else: return suggestion.startswith(word_before_cursor)
  85. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... ...

    # 4. Yield each valid completion for suggestion in suggestions: if word_matches(suggestion):
  86. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... ...

    # 4. Yield each valid completion for suggestion in suggestions: if word_matches(suggestion): yield Completion(suggestion, start_position=-len(word_before_cursor))
  87. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... ...

    # 4. Yield each valid completion for word in suggestions: if word_matches(word): yield Completion(word, start_position=-len(word_before_cursor)) # All done -- did everyone pass their INT saves??
  88. from dndme.commands import Command class DamageCombatant(Command): keywords = ["hit", "damage"]

    def get_suggestions(self, words): def do_command(self, *args): ...
  89. from dndme.commands import Command class DamageCombatant(Command): keywords = ["hit", "damage"]

    def get_suggestions(self, words): names_already_chosen = words[1:] def do_command(self, *args): ...
  90. from dndme.commands import Command class DamageCombatant(Command): keywords = ["hit", "damage"]

    def get_suggestions(self, words): names_already_chosen = words[1:] return sorted(set(game.combatant_names) - set(names_already_chosen)) def do_command(self, *args): ...
  91. Links • Mike Pirnat: https://mike.pirnat.com or @mpirnat on Twitter •

    dndme: https://github.com/mpirnat/dndme • Prompt-Toolkit: https://python-prompt-toolkit.readthedocs.io • Attrs: https://www.attrs.org • Toml: https://pypi.org/project/toml/ • Click: https://click.palletsprojects.com
  92. Credits • Dungeon map on cover slide: https://www.tribality.com/2016/04/22/mapping-and- stocking-your-dungeon-using-randomly-generated-dungeons/ •

    Page textures by Jared Ondricek aka /u/flamableconcrete: https://imgur.com/a/OZt2m • Tavern: https://www.artstation.com/artwork/mLqVd • Python logo: https://www.python.org/community/logos/ • MacBook Pro image: https://www.apple.com/shop/buy-mac/macbook-pro • Underground Temple image: https://www.worldanvil.com/i/356027 • Guardian Naga original image: https://www.dndbeyond.com/monsters/guardian-naga