Skip to content

Instantly share code, notes, and snippets.

@HacKanCuBa
Last active February 14, 2025 00:52
Show Gist options
  • Save HacKanCuBa/bdbca381d2e7b7642aa43c478c61a0e0 to your computer and use it in GitHub Desktop.
Save HacKanCuBa/bdbca381d2e7b7642aa43c478c61a0e0 to your computer and use it in GitHub Desktop.
Simulate picking a lock in DnD using a minigame based on Bob World Builder's Lockpickery rules
"""DnD Lockpickery Simulator.
Simulate picking a lock in DnD using a minigame based on Bob World Builder's Lockpickery rules.
These rules have been modified by HacKan.
CC0 - Credit appreciated.
by HacKan 2025 - https://hackan.net
Requirements:
- Python 3.12+
"""
import argparse
import logging
import logging.config
import statistics
import sys
from collections.abc import Iterable
from enum import StrEnum, auto
from functools import partial
from math import ceil, floor
from secrets import randbelow
from typing import Final, NamedTuple
D6: Final = 6
D20: Final = 20
DIE: Final = D6 # Use a d6 by default
DICE_CHARS: Final = ('βš€', '⚁', 'βš‚', 'βšƒ', 'βš„', 'βš…')
DC_LOCK_DAMAGES: Final = 9
SIMULATION_RUNS: Final = 10_000
SUCCESS_PROBABILITY_EMOJIS: Final = ('πŸ’€', 'πŸ₯–', 'πŸ“ˆ', 'πŸ™Œ', 'πŸŽ‰')
# We spread the emojis every 0.2, starting on 0.1, to represent success probability
SUCCESS_PROBABILITY_MIN: Final = 0.1
SUCCESS_PROBABILITY_STEP: Final = 0.2
MAIN_LOGGER_NAME: Final = __name__
COMPUTE_LOGGER_NAME: Final = f'{__name__}.com'
SIMULATE_LOGGER_NAME: Final = f'{__name__}.sim'
class CustomFormatter(logging.Formatter):
"""Custom logging formatter to show some emojis conditionally on the log level."""
def format(self, record: logging.LogRecord) -> str:
assert self._style.default_format.startswith('%')
if record.levelno == logging.ERROR:
self._style._fmt = 'β›” %(message)s β›”'
elif record.levelno == logging.WARNING:
self._style._fmt = '⚠️ %(message)s'
else:
self._style._fmt = '%(message)s'
return super().format(record)
class Mode(StrEnum):
COMPUTE = auto()
SIMULATE = auto()
class SkillProficiency(StrEnum):
NONE = auto()
PROFICIENT = auto()
EXPERT = auto()
class Config(NamedTuple):
complexity: int
proficiency: SkillProficiency
total_rerolls: int
required_successes: int
failures_leaves_marks_cutoff: int
failures_damages_lock_cutoff: int
dc_lock_damages: int
class SimulationResult(NamedTuple):
opened: bool
damaged: bool
marked: bool
minutes: int
def main() -> None:
logging.config.dictConfig(
{
'version': 1,
'formatters': {
'custom': {
'format': '%(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S',
'style': '%',
'class': f'{__name__}.CustomFormatter',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'custom',
},
},
'loggers': {
MAIN_LOGGER_NAME: {
'handlers': ['console'],
'level': logging.INFO,
},
SIMULATE_LOGGER_NAME: {
'handlers': ['console'],
'level': logging.INFO,
'propagate': False,
},
COMPUTE_LOGGER_NAME: {
'handlers': ['console'],
'level': logging.INFO,
'propagate': False,
},
},
},
)
mlogger = logging.getLogger(MAIN_LOGGER_NAME)
clogger = logging.getLogger(COMPUTE_LOGGER_NAME)
slogger = logging.getLogger(SIMULATE_LOGGER_NAME)
mlogger.info('DnD Lockpickery Simulator')
mlogger.info('')
args = parse_args()
mode = args.mode
complexity = args.complexity
proficiency = args.proficiency
skill = args.skill
thieves_tools = args.tools
runs = args.runs
if complexity < 1 or complexity > 6:
mlogger.error('complexity should be between 1 and 6 inclusively')
raise SystemExit(1)
if mode == Mode.COMPUTE:
if runs < 1:
mlogger.error('runs should be bigger than or equal to 1')
raise SystemExit(1)
elif runs > SIMULATION_RUNS:
mlogger.warning('The computation may take a long time due to high number of runs')
elif runs < SIMULATION_RUNS / 10:
mlogger.warning('The computation may be very inexact due to low number of runs')
if mode == Mode.SIMULATE:
clogger.setLevel(logging.CRITICAL) # Turn it off
simulate_lockpickery(
complexity=complexity,
proficiency=proficiency,
skill=skill,
thieves_tools=thieves_tools,
)
elif mode == Mode.COMPUTE:
slogger.setLevel(logging.CRITICAL) # Turn it off
compute_probabilities(
complexity=complexity,
proficiency=proficiency,
skill=skill,
thieves_tools=thieves_tools,
runs=runs,
)
else:
raise NotImplementedError(f'unknown mode: {mode!r}')
def parse_args() -> argparse.Namespace:
"""Parse CLI arguments."""
parser = argparse.ArgumentParser(
prog='lockpickery',
description=(
"Simulate or compute probabilities of a lock picking attempt using a minigame based "
+ "on Bob World Builder's Lockpickery rules, modified by HacKan."
),
)
parser.add_argument(
'mode',
type=Mode,
choices=tuple(Mode),
default=Mode.SIMULATE,
nargs='?',
help='Select operation mode (defaults to simulate).',
)
parser.add_argument(
'runs',
type=int,
default=SIMULATION_RUNS,
nargs='?',
help=f'Indicate number of runs to compute probabilities (defaults to {SIMULATION_RUNS}).',
)
parser.add_argument(
'--complexity',
'-c',
type=int,
required=True,
help='Lock complexity.',
)
parser.add_argument(
'--proficiency',
'-p',
type=SkillProficiency,
choices=tuple(SkillProficiency),
default=SkillProficiency.NONE,
help=f"Character's skill proficiency (defaults to none).",
)
parser.add_argument(
'--skill',
'-s',
type=int,
default=0,
help=(
"Character's skill total (including proficiency/expertise bonus) (only relevant "
+ "for a proficient/expert character) (defaults to 0)."
),
)
parser.add_argument(
'--tools',
default=False,
action=argparse.BooleanOptionalAction,
help="Indicate if the character uses Thieves' Tools (defaults to no).",
)
args = parser.parse_args()
return args
def simulate_lockpickery(
*,
complexity: int,
proficiency: SkillProficiency,
skill: int,
thieves_tools: bool,
) -> None:
"""Simulate picking a lock using a minigame based on Bob World Builer's Lockpickery rules.
Keyword Args:
complexity: Lock's complexity (based on its DC).
proficiency: Character's proficiency level.
skill: Character's skill including all bonus if any.
thieves_tools: Indicate if the character is using Thieves' Tools, or not.
"""
config = do_setup(
complexity=complexity,
proficiency=proficiency,
skill=skill,
thieves_tools=thieves_tools,
)
if config is None:
return
run_simulation(config)
def compute_probabilities(
*,
complexity: int,
proficiency: SkillProficiency,
skill: int,
thieves_tools: bool,
runs: int,
) -> None:
"""Compute the probability of successfully picking a lock.
This considers using a minigame based on Bob World Builer's Lockpickery rules.
The probabilities are computed by running simulations over uniform random data (Monte Carlo).
Keyword Args:
complexity: Lock's complexity (based on its DC).
proficiency: Character's proficiency level.
skill: Character's skill including all bonus if any.
thieves_tools: Indicate if the character is using Thieves' Tools, or not.
"""
config = do_setup(
complexity=complexity,
proficiency=proficiency,
skill=skill,
thieves_tools=thieves_tools,
)
if config is None:
return
logger = logging.getLogger(COMPUTE_LOGGER_NAME)
logger.info('')
logger.info('Computing πŸ€–...')
logger.info('')
successes = []
minutes = []
damages = []
marks = []
for _ in range(runs):
result = run_simulation(config)
successes.append(result.opened)
minutes.append(result.minutes)
damages.append(result.damaged)
marks.append(result.marked)
p_success = sum(successes) / runs
p_damaged = sum(damages) / runs
p_marks = sum(marks) / runs
minutes_mean = statistics.fmean(
minutes,
weights=[0.6 if success else 0.4 for success in successes],
)
emoji = get_success_probability_emoji(p_success)
logger.info('Probability of picking the lock: %.2f%% %s', p_success * 100, emoji)
logger.info(
'Probability of visibly damage the lock so that not even the key would work: %.2f%% πŸ’₯',
p_damaged * 100,
)
logger.info('Probability of leaving marks on the lock: %.2f%% 🚨', p_marks * 100)
logger.info(
'The character would have taken around %s πŸ•°οΈ',
human_readable_time(floor(minutes_mean)),
)
def run_simulation(config: Config, /) -> SimulationResult:
"""Run a simulation of a charcater picking a lock.
This considers using a minigame based on Bob World Builer's Lockpickery rules.
Args:
config: A simulation configuration object.
Returns:
An object representing the simulation result, indicating success, marks, damaged, etc.
"""
logger = logging.getLogger(SIMULATE_LOGGER_NAME)
logger.info('')
logger.info('Simulating πŸ€–...')
successes = 0
failures = 0
failures_saved = 0
minutes = 0
attempts = 0
marks = 0
damaged = False
rerolls = config.total_rerolls
dc_lock_damages = config.dc_lock_damages
while not damaged and successes < config.required_successes:
attempts += 1
logger.info('')
logger.info('Attempt #%d', attempts)
rolled = roll_for_complexity(config.complexity)
while True:
logger.info('Current roll: %s', ' '.join(human_readable_roll(rolled)))
failed = [idx for idx, die in enumerate(rolled) if die == 1]
if failed:
# It makes no sense to spend rerolls if we can't reroll all dice
failed_dice = len(failed)
if rerolls >= failed_dice:
logger.info('Failure ❓')
failures_saved += 1
logger.info('Rerolling 🎲...')
rerolls -= failed_dice
logger.info('Using %d rerolls (%d rerolls left)', failed_dice, rerolls)
rerolled = roll_for_failures(failed_dice)
logger.info('Reroll: %s', ' '.join(human_readable_roll(rerolled)))
for idx, reroll in zip(failed, rerolled, strict=True):
rolled[idx] = reroll
else:
logger.warning('Failure 🚫')
successes = 0
failures += 1
if failures > config.failures_damages_lock_cutoff:
if roll(D20) < dc_lock_damages:
logger.warning('Oh no, the lock is damaged! πŸ’₯')
damaged = True
break
dc_lock_damages += 1
if failures > config.failures_leaves_marks_cutoff and roll_for_marks(
config.proficiency
):
logger.warning('Oh no, the lock has been scratched! 〽️')
marks += 1
logger.warning('Lock reset πŸ”’')
break
else:
logger.info('Success πŸ—οΈ')
successes += 1
break
time_taken = get_time_taken_for_proficiency(
config.proficiency,
roll=rolled,
complexity=config.complexity,
)
logger.info('Attempt took %d minutes πŸ•°οΈ', time_taken)
minutes += time_taken
logger.info('Current successes πŸ—οΈ: %d / %d', successes, config.required_successes)
logger.info('')
logger.info('Simulation done 🏁')
logger.info('')
if closed := (damaged or successes < config.required_successes):
logger.info('Lock remains closed πŸ”’')
else:
logger.info('Lock picked successfully πŸ”“')
if damaged:
logger.info('The lock is visibly damaged πŸ’₯')
logger.info('Not even the key will work now ❌')
elif marks:
logger.info('The lock has visible marks of picking attempts 🚨')
logger.info('')
logger.info('Statistics πŸ“ˆ')
logger.info('')
logger.info('The character took %s in total πŸ•°οΈ', human_readable_time(minutes))
logger.info('After %d attempts πŸ”¨', attempts)
logger.info('Having %d failures 🚫', failures)
logger.info('And %d marks/scratches on the lock 〽️', marks)
if config.proficiency != SkillProficiency.NONE:
logger.info('Also %d failures saved πŸ—Έ', failures_saved)
logger.info('With %d rerolls left out of %d 🎲', rerolls, config.total_rerolls)
return SimulationResult(
opened=not closed,
damaged=damaged,
marked=bool(marks),
minutes=minutes,
)
def do_setup(
*,
complexity: int,
proficiency: SkillProficiency,
skill: int,
thieves_tools: bool,
) -> Config | None:
assert 1 <= complexity <= 6
mlogger = logging.getLogger(MAIN_LOGGER_NAME)
slogger = logging.getLogger(SIMULATE_LOGGER_NAME)
clogger = logging.getLogger(COMPUTE_LOGGER_NAME)
slogger.info('Simulating picking a lock of complexity %d πŸ€–', complexity)
clogger.info('Computing probabilities of picking a lock of complexity %d πŸ— ', complexity)
mlogger.info('Character skill (including proficiency/expertise): %d 🀺', skill)
if thieves_tools:
mlogger.info("The character has Thieves' Tools πŸ› οΈ")
elif get_thieves_tools_required_for_complexity(complexity):
mlogger.info('')
slogger.info('No simulation required βœ…')
clogger.info('No computation required βœ…')
mlogger.info("The character needs Thieves' Tools to pick this lock πŸ› οΈ")
slogger.info('Lock remains closed πŸ”’')
clogger.info('Probability of picking the lock: 0% 🚫')
return None
if proficiency == SkillProficiency.NONE:
total_rerolls = 0
mlogger.info('The character does not have rerolls due to lack of proficiency 🎲')
else:
mlogger.info('The character is %s πŸ’ͺ', proficiency)
if skill > 0:
total_rerolls = skill
mlogger.info('And the character has %d rerolls 🎲', total_rerolls)
else:
total_rerolls = 0
mlogger.info('But the character does not have rerrols due to low skill πŸ“‰')
if complexity < 3:
# Proficient and Expert don't need roll for complexity 1.
# Expert doesn't need roll for complexity 2.
# Proficient neds roll for complexity 2 unless it has thieves' tools.
if proficiency == SkillProficiency.EXPERT or complexity == 1 or thieves_tools:
minutes = complexity if proficiency == SkillProficiency.PROFICIENT else 1
mlogger.info('')
slogger.info('No simulation required βœ…')
slogger.info('Lock picked successfully πŸ”“')
slogger.info('Lock picking took %s πŸ•°οΈ', human_readable_time(minutes))
clogger.info('No computation required βœ…')
clogger.info('Probability of picking the lock: 100% βœ…')
clogger.info('The character would take %s πŸ•°οΈ', human_readable_time(minutes))
return None
return Config(
complexity=complexity,
proficiency=proficiency,
total_rerolls=total_rerolls,
required_successes=get_successes_required_for_complexity(complexity),
failures_leaves_marks_cutoff=get_marks_at_min_failures(complexity),
failures_damages_lock_cutoff=get_tolerated_failures(complexity),
dc_lock_damages=DC_LOCK_DAMAGES,
)
def get_success_probability_emoji(probability: float, /) -> str:
max_idx = len(SUCCESS_PROBABILITY_EMOJIS) - 1
idx = min(ceil((probability - SUCCESS_PROBABILITY_MIN) / SUCCESS_PROBABILITY_STEP), max_idx)
return SUCCESS_PROBABILITY_EMOJIS[idx]
def get_thieves_tools_required_for_complexity(complexity: int, /) -> bool:
return complexity > 2
def get_successes_required_for_complexity(complexity: int, /) -> int:
# Great design! :D
return complexity
def get_marks_at_min_failures(complexity: int, /) -> int:
return randbelow(complexity) + 1
def get_tolerated_failures(complexity: int, /) -> int:
match complexity:
case 1:
lbound, ubound = 5, 10
case 2:
lbound, ubound = 5, 15
case 3:
lbound, ubound = 10, 20
case 4:
lbound, ubound = 10, 25
case 5:
lbound, ubound = 15, 30
case 6:
lbound, ubound = 15, 35
case _:
raise NotImplementedError(f'complexity not implemented: {complexity}')
return randbelow(ubound - lbound) + lbound
def roll_for_complexity(complexity: int, /) -> list[int]:
dice = get_number_of_dice_for_complexity(complexity)
return rolls(DIE, amount=dice)
def rolls(die: int, *, amount: int) -> list[int]:
return [roll(die) for _ in range(amount)]
def roll(die: int, /) -> int:
return randbelow(die) + 1
def human_readable_roll(roll: Iterable[int], /) -> list[str]:
return [DICE_CHARS[die - 1] for die in roll]
def get_number_of_dice_for_complexity(complexity: int, /) -> int:
# Seriously, this is very clever!
return complexity * 2
def roll_for_failures(failures: int) -> list[int]:
return rolls(DIE, amount=failures)
def roll_for_marks(proficiency: SkillProficiency, /) -> bool:
rolled = roll(D20)
if proficiency == SkillProficiency.NONE:
return rolled < 12
if proficiency == SkillProficiency.PROFICIENT:
return rolled < 10
assert proficiency == SkillProficiency.EXPERT
return rolled < 8
def get_time_taken_for_proficiency(
proficiency: SkillProficiency,
*,
roll: Iterable[int],
complexity: int,
) -> int:
dice = get_number_of_dice_for_complexity(complexity)
minutes = float(sum(roll) - dice)
if proficiency == SkillProficiency.NONE:
minutes /= 2
if proficiency == SkillProficiency.PROFICIENT:
minutes /= 4
elif proficiency == SkillProficiency.EXPERT:
minutes /= 8
return floor(minutes)
def human_readable_time(minutes: int) -> str:
if minutes == 1:
return f'{minutes} minute'
if minutes < 60:
return f'{minutes} minutes'
days = floor(minutes / (60 * 24))
hours = floor((minutes - (days * 60 * 24)) / 60)
minutes -= (days * 60 * 24) + (hours * 60)
hours_word = 'hours' if hours > 1 else 'hour'
if days:
days_word = 'days' if days > 1 else 'day'
return f"{days} {days_word}, {hours} {hours_word} and {minutes} minutes"
return f'{hours} {hours_word} and {minutes} minutes'
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment