Skip to content

Commit

Permalink
Merge pull request #21 from Vlek/pyparsing
Browse files Browse the repository at this point in the history
Although the github tests do not work, it's because we are no longer using setup files and instead use poetry. I will have to include generated ones from here on out.
  • Loading branch information
Vlek authored Feb 4, 2021
2 parents 4dca3d3 + cdcc2fe commit a38d957
Show file tree
Hide file tree
Showing 11 changed files with 827 additions and 193 deletions.
419 changes: 419 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[tool.poetry]
name = "pyroll"
version = "1.0.1"
description = "Dice roller with all of the features you could want."
authors = ["Derek 'Vlek' McCammond <[email protected]>"]
license = "gpl-3.0"
readme = "README.md"
homepage = "https://github.com/vlek/roll"
repository = "https://github.com/vlek/roll"
keywords = [
"dice", "die", "roll", "rolling", "game",
"gaming", "rp", "rpg", "parse", "parser", "parsing",
"cli", "terminal"
]

[tool.poetry.dependencies]
python = "^3.6"
typing_extensions = "^3.7.4"
toml = "^0.10.1"
pyparsing = "^2.4.7"
click = "^7.1.2"
parsley = "^1.3"

[tool.poetry.dev-dependencies]
pytest = "^5.4.2"
pytest-cov = "^2.9.0"
coverage = "^5.3"
codecov = "^2.1.10"

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"
56 changes: 56 additions & 0 deletions roll/click.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env python3

"""
Dice roller CLI Script.
Makes it easy to roll dice via command line and is able handle the basic
math functions, including parens!
1d20 -> 19
1d8 + 3d6 + 5 -> 15
d% -> 42
<Nothing> -> 14 (Rolls a d20)
etc.
"""

from typing import List

import click
from roll import roll


@click.command()
@click.argument('expression', nargs=-1, type=str)
@click.option('-v', '--verbose', 'verbose', is_flag=True,
help='Print the individual die roll values')
def roll_cli(expression: List[str] = None, verbose: bool = False) -> None:
"""
CLI dice roller.
Usage: roll [EXPRESSION]
A cli command for rolling dice and adding modifiers in the
same fashion as the node.js version on npm.
Examples:
roll - Rolls 1d20
roll <expression> - Rolls all dice + does math
Expressions:
1d20 - Rolls one 20-sided die
d20 - Does not require a '1' in front of 'd'
d% - Rolls 1d100
d8 + 3d6 + 5 - Rolls 1d8, 3d6, and adds everything together
(1d4)d6 - Rolls 1d4 d6 die
"""
command_input = ' '.join(expression) if expression is not None else ''

click.echo(roll(command_input, verbose))


if __name__ == '__main__':
roll_cli()
251 changes: 251 additions & 0 deletions roll/diceparser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
#!/user/bin/env python3

"""
Dice rolling PyParser grammar.
Grammar:
Digit ::= [1234567890]
Number ::= ( '-' )? Digit Digit* ( '.' Digit Digit*)?
Add ::= Number '+' Number
Sub ::= Number '-' Number
AddOrSub ::= Add | Sub
Mult ::= Number '*' Number
Div ::= Number '/' Number
Mod ::= Number '%' Number
IntDiv ::= Number '//' Number
MultOrDiv ::= Mult | Div | Mod | IntDiv
Exponent ::= Number '**' Number
PercentDie ::= Number? 'd%'
Die ::= Number? 'd' Number
Dice ::= Die | PercentDie
Parens ::= Number? '(' Expression ')'
Expression ::= (Parens | Exponent | Dice | MultOrDiv | AddOrSub | Number)+
Main ::= Expression
Website used to do railroad diagrams: https://www.bottlecaps.de/rr/ui
"""

from math import ceil, e, factorial, pi
from operator import add, floordiv, mod, mul, sub, truediv
from random import randint
from typing import List, Union

from pyparsing import (CaselessKeyword, CaselessLiteral, Forward, Literal,
Optional, ParserElement, ParseResults, oneOf, opAssoc,
operatorPrecedence, pyparsing_common)

ParserElement.enablePackrat()


def _roll_dice(
num_dice: Union[int, float],
sides: Union[int, float],
debug_print: bool = False) -> Union[int, float]:
"""Calculate value of dice roll notation."""
starting_num_dice = num_dice
starting_sides = sides

# If it's the case that we were given a dice with negative sides,
# then that doesn't mean anything in the real world. I cannot
# for the life of me figure out a possible scenario where that
# would make sense. We will just error out.
if sides < 0:
raise ValueError('The sides of a die must be positive or zero.')

