Last active
February 14, 2025 00:52
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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