CCCPaste

Advanced Dice Module

#!/usr/bin/env python3

import random
from typing import Self
import re
from dataclasses import dataclass, field
from copy import deepcopy as copy
from cli import clear, pause, flags_in_args
import sys
from data_processing import try_cast, remove_items_from_list as remove_extremes

@dataclass
class Dice:
    """
    Represents a series of dice rolls for tabletop roleplaying games.
    """
    quantity: int = 1
    sides: int = 6
    modifier: int = 0
    down_the_line: bool = False
    keep_high: int = 0
    keep_low: int = 0
    explode: bool = False
    results: list[int] = field(default_factory=list)

    def __str__(self) -> str:
        string = f"{self.quantity}d{self.sides}"
        string += f"{'+' if self.modifier > 0 else ''}"
        string += f"{'-' if self.modifier < 0 else ''}"
        string += f"{self.modifier if self.modifier != 0 else ''}"
        string += " "
        string += f"{":dtl " if self.down_the_line else ""}"
        string += f"{f':kh{self.keep_high} ' if self.keep_high > 0 else ''}"
        string += f"{f':kl{self.keep_low} ' if self.keep_low > 0 else ''}"
        string += f"{':ex ' if self.explode else ''}"

        return string

    @staticmethod
    def _parse_options(dice: Dice, options: list[str]) -> Dice:
        for option in options:
            if option == 'dtl':
                dice.down_the_line = True
                continue

            if option.startswith("kh"):
                option = option.replace("kh", "")
                dice.keep_high = try_cast(option, int) or 0
                continue

            if option.startswith("kl"):
                option = option.replace("kl", "")
                dice.keep_low = try_cast(option, int) or 0
                continue

            # Exploding dice, only keeps rolls with maximum value
            if option.startswith("ex"):
                dice.explode = True
                continue

        return dice

    @staticmethod
    def _parse_dice(dice: Dice, string: str) -> Dice | None:
        pattern: str = r"^(?P\d*)d(?P\d+)(?P[+-]*\d*)$"

        match = re.search(pattern, string)
        if not match:
            return None

        quantity = match.group('quantity')
        sides = match.group('sides')
        modifier = match.group('modifier')

        dice.quantity = try_cast(quantity, int) or 1
        dice.sides = try_cast(sides, int) or 6
        dice.modifier = try_cast(modifier, int) or 0

        if dice.quantity < 1 or dice.sides < 2:
            return None

        return dice


    @staticmethod
    def from_string(string: str) -> Dice | None:
        """
        Format: # d# ±# :flag :flag

        - #1: Quantity of dice
        - #2: Sides of dice
        - #3: Modifier

        Flags:

        - kh#: keep highest # dice
        - kl#: keep lowest # dice
        - ex: explode dice (not compatible with dtl)
        - dtl: roll this dice 6 times in order for D&D scores (not compatible with ex)
        """
        dice: Dice = Dice()
        string = string.strip().replace(' ', '').lower()
        parts: list[str] = string.split(':')

        new_dice: Dice | None = Dice._parse_dice(dice, parts[0])

        if not new_dice:
            return None

        if len(parts) > 1:
            new_dice = Dice._parse_options(new_dice, parts[1::])

        return new_dice

    @staticmethod
    def from_strings(strings: list[str]) -> list[Dice]:
        queue: list[Dice] = []

        for string in strings:
            dice = Dice.from_string(string)

            if dice:
                queue.append(dice)

        return queue


    def roll(self) -> Self:
        dice = self
        dice.results = [random.randint(1, dice.sides) for _ in range(dice.quantity)]

        if dice.keep_low != 0:
            dice.results = remove_extremes(dice.results, dice.keep_low)

        if dice.keep_high != 0:
            dice.results = remove_extremes(dice.results, dice.keep_high, descending=True)

        if dice.explode:
            dice.results = list(filter(lambda x: x == dice.sides, dice.results))

        return self

    def total(self) -> int:
        return sum(self.results) + self.modifier

    def _print_exploding(self, pretty=False) -> bool:
        if self.explode and not pretty:
            print(len(self.results))
            return True

        if self.explode and pretty:
            print(f"Exploded dice: {len(self.results)} ({len(self.results)}/{self.quantity} dice rolled a {self.sides})")
            return True

        return False

    def _print_down_the_line(self, pretty=False) -> bool:
        if self.down_the_line and not pretty:
            results = ""

            for _ in range(1,6):
                results += f"{copy(self.roll()).total()} "

            print(results)
            return True

        if self.down_the_line and pretty:
            strength = copy(self.roll())
            dexterity = copy(self.roll())
            constitution = copy(self.roll())
            intelligence = copy(self.roll())
            wisdom = copy(self.roll())
            charisma = copy(self.roll())

            print(f"Strength: {strength.total()} {strength.results}")
            print(f"Dexterity: {dexterity.total()} {dexterity.results}")
            print(f"Constitution: {constitution.total()} {constitution.results}")
            print(f"Intelligence: {intelligence.total()} {intelligence.results}")
            print(f"Wisdom: {wisdom.total()} {wisdom.results}")
            print(f"Charisma: {charisma.total()} {charisma.results}")
            return True

        return False

    def print(self, pretty=False) -> None:
        if self._print_exploding(pretty) or self._print_down_the_line(pretty):
            return

        if pretty:
            print(f"{str(self)}: {self.total()} {self.results}")
            return

        print(self.total())
        return


def help_text():
    print()
    print("Format: # d# [+-] # :flag :flag")
    print("- #1: Quantity of dice")
    print("- #2: Number of sides on dice")
    print("- [+-]: Add OR subtract")
    print("- #3: Modifier")
    print("")
    print("Flags:")
    print("- kh#: keep highest # dice")
    print("- kl#: keep lowest # dice")
    print("- ex: explode dice (not compatible with dtl)")
    print("- dtl: roll this dice 6 times in order for D&D scores (not compatible with ex)")
    print("")
    print("Example: 3d6 :dtl -> Roll 3d6 for each D&D ability score.")


def interactive(debug=False):
    clear()
    text = input("> ")
    commands = text.strip().lower().split(" ")

    if flags_in_args(['-h', '--help', 'help', '?'], commands):
        help_text()
        pause()
        interactive(debug)

    if flags_in_args(['-d', '--debug', 'debug'], commands):
        print(f"Debug mode {'disabled' if debug else 'enabled'}.")
        pause()
        interactive(False) if debug else interactive(True)

    print()

    for die in Dice.from_strings(commands):
        die.roll().print(pretty=True)
        print(f"\nDEBUG: {str(die)}\n") if debug else None

    if flags_in_args(['-e', '--exit', 'exit'], commands):
        clear()
        exit(0)

    pause()
    interactive(debug)


def main():
    if len(sys.argv) > 1:
        commands = [arg.lower() for arg in sys.argv]
        debug = False

        if flags_in_args(['-h', '--help', 'help', '?'], commands):
            help_text()
            return

        if flags_in_args(['-d', '--debug', 'debug'], commands):
            debug = True

        for die in Dice.from_strings(commands):
            die.roll().print(pretty=True)
            print(f"\nDEBUG: {str(die)}\n") if debug else None

    else:
        interactive()

if __name__ == "__main__":
    main()