if isinstance(num_dice, float):
sides *= num_dice

# 0.5d20 == 1d10, so, after we've changed the value,
# we need to set the left value to 1.
num_dice = 1

result_is_negative = num_dice < 0

if result_is_negative:
num_dice = abs(num_dice)

sides = ceil(sides)

rolls = [
randint(1, sides) for _ in range(num_dice)
] if sides != 0 else []

rolls_total = sum(rolls)

if result_is_negative:
rolls_total *= -1

if debug_print:
debug_message = [
f'{starting_num_dice}d{starting_sides}:',
f'{rolls_total}',
(f'{rolls}' if len(rolls) > 1 else '')
]

return rolls_total


class DiceParser:
"""Parser for evaluating dice strings."""

operations = {
"+": add,
"-": sub,
"*": mul,
"/": truediv,
"//": floordiv,
"%": mod,
"^": pow,
"**": pow,
"d": _roll_dice,
"!": factorial
}

constants = {
"pi": pi,
"e": e
}

def __init__(self: "DiceParser") -> None:
"""Initialize a parser to handle dice strings."""
self._parser = self._create_parser()

@staticmethod
def _create_parser() -> Forward:
"""Create an instance of a dice roll string parser."""
atom = (
CaselessLiteral("d%") |
pyparsing_common.number |
CaselessKeyword("pi") |
CaselessKeyword("e")
)

expression = operatorPrecedence(atom, [
(oneOf('^ **'), 2, opAssoc.RIGHT),

(Literal('!'), 1, opAssoc.LEFT),

# (Literal('-'), 1, opAssoc.RIGHT),

(CaselessLiteral('d%'), 1, opAssoc.LEFT),
(CaselessLiteral('d'), 2, opAssoc.RIGHT),

# This line causes the recursion debug to go off.
# Will have to find a way to have an optional left
# operator in this case.
(CaselessLiteral('d'), 1, opAssoc.RIGHT),

(oneOf('* / % //'), 2, opAssoc.LEFT),

(oneOf('+ -'), 2, opAssoc.LEFT),
])

return expression

def parse(self: "DiceParser", dice_string: str) -> List[Union[str, int]]:
"""Parse well-formed dice roll strings."""
return self._parser.parseString(dice_string, parseAll=True)

def evaluate(
self: "DiceParser",
parsed_values: Union[List[Union[str, int]], str]
) -> Union[int, float]:
"""Evaluate the output parsed values from roll strings."""
if isinstance(parsed_values, str):
parsed_values = self.parse(parsed_values)

result = None
operator = None

for val in parsed_values:
if (
isinstance(val, (int, float, ParseResults)) or
val in self.constants
):
if val in self.constants:
val = self.constants[val]
elif isinstance(val, ParseResults):
val = self.evaluate(val)

if operator is not None:
result = operator(result if result is not None else 1, val)
else:
if result is None:
result = val
else:
result += val

elif val in self.operations:
if val == "!":
result = factorial(result)
continue

operator = self.operations[val]

elif val in ["D%", "d%"]:
result = _roll_dice(result if result is not None else 1, 100)

else:
raise Exception("Unable to evaluate input.")

return result


if __name__ == "__main__":
parser = DiceParser()

# print("Recursive issues:", parser._parser.validate())
roll_strings = [
"5-3",
"3-5",
"3--5",
"1d2d3",
"5^2d1",
"0!d20",
"5 + 2!",
"5**(2)",
"5**2 * 7",
"2 + 5 d 6",
"(2)d6",
"2d(6)",
"3",
"-3",
"--3",
"100.",
"1 2",
# "--7", # Currently have an issue with this.
"9.0",
"-12.05",
"1 + 2",
"2 - 1",
"100 - 3",
"12 // 4",
"3+2*4",
"2^4",
"3 + 2 * 2^1",
"9-1+27+(3-5)+9",
"1d%",
"d%",
"D%",
"1d20",
"d20",
"d20 + 5",
"2d6 + 1d8 + 4",
"5!",
"pi",
"pi + 2",
"pi * e",
"(2 + 8 / (9 - 5)) * 3",
"100 - 21 / 7",
# "((((((3))))))",
]

for rs in roll_strings:
try:
parsed_string = parser.parse(rs)

# print(rs)
# print(parsed_string)
print(parser.evaluate(parsed_string))
except Exception:
print("Exception occured parsing: " + rs)
Loading

0 comments on commit a38d957

Please sign in to comment.