# SPDX-FileCopyrightText: 2025 2025 # SPDX-FileContributor: Gergely Polonkai # # SPDX-License-Identifier: GPL-3.0-or-later """dice -- Utilities to deal with dice rolls""" import random import re DICE_DESCRIPTOR_PATTERN = re.compile(r"^([0-9]*d[0-9]+)([+-]([0-9]+|[0-9]*d[0-9]+))*$") D666_COMBINATIONS = [(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)] class Die: """A generic die rolling class""" def __init__(self, descriptor: str) -> None: self.positive_pool, self.negative_pool, self.modifier = self.parse(descriptor) @staticmethod def parse(descriptor: str) -> tuple[list[int], list[int], int]: """Parse dice descriptors such as ``d6`` or ``3d8+2`` :param descriptor: the dice descriptor :returns: a tuple with the number and types of posititve dice, the number and types of negative dice, and the constant modifier """ normalized_descriptor = "".join(c for c in descriptor if c != " ") if not normalized_descriptor: raise ValueError("Empty string cannot be parsed") if not DICE_DESCRIPTOR_PATTERN.match(normalized_descriptor): raise ValueError(f"Invalid dice descriptor {descriptor}") sign = "+" positive_pool = [] negative_pool = [] modifier = 0 while normalized_descriptor: if (new_sign := normalized_descriptor[0]) in ("+", "-"): sign = new_sign normalized_descriptor = normalized_descriptor[1:] continue match = re.search(r"^([0-9]*)(d?)([0-9]+)", normalized_descriptor) # This will always pass as we already checked the descriptor against DICE_DESCRIPTOR_PATTERN; we need this # assertion to prevent mypy warnings assert match multiplier, has_d, die_max = match.groups() normalized_descriptor = normalized_descriptor[match.end() :] if has_d: count = int(multiplier) if multiplier else 1 dice = [int(die_max) for _ in range(count)] if sign == "+": positive_pool.extend(dice) elif sign == "-": # pragma: no branch negative_pool.extend(dice) else: value = int(die_max) if sign == "-": value = 0 - value modifier += value return positive_pool, negative_pool, modifier @property def minimum(self) -> int: """The lowest number this dice roller can yield""" return len(self.positive_pool) - sum(self.negative_pool) + self.modifier @property def maximum(self) -> int: """The highest number this dice roller can yield""" return sum(self.positive_pool) - len(self.negative_pool) + self.modifier def roll(self) -> int: """Roll the dice in this roller’s dice pool and return the result""" result = 0 for die in self.positive_pool: result += random.randint(1, die) for die in self.negative_pool: result -= random.randint(1, die) result += self.modifier return result def d666(strict_order: bool = False) -> list[int]: """Roll three six sided dice and use them as separate digits “d666” is a special die type often used in random tables; here you don’t roll one 666 sided die, but three 6-sided ones, then use the three values of the three digits of the final result. :param strict_order: if ``True``, use the dics in strict order. Otherwise, return all possible combinations. :returns: the list of all valid combinations """ roll_values = [random.randint(1, 6) for _ in range(3)] results = [] combinations = [(0, 1, 2)] if strict_order else D666_COMBINATIONS for a, b, c in combinations: result = roll_values[a] * 100 + roll_values[b] * 10 + roll_values[c] if result not in results: results.append(result) return results