Source code for DnD_5e.dice

import warnings
import random
from collections import namedtuple, Counter
from copy import copy, deepcopy
from typing import Tuple, List
from itertools import product
from nltk.probability import FreqDist

DICE_TUPLE = Tuple[int, int]  # pylint: disable=invalid-name
TYPE_ROLL_RESULT = Tuple[int, int]  # pylint: disable=invalid-name

[docs] class Dice: """ This class represents a die """ __hash__ = None # Tell the interpreter that instances of this class cannot be hashed def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param dice_num: the number of dice :type dice_num: positive integer :param dice_type: the number of faces on the die :type dice_type: positive integer :param dice_tuple: a tuple of format *dice_num*, *dice_type* :type dice_tuple: tuple of 2 positive integers :param str_val: a string representation; e.g., "1d6" :type str_val: str :param modifier: modifier to rolls with this die :type modifier: int :param adv: indicates the advantage the die rolls with (positive is advantage, negative is disadvantage, 0 is neither) :type adv: int :param critable: indicates whether the dice can roll a critical success or critical fail :type critable: bool :raise: ValueError if input is invalid """ self._dice_tuple = kwargs.get("str_val") if self._dice_tuple: try: self._dice_tuple = tuple(int(x) for x in self._dice_tuple.split("d")) except ValueError: raise ValueError("Must provide dice as tuple of two ints dice_tuple=(1, 6) " "or dice_num=1 and dice_type=6 or string format str_val=1d6") else: self._dice_tuple = kwargs.get("dice_tuple", (kwargs.get("dice_num", 1), kwargs.get("dice_type"))) try: if len(self._dice_tuple) != 2: raise ValueError("Must provide dice as tuple of exactly two ints dice_tuple=(1, 6) " "or dice_num=1 and dice_type=6 or string format str_val=1d6") except TypeError: raise ValueError("Must provide dice as tuple of exactly two ints dice_tuple=(1, 6) " "or dice_num=1 and dice_type=6 or string format str_val=1d6") if not isinstance(self._dice_tuple[0], int) or not isinstance(self._dice_tuple[1], int) \ or self._dice_tuple[0] < 0 or self._dice_tuple[1] < 0: raise ValueError("Must provide dice as tuple of two non-negative integers dice_tuple=(1, 6) " "or dice_num=1 and dice_type=6 or string format str_val=1d6") self._modifier = kwargs.get("modifier", 0) if not isinstance(self._modifier, int): raise ValueError("Modifier must be an int") self._adv = kwargs.get("adv", 0) if not isinstance(self._adv, int): raise ValueError("Adv must be an int") self._critable = bool(kwargs.get("critable")) self._freqdist = None self.create_probdist() def __eq__(self, other) -> bool: """ Compare *self* and *other* to determine if they are equal based on the following characteristics: dice_tuple, critable, modifier :param other: the Dice to be compared :type other: Dice :return: True if *self* equals *other*, False otherwise :rtype: bool """ if other is self: return True if type(other) != type(self): # pylint: disable=unidiomatic-typecheck return False return self.get_dice_tuple() == other.get_dice_tuple() \ and self.get_critable() == other.get_critable() \ and self.get_adv() == other.get_adv() \ and self.get_modifier() == other.get_modifier()
[docs] def get_dice_tuple(self) -> DICE_TUPLE: """ :return: self._dice_tuple :rtype: tuple of positive integers (dice num, dice type) """ return self._dice_tuple
[docs] def get_dice_num(self) -> int: """ :return: dice num (the first part of the dice tuple) :rtype: positive integer """ return self.get_dice_tuple()[0]
[docs] def get_dice_type(self) -> int: """ :return: dice type (the second part of the dice tuple) :rtype: positive integer """ return self.get_dice_tuple()[1]
[docs] def get_modifier(self) -> int: """ :return: modifier :rtype: int """ return self._modifier
[docs] def get_adv(self) -> int: """ :return: adv :rtype: int """ return self._adv
[docs] def get_critable(self) -> bool: """ :return: True if a critical roll makes sense, False otherwise :rtype: bool """ return self._critable
[docs] def get_max_value(self) -> int: """ :return: the maximum number that could result from a roll :rtype: int """ return self.get_dice_num() * self.get_dice_type() + self.get_modifier()
[docs] def get_min_value(self) -> int: """ :return: the minimum number that could result from a roll :rtype: int """ return self.get_dice_num() + self.get_modifier()
[docs] def get_average_value(self) -> float: """ Get the average value of the dice .. Note:: this assumes a fair die (i.e., each number the die can roll, or outcome, is equally likely) if self._adv is 0 (i.e., if the dice does not have advantage or disadvantage). If self._adv is not 0, there is a small error margin (less than 10^-16 for a d20) due to floating point arithmetic Formula and explanation found at https://anydice.com/articles/dice-and-averages/ :return: the average number that would result from a roll :rtype: float """ if self.get_adv() == 0: # in this case, all outcomes are equally likely dice_num = self.get_dice_num() dice_type = self.get_dice_type() result = (dice_num + (dice_num * dice_type)) / 2 result += self.get_modifier() else: result = 0 for num in range(self.get_min_value(), self.get_max_value() + 1): result += num * self.get_freq(num) result /= self.get_freqdist().N() return result
[docs] def get_freqdist(self) -> FreqDist: """ :return: the probability distribution for the rolls of this dice :rtype: FreqDist """ return self._freqdist
[docs] def get_freq(self, sample: int) -> int: """ Get the number of possible outcomes (roll combinations) that give a desired result :param sample: a number that could be rolled on this dice :type sample: int :return: the number of possible outcomes (roll combinations) that give a result of *sample* """ return self.get_freqdist()[sample]
[docs] def get_prob(self, sample: int, kind: str = "eq") -> float: """ Get the probability of rolling a given number on this dice. :param sample: a number that could be rolled on this dice :param kind: eq (equal), lt (less than), le (less than or equal to), gt, or ge. :return: the probability of rolling *sample* :rtype: float """ prob = 0 if kind == "eq": prob = self.get_freqdist().freq(sample) else: if kind == "lt": min_val = self.get_min_value() max_val = sample elif kind == "le": min_val = self.get_min_value() max_val = sample + 1 elif kind == "gt": min_val = sample + 1 max_val = self.get_max_value() + 1 else: # kind == "ge" min_val = sample max_val = self.get_max_value() + 1 for num in range(min_val, max_val): prob += self.get_freq(num) # count the number of desired roll outcomes prob /= self.get_freqdist().N() # divide by the number of possible roll outcomes return prob
[docs] def create_probdist(self): """ Create the dict that stores the (numerators for) probability of getting a certain result from the dice """ self._freqdist = FreqDist() possible_nums = list(range(1, self.get_dice_type() + 1)) rep = self.get_dice_num() if self.get_adv() != 0: # for advantage or disadvantage, roll an extra die rep *= 2 # roll every die twice (and choose one of the two) for roll_combo in product(possible_nums, repeat=rep): if self.get_adv() == 0: total = sum(roll_combo) else: rev = self.get_adv() > 0 # in case of advantage, give n largest. in disadvantage, give n smallest. total = sum(sorted(roll_combo, reverse=rev)[:self.get_dice_num()]) total += self.get_modifier() self._freqdist[total] += 1
[docs] def set_modifier(self, modifier: int): """ Set :py:attr:`_modifier` to the given value :param modifier: the new modifier :type modifier: int :return: None :raise: ValueError if *modifier* is invalid """ if not isinstance(modifier, int): raise ValueError("Modifier must be an int") self._modifier = modifier self.create_probdist()
[docs] def shift_modifier(self, modifier: int): """ Add *modifier* to :py:attr:`_modifier` :param modifier: the amount to shift by :type modifier: int :return: None :raise: ValueError if *modifier* is invalid """ if not isinstance(modifier, int): raise ValueError("Modifier must be an int") self._modifier += modifier self.create_probdist()
[docs] def set_adv(self, adv: int): """ Set :py:attr:`_adv` to the given value :param adv: the new advantage :type adv: int :return: None """ if not isinstance(adv, int): raise ValueError("Adv must be an int") self._adv = adv self.create_probdist()
[docs] def shift_adv(self, adv: int): """ Add *adv* to :py:attr:`_adv` :param adv: the amount to shift by :type adv: int :return: """ if not isinstance(adv, int): raise ValueError("Adv must be an int") if adv == 0: return # don't do extra work self._adv += adv self.create_probdist()
[docs] def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> 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 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 crit: indicates whether to roll more dice (e.g., because a crit was rolled) :type crit: one of these integers: -1, 0, 1 :param crit_multiplier: the multiplier for dice num if the hit is a crit :type crit_multiplier: positive integer :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_status = 0 adv += self.get_adv() num = self.get_dice_num() dice_type = self.get_dice_type() try: modifier += self.get_modifier() except TypeError: raise ValueError("Modifier must be an int") if num > 1 and self.get_critable(): warnings.warn("Rolling multiple critable dice in one go is currently not supported. " "Crit information will match the last die rolled.") if crit == 1: # crit success num *= crit_multiplier # roll more dice as specified for i in range(num): # pylint: disable=unused-variable nat_roll = random.randint(1, dice_type) if adv != 0: 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) if self.get_critable(): if nat_roll == dice_type: crit_status = 1 # crit success elif nat_roll == 1: crit_status = -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_status
[docs] class NullDice(Dice): """ For when you need a dice, but you don't actually care about the results """ def __init__(self, **kwargs): kwargs.update({"dice_tuple": (0, 0), "modifier": 0}) # ensure that the dice will be rolled a total of 0 times super().__init__(**kwargs)
[docs] class DamageDice(Dice): """ This class represents a damage die (includes a regular die and a damage type) """ def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Some of the keyword arguments are overridden by this class. :param damage_type: damage type :type damage_type: str :param dice_num: the number of dice :type dice_num: positive integer :param dice_type: the number of faces on the die :type dice_type: positive integer :param dice_tuple: a tuple of format *dice_num*, *dice_type* :type dice_tuple: tuple of 2 positive integers :param str_val: a string representation; e.g., "1d6" :type str_val: str :param modifier: modifier to rolls with this die :type modifier: int :param critable: indicates whether the dice can roll a critical success or critical fail :type critable: bool :raise: ValueError if input is invalid """ super().__init__(**kwargs) # review later to see if this should be super or if it should be Dice self._damage_type = kwargs.get("damage_type") if not isinstance(self._damage_type, str): self._damage_type = "" def __eq__(self, other) -> bool: """ Compare *self* and *other* to determine if they are equal based on the superclass method and these attributes: damage_type :param other: the DamageDice to compare :type other: DamageDice :return: True if *self* equals *other*, False otherwise :rtype: bool """ return super().__eq__(other) \ and self.get_damage_type() == other.get_damage_type()
[docs] def get_damage_type(self) -> str: """ :return: damage type :rtype: str """ return self._damage_type
[docs] def has_damage_type(self, damage_type: str) -> bool: """ :param damage_type: the damage type searched for :type damage_type: str :return: True if *self* has the damage type specified :rtype: bool """ return damage_type == self.get_damage_type()
[docs] def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> Tuple: """ Roll dice (applying advantage as specified), add the modifier (but don't go below 0), and return a namedtuple that contains the roll number and damage type :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 crit: indicates whether to roll more dice :type crit: one of these integers: -1, 0, 1 :param crit_multiplier: the multiplier for dice num if the hit is a crit :type crit_multiplier: positive integer :return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not :rtype: namedtuple that contains fields "roll_number" and "damage_type" """ template_nametup = namedtuple("damage_tuple", ["roll_number", "damage_type"]) result_nametup = template_nametup(roll_number=super().roll_dice(modifier=modifier, adv=adv, crit=crit, crit_multiplier=crit_multiplier)[0], damage_type=self.get_damage_type()) return result_nametup
[docs] class NullDamageDice(NullDice, DamageDice): """ For when you need DamageDice but don't care about the result (e.g., in an Attack that doesn't deal damage) """ def __init__(self, **kwargs): """ Create the dice """ if not kwargs.get("damage_type"): kwargs.update({"damage_type": ""}) super().__init__(**kwargs)
[docs] class DiceBag(Dice): """ Container class that holds multiple Dice """ def __init__(self, **kwargs): # pylint: disable=super-init-not-called """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param dice_list: list of Dice, in the order in which they will be rolled :type dice_list: list of Dice :raise: ValueError if input is invalid """ dice_list = kwargs.get("dice_list") if not isinstance(dice_list, (list, tuple)): raise ValueError("Dice list must be a list or tuple") self._dice_list = [] for die in dice_list: self.add_dice(die) if len(self._dice_list) < 2: raise ValueError("DiceBag must contain 2 or more Dice") self.create_probdist() def __eq__(self, other) -> bool: """ Compare *self* and *other* to determine if they are equal based on these attributes: :py:attr:`dice_list` :param other: the DiceBag to compare :type other: DiceBag :return: True if *self* equals *other*, False otherwise :rtype: bool """ if other is self: return True if type(other) != type(self): # pylint: disable=unidiomatic-typecheck return False return self.get_dice_list() == other.get_dice_list()
[docs] def get_dice_list(self) -> list: """ :return: dice list :rtype: list of Dice """ return self._dice_list
[docs] def get_max_value(self) -> int: """ :return: the maximum number that could result from rolls of all dice combined :rtype: int """ result = 0 for die in self.get_dice_list(): result += die.get_max_value() return result
[docs] def get_min_value(self) -> int: """ :return: the minimum number that could result from rolls of all dice combined :rtype: int """ result = 0 for die in self.get_dice_list(): result += die.get_min_value() return result
[docs] def get_average_value(self) -> float: """ :return: the average number that would result from rolls of all dice combined :rtype: float """ result = 0 for die in self.get_dice_list(): result += die.get_average_value() return result
[docs] def create_probdist(self): """ Create the dict that stores the (numerators for) probability of getting a certain result from the dice :return: """ self._freqdist = FreqDist() single_rolls = [die.get_freqdist().keys() for die in self.get_dice_list()] for roll_combo in product(*single_rolls): total = sum(roll_combo) freq = 1 for i in range(len(roll_combo)): # pylint: disable=consider-using-enumerate freq *= self.get_dice_list()[i].get_freq(roll_combo[i]) self._freqdist[total] += freq
[docs] def add_dice(self, die): """ Append the given die to :py:attr:`_dice_list` :param die: the die to add :return: None :raise: ValueError if input is invalid """ if not isinstance(die, Dice): raise ValueError("Die must be a Dice") self._dice_list.append(die)
[docs] def set_modifier(self, modifier: int): """ Set modifier for all Dice in the bag to the specified modifier :param modifier: modifier :type modifier: int :return: None """ for die in self.get_dice_list(): die.set_modifier(modifier) self.create_probdist()
[docs] def shift_modifier(self, modifier: int): """ Add *modifier* to each die's modifier :param modifier: the amount to shift by :type modifier: int :return: None :raise: ValueError if *modifier* is invalid """ for die in self.get_dice_list(): die.shift_modifier(modifier) self.create_probdist()
[docs] def set_adv(self, adv: int): """ Set adv for all Dice in the bag to the specified value :param adv: adv :type adv: int :return: None """ for die in self.get_dice_list(): die.set_adv(adv) self.create_probdist()
[docs] def shift_adv(self, adv: int): """ Add *adv* to each die's adv :param adv: the amount to shift by :type adv: int :return: None :raise: ValueError if *adv* is invalid """ for die in self.get_dice_list(): die.shift_adv(adv) self.create_probdist()
[docs] def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> Tuple: """ Roll dice (applying advantage as specified), add the modifier (but don't go below 0), and return the result :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 crit: indicates whether to roll more dice (applies to each individual die) :type crit: one of these integers: -1, 0, 1 :param crit_multiplier: the multiplier for dice num if the hit is a crit (applies to each individual die) :type crit_multiplier: positive integer :return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not :rtype: tuple (total number, list of all numbers rolled) """ total_num = 0 result_list = [] for die in self.get_dice_list(): num = die.roll_dice(modifier=modifier, adv=adv, crit=crit, crit_multiplier=crit_multiplier)[0] total_num += num result_list.append(num) return total_num, result_list
[docs] def get_dice_tuple(self): """ :raise: NotImplementedError """ raise NotImplementedError("get_dice_tuple not implemented for DiceBag")
[docs] def get_dice_num(self): """ :raise: NotImplementedError """ raise NotImplementedError("get_dice_num not implemented for DiceBag")
[docs] def get_dice_type(self): """ :raise: NotImplementedError """ raise NotImplementedError("get_dice_type not implemented for DiceBag")
[docs] def get_modifier(self): """ :raise: NotImplementedError """ raise NotImplementedError("get_modifier not implemented for DiceBag")
[docs] def get_adv(self): """ :raise: NotImplementedError """ raise NotImplementedError("get_adv not implemented for DiceBag")
[docs] def get_critable(self): """ :raise: NotImplementedError """ raise NotImplementedError("get_critable not implemented for DiceBag")
[docs] class DamageDiceBag(DiceBag, DamageDice): """ A class for a DiceBag of DamageDice """ def __init__(self, **kwargs): """ Validate the input and set the instance variables """ kwargs.update(damage_type="") # placeholder string so DamageDice constructor doesn't fail super().__init__(**kwargs) for die in self.get_dice_list(): if not isinstance(die, DamageDice): raise ValueError("DamageDiceBag can only contain DamageDice")
[docs] def get_damage_type_list(self) -> list: """ :return: damage types of all dice in the bag :rtype: list """ damage_types = [] for die in self.get_dice_list(): damage_types.append(die.get_damage_type()) return damage_types
[docs] def get_damage_type_set(self) -> set: """ :return: damage types of all dice in the bag :rtype: set """ damage_types = set() for die in self.get_dice_list(): damage_types.add(die.get_damage_type()) return damage_types
[docs] def has_damage_type(self, damage_type: str): """ :param damage_type: the damage type searched for :type damage_type: str :return: True if *self* has the damage type specified :rtype: bool """ # worst case this is O(n^2) because get_damage_type_set is O(n), # which may be ok for small values, but could compound over many iterations. return damage_type in self.get_damage_type_set()
[docs] def add_dice(self, die): """ Append the given die to *self._dice_list* :param die: the die to add :return: None :raise: ValueError if input is invalid """ if not isinstance(die, DamageDice): raise ValueError("Can only add DamageDice to a DamageDiceBag") super().add_dice(die)
[docs] def roll_dice(self, modifier: int = 0, adv: int = 0, crit: int = 0, crit_multiplier: int = 2) -> Tuple: """ Roll dice (applying advantage as specified), add the modifier (but don't go below 0), and return the result :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 crit: indicates whether to roll more dice (applies to each individual die) :type crit: one of these integers: -1, 0, 1 :param crit_multiplier: the multiplier for dice num if the hit is a crit (applies to each individual die) :type crit_multiplier: positive integer :return: a tuple that contains the total roll value as well as an indicator of whether the roll was a crit or not :rtype: tuple (total number, namedtuple with roll number and damage type) """ total_num = 0 result_list = [] for die in self.get_dice_list(): result_tup = die.roll_dice(modifier=modifier, adv=adv, crit=crit, crit_multiplier=crit_multiplier) total_num += result_tup[0] result_list.append(result_tup) return total_num, result_list
[docs] def get_damage_type(self): raise NotImplementedError("Use get_damage_type_list or get_damage_type_set")