#!/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()