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

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

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

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