Dungeons & Dragons & Python: Epic Adventures with Prompt-Toolkit and Friends

E4c5e3c69566ff80db62a4ab521b6e5a?s=47 Mike Pirnat
October 05, 2019

Dungeons & Dragons & Python: Epic Adventures with Prompt-Toolkit and Friends

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 PyGotham 2019

E4c5e3c69566ff80db62a4ab521b6e5a?s=128

Mike Pirnat

October 05, 2019
Tweet

Transcript

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

    Friends BY MIKE PIRNAT Twitter: @mpirnat Email: mpirnat@gmail.com
  2. Prologue

  3. with a mysterious stranger... It begins at a tavern... ...who

    seeks your aid
  4. Preparing Encounters • Read an encounter • Look up the

    monsters • Copy stat blocks to an encounter sheet • Painstaking, time-consuming, repetitive!
  5. 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”
  6. 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!
  7. None
  8. None
  9. None
  10. None
  11. None
  12. One: Prompt-Toolkit

  13. 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!
  14. Getting Started from prompt_toolkit import prompt

  15. Getting Started from prompt_toolkit import prompt user_input = prompt("> ")

    print(f"You entered '{user_input}'")
  16. Getting Started from prompt_toolkit import prompt while True: user_input =

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

    True: user_input = session.prompt("> ") print(f"You entered '{user_input}'")
  18. None
  19. 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}'")
  20. None
  21. Auto Completion from prompt_toolkit.completion import WordCompleter

  22. Auto Completion from prompt_toolkit.completion import WordCompleter words = ["roll", "start",

    "next", "stop", "quit"]
  23. Auto Completion from prompt_toolkit.completion import WordCompleter words = ["roll", "start",

    "next", "stop", "quit"] completer = WordCompleter(words)
  24. Auto Completion from prompt_toolkit.completion import WordCompleter words = ["roll", "start",

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

  27. Bottom Toolbar def bottom_toolbar(): return "ctl-d or ctl-c to exit"

    session = PromptSession( bottom_toolbar=bottom_toolbar )
  28. None
  29. Bottom Toolbar def bottom_toolbar(): return "ctl-d or ctl-c to exit"

  30. Bottom Toolbar def bottom_toolbar(): return [( "class:bottom-toolbar", )]

  31. Bottom Toolbar def bottom_toolbar(): return [( "class:bottom-toolbar", "ctl-d or ctl-c

    to exit", )]
  32. Bottom Toolbar: Style from prompt_toolkit.styles import Style

  33. Bottom Toolbar: Style from prompt_toolkit.styles import Style style = Style.from_dict({

    })
  34. Bottom Toolbar: Style from prompt_toolkit.styles import Style style = Style.from_dict({

    "bottom-toolbar": "#333333 bg:#ffcc00", })
  35. 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 )
  36. None
  37. None
  38. None
  39. None
  40. Two: TOML + ATTRS + Click

  41. 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.
  42. [Sariel] name = "Sariel" race = "Elf" cclass = "Ranger"

    level = 4 max_hp = 32 cur_hp = 32 ac = 16 [Sariel.senses] perception = 15 darkvision = 60

  43. [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, } }
  44. 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, ..."""
  45. name = "Goblin Ambush" location = "Triboar Trail" notes =

    """ Four goblins are hiding in the woods, ... """ [groups.goblins] monster = "goblin" count = 4
  46. 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 • Has a more minimal syntax and a more verbose syntax (I prefer the latter)
  47. from attr import attrs, attrib

  48. from attr import attrs, attrib @attrs class Character:

  49. 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)
  50. from attr import Factory as attr_factory @attrs class Character: ...

    senses = attrib(default=attr_factory(dict))
  51. import toml

  52. import toml def load_party(party_file): with open(party_file, "r") as fin:

  53. import toml def load_party(party_file): with open(party_file, "r") as fin: party

    = toml.load(fin)
  54. import toml def load_party(party_file): with open(party_file, 'r') as fin: party

    = toml.load(fin) return {x["name"]: Character(**x) for x in party.values()}
  55. 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!
  56. while True: user_input = session.prompt("> ") print(f"You entered '{user_input}'")

  57. def main_loop(): while True: user_input = session.prompt("> ") print(f"You entered

    '{user_input}'") if __name__ == "__main__": main_loop()
  58. import click

  59. import click @click.command() def main_loop(): ...

  60. import click @click.command() @click.option("--party", help="Party file to load") def main_loop(party):

    ...
  61. import click @click.command() @click.option("--party", help="Party file to load") def main_loop(party):

    settings = {"party_file": party} ...
  62. 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"]) ...
  63. 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, } ...
  64. None
  65. None
  66. None
  67. None
  68. None
  69. None
  70. Three: Basic Commands

  71. Rolling Dice 1d6 2d10 3d4+3 1d8-1

  72. import random

  73. import random def roll_dice(times, sides, modifier=0):

  74. import random def roll_dice(times, sides, modifier=0): dice_result = 0

  75. import random def roll_dice(times, sides, modifier=0): dice_result = 0 for

    i in range(times):
  76. import random def roll_dice(times, sides, modifier=0): dice_result = 0 for

    i in range(times): dice_result += random.randint(1, sides)
  77. 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
  78. import random def roll_dice(times, sides, modifier=0): return sum(map(lambda x: random.randint(1,

    sides), range(times))) + modifier
  79. Rolling Dice 1d6 2d10 3d4+3 1d8-1

  80. import re

  81. import re DICE_EXPR = re.compile(r"^(\d+)d(\d+)\+?(\-?\d+)?$") How many dice How many

    sides Plus or minus anything?
  82. import re DICE_EXPR = re.compile(r"^(\d+)d(\d+)\+?(\-?\d+)?$") def roll_dice_expr(value): m = DICE_EXPR.match(value)

  83. 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}'")
  84. 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()
  85. 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)
  86. 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)
  87. def main_loop(): ... while True: user_input = session.prompt("> ")

  88. def main_loop(): ... while True: user_input = session.prompt("> ").split() command,

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

    not user_input: continue command, args = user_input[0], user_input[1:]
  90. 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":
  91. 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]
  92. 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))
  93. 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}")
  94. None
  95. 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
  96. 20: Sariel 13: goblin1, goblin3 10: Lander, goblin4, Pip 5:

    goblin2
  97. 20: Sariel 13: goblin1, goblin3 10: Lander, goblin4, Pip 5:

    goblin2
 { 20: ["Sariel"], 13: ["goblin1", "goblin3"], 10: ["Lander", "goblin4", "Pip"], 5: ["goblin2"], }
  98. from collections import defaultdict

  99. from collections import defaultdict initiative = defaultdict(list)

  100. from collections import defaultdict initiative = defaultdict(list) round_number = 0

  101. def add_combatant(combatant, initiative_value):

  102. def add_combatant(combatant, initiative_value): initiative[initiative_value].append(combatant)

  103. def remove_combatant(combatant):

  104. def remove_combatant(combatant): for combatants in initiative.values():

  105. def remove_combatant(combatant): for combatants in initiative.values(): combatants.remove(combatant)

  106. def remove_combatant(combatant): for combatants in initiative.values(): if combatant in combatants:

    combatants.remove(combatant)
  107. def generate_turns():

  108. def generate_turns(): while initiative:

  109. def generate_turns(): while initiative: round_number += 1

  110. def generate_turns(): while initiative: round_number += 1 turn_order = list(reversed(sorted(initiative.items())))

  111. def generate_turns(): while initiative: round_number += 1 turn_order = list(reversed(sorted(initiative.items())))

    for initiative_value, combatants in turn_order:
  112. 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:
  113. 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
  114. >>> turns = generate_turns()

  115. >>> turns = generate_turns() >>> for i in range(100):

  116. >>> turns = generate_turns() >>> for i in range(100): next(turns)

  117. >>> 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"), ...
  118. 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?
  119. 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!
  120. 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): ...
  121. def main_loop(): ... while True: user_input = session.prompt("> ").split() command,

    args = user_input[:1], user_input[1:] elif command == "next":
  122. 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)
  123. 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}")
  124. None
  125. None
  126. None
  127. None
  128. Four: Better Commands

  129. 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": ... ...
  130. 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
  131. class Command:

  132. class Command: keywords = ["command"]

  133. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game
  134. class Command: keywords = ["command"] def __init__(self, game): self.game =

    game for kw in self.keywords: game.commands[kw] = self
  135. 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__}")
  136. 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):
  137. 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.")
  138. 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):
  139. 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())
  140. 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}'")
  141. from dndme.commands import Command from dndme.dice import roll_dice_expr

  142. from dndme.commands import Command from dndme.dice import roll_dice_expr class RollDice(Command):

  143. from dndme.commands import Command from dndme.dice import roll_dice_expr class RollDice(Command):

    keywords = ["roll", "dice"]
  144. from dndme.commands import Command from dndme.dice import roll_dice_expr class RollDice(Command):

    keywords = ["roll", "dice"] help_text = """..."""
  145. 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):
  146. 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))
  147. from dndme.commands import Command class NextTurn(Command): keywords = ["next"] help_text

    = """...""" def do_command(self, *args): turn = next(self.game.turn_manager.turns) ...
  148. 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!
  149. import importlib, os, pkgutil, sys

  150. import importlib, os, pkgutil, sys def load_commands(game):

  151. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands")
  152. import importlib, os, pkgutil, sys def load_commands(game): path = os.path.join(os.path.dirname(__file__),

    "commands") modules = pkgutil.iter_modules(path=[path])
  153. 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:
  154. 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
  155. 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}")
  156. 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("_")])
  157. 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)
  158. 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
  159. 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)
  160. 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": ... ...
  161. load_commands(game) while True: user_input = session.prompt("> ").split() command, args =

    user_input[:1], user_input[1:]
  162. 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)
  163. 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)
  164. 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}'")
  165. None
  166. None
  167. None
  168. None
  169. Five: Custom Input Completion

  170. from prompt_toolkit import WordCompleter words = ["roll", "start", "next", "stop",

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

    match_middle=True) session = PromptSession( completer=completer, ... )
  172. completer = CommandCompleter(game.commands, ignore_case=True) session = PromptSession( completer=completer, ... )

  173. from prompt_toolkit.completion import Completer, Completion

  174. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer):

  175. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): def __init__(self, commands,

    ignore_case=False, match_middle=False):
  176. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): def __init__(self, commands,

    ignore_case=False, match_middle=False): self.commands = commands
  177. 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())) ...
  178. from prompt_toolkit.completion import Completer, Completion class CommandCompleter(Completer): ... def get_completions(self,

    document, complete_event):
  179. 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
  180. 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()
  181. 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()
  182. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... # 2.

    Find out what we completions we *might* suggest suggestions = []
  183. 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(" ")
  184. 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
  185. 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:
  186. 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]]
  187. 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)
  188. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... #

    3. Figure out if a completion is valid def word_matches(suggestion):
  189. 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()
  190. 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
  191. 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)
  192. class CommandCompleter(Completer): ... def get_completions(self, document, complete_event): ... ... ...

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

    # 4. Yield each valid completion for suggestion in suggestions: if word_matches(suggestion):
  194. 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))
  195. 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??
  196. class Command: ... def get_suggestions(self, words): return []

  197. from dndme.commands import Command class DamageCombatant(Command): keywords = ["hit", "damage"]

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

    def get_suggestions(self, words): def do_command(self, *args): ...
  199. 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): ...
  200. 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): ...
  201. None
  202. None
  203. None
  204. None
  205. The End ...for now

  206. Epilogue

  207. 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
  208. 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
  209. zapier.com/jobs

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

    Friends BY MIKE PIRNAT Twitter: @mpirnat Email: mpirnat@gmail.com