"""
A module containing useful functions for other modules in this DnD 5e combat project
"""
import random
import warnings
from typing import Tuple, Union, List
TYPE_ROLL_RESULT = Tuple[int, int]
TYPE_DICE_TUPLE = Tuple[int, int]
# table from https://5etools.com/crcalculator.html
CR_XP_TBL = {0: 10, 0.125: 25, 0.25: 50, 0.5: 100, 1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900,
8: 3900, 9: 5000, 10: 5900, 11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000, 16: 15000, 17: 18000,
18: 20000, 19: 22000, 20: 25000, 21: 33000, 22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000,
27: 105000, 28: 120000, 29: 135000, 30: 155000}
[docs]
def ability_to_mod(score: int) -> int:
"""
Convert an ability score to a modifier
:param score: the ability score to convert
:type score: integer between 1 and 30 (inclusive)
:return: the ability modifier
:rtype: int
:raise: ValueError if *score* is invalid
"""
if not isinstance(score, int):
raise ValueError("Ability score must be an integer")
if score < 1:
raise ValueError("Ability score too low (did you input a modifier?)")
if score > 30:
raise ValueError("Ability score too high (did you add an extra digit?)")
return (score - 10) // 2
[docs]
def validate_dice(dice) -> TYPE_DICE_TUPLE:
"""
Determine whether a given dice representation is valid. If it is, return the dice in tuple form
:param dice: the dice to validate
:type dice: a tuple of dice number and dice type (e.g., (1, 6)) or a string of format dicenum + "d" + dicetype (e.g., "1d6")
:return: the dice representation
:rtype: :py:const:`TYPE_DICE_TUPLE`
:raise: ValueError if *dice* is invalid
"""
if isinstance(dice, tuple): # lists are mutable and that could be a problem
if len(dice) != 2:
raise ValueError("Must provide exactly two values: number of dice and type of dice (1, 6)")
if not isinstance(dice[0], int) or not isinstance(dice[1], int) or dice[0] < 1 or dice[1] < 1:
raise ValueError("Must provide exactly two positive integer values: number of dice and type of dice (1, 6)")
elif isinstance(dice, str):
try:
dice = tuple(int(x) for x in dice.split("d"))
except ValueError:
raise ValueError("Must provide damage dice in tuple of two ints (1, 6) or string format 1d6")
if len(dice) != 2:
raise ValueError("Must provide damage dice in tuple of two ints (1, 6) or string format 1d6")
else:
raise ValueError("Must provide damage dice in tuple of two ints (1, 6) or string format 1d6")
return dice
# TODO: remove if unused
[docs]
def roll_dice(dice_type: int, num: int = 1, modifier: int = 0, adv: int = 0, critable: bool = False) -> TYPE_ROLL_RESULT:
"""
Roll dice (applying advantage as specified), add the modifier (but don't go below 0),
and return a result that includes the total number and an integer that indicates whether or not the roll was a crit
:param dice_type: the type of dice to roll (e.g., a d20)
:type dice_type: positive integer
:param num: the number of dice to roll
:type num: positive integer
:param modifier: the modifier to add after all dice have been rolled
:type modifier: int
:param adv: indicates whether this roll has advantage
:type adv: one of these integers: -1, 0, 1
:param critable: whether critical success and critical fail are possible
:type critable: bool
:return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not
:rtype: :py:const:`TYPE_ROLL_RESULT`
"""
roll_val = 0
crit = 0
if num > 1 and critable:
warnings.warn("Rolling multiple critable dice in one go is currently not supported. "
"Crit information will match the last die rolled.")
for i in range(num): # pylint: disable=unused-variable
nat_roll = random.randint(1, dice_type)
if adv:
roll_2 = random.randint(1, dice_type)
if adv > 0:
nat_roll = max(nat_roll, roll_2)
else:
nat_roll = min(nat_roll, roll_2)
crit = 0
if critable:
if nat_roll == dice_type:
crit = 1 # crit success
elif nat_roll == 1:
crit = -1 # crit fail. WARNING: -1 evaluates to true. Use this method appropriately.
roll_val += nat_roll
total_val = roll_val + modifier
total_val = max(total_val, 0)
return total_val, crit
[docs]
def cr_to_xp(cr: Union[float, int]) -> int:
"""
Convert a challenge rating to an xp value
:param cr: challenge rating
:type cr: float or int
:return: the xp value that corresponds to *cr*
:rtype: positive integer
:raise: ValueError if *cr* is not a valid challenge rating
"""
try:
return CR_XP_TBL[cr]
except KeyError:
raise ValueError("Invalid cr value")
[docs]
def calc_advantage(advs: Union[List[int], Tuple[int]]) -> int:
"""
Calculate advantage or disadvantage based on a list of advantage values
.. warning: does not sanitize input (i.e., assumes you gave a sequence of valid values). User must use appropriately
:param advs: a list or tuple of advantage values
:type advs: a list or tuple of integers
:return: a value to indicate advantage
:rtype: one of these integers: -1, 0, 1
"""
sum_val = 0
for adv in advs:
sum_val += adv
if sum_val == 0:
return 0
if sum_val > 0:
return 1
return -1
[docs]
def time_to_rounds(time_var) -> int: # pylint: disable=inconsistent-return-statements
"""
Convert a given time to a number of rounds
:param time_var: a variable that contains some kind of time
:type time_var: string, or tuple containing a positive integer and a string
:return: the number of rounds that corresponds to *time_var*
:rtype: non-negative integer
:raise: ValueError if *time_var* is invalid
"""
if isinstance(time_var, str):
if time_var == "instantaneous":
return 0
time_list = time_var.split(" ")
elif isinstance(time_var, (tuple, list)):
time_list = time_var
else:
raise ValueError("Must provide string (\"1 minute\") or tuple/list (1, \"minute\")")
try:
time = int(time_list[0])
if time <= 0:
raise ValueError()
except ValueError:
raise ValueError("Time must be a positive integer")
try:
if time_list[1] not in ["minute", "minutes", "hour", "hours", "round", "rounds"]:
raise ValueError("Unit must be minutes, hours, or rounds; e.g., 1 minute")
except IndexError:
raise ValueError("Must provide string (\"1 minute\") or tuple/list (1, \"minute\")")
if "round" in time_list[1]:
return time
if "minute" in time_list[1]:
return time * 10
if "hour" in time_list[1]:
return time * 600
raise ValueError("Something else went wrong")
[docs]
def proficiency_bonus_per_level(level: int) -> int:
"""
Calculate proficiency bonus based on level
:param level: a Character level
:type level: integer between 1 and 20 (inclusive)
:return: the proficiency bonus that corresponds to *level*
:rtype: positive integer
"""
if not isinstance(level, int) or level <= 0 or level > 20:
raise ValueError("Level must be an integer between 1 and 20")
return (level - 1) // 4 + 2
[docs]
def proficency_bonus_by_cr(cr: int) -> int:
"""
Calculate proficiency bonus based on challenge rating
:param cr: challenge rating
:type cr: non-negative number
:return: the proficiency bonus that corresponds to *cr*
:rtype: positive integer
"""
if not isinstance(cr, (int, float)) or cr < 0:
raise ValueError("cr must be a non-negative number")
if cr < 5:
return 2
return 3 + (cr - 5) // 4
[docs]
def ability_from_abbreviation(name: str) -> str: # pragma: no cover
"""
Get the full ability score name given an abbreviation
:param name: the abbreviated ability score name
:type name: one of these strings: "str", "dex", "con", "int", "wis", "cha"
:return: the full ability score name that corresponds to *name*
:rtype: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"
:raise: ValueError if *name* is invalid
"""
if name == "str":
return "strength"
if name == "dex":
return "dexterity"
if name == "con":
return "constitution"
if name == "int":
return "intelligence"
if name == "wis":
return "wisdom"
if name == "cha":
return "charisma"
raise ValueError("Unknown abbreviation")
[docs]
class NullLogger:
"""
For when you don't care about the logging results and you don't want multiprocessing runs to crash and burn
"""
def __init__(self):
pass
[docs]
def debug(self, msg, *args, **kwargs):
pass
[docs]
def info(self, msg, *args, **kwargs):
pass
[docs]
def warning(self, msg, *args, **kwargs):
pass
[docs]
def error(self, msg, *args, **kwargs):
pass
[docs]
def critical(self, msg, *args, **kwargs):
pass
[docs]
def log(self, lvl, msg, *args, **kwargs):
pass
[docs]
def exception(self, msg, *args, **kwargs):
pass