"""Parse standard dice notation into ``Roll`` objects."""
import re
from .die import Constant, Exploding, NumericBased, Rerolling, Standard
from .rolls import _PREC_ATOM, Roll
_token = re.compile(
r'(\d*)[dD](\d+|[fF])([xXpP]|[rR]{1,2}\d*)?|([dDkK][hHlL])(\d*)|(\d+)|(//|>=|<=|[-+*/%()])',
)
def _fudge_die(rng):
return NumericBased('dF', (('+', 1, 2), (' ', 0, 2), ('-', -1, 2)), rng)
def _tokenize(notation, text):
tokens = list()
pos = 0
while pos < len(text):
if text[pos].isspace():
pos += 1
continue
match = _token.match(text, pos)
if match is None:
raise ValueError(f'Bad dice notation: {notation!r}')
count, sides, explode, suffix, suffix_count, constant, op = match.groups()
if sides is not None:
tokens.append(('die', count, sides, explode))
elif suffix is not None:
tokens.append(('suffix', suffix.lower(), suffix_count))
elif constant is not None:
tokens.append(('int', int(constant)))
else:
tokens.append((op,))
pos = match.end()
return tokens
class _Parser:
def __init__(self, notation, tokens, rng):
"""Recursive descent over tokens, standard operator precedence.
expr := term (('+' | '-') term)*
term := unary (('*' | '/' | '//' | '%') unary)*
unary := ('+' | '-')? atom
atom := primary (SUFFIX)*
primary := NdS | NdF | integer | '(' expr ')'
NdS := N? 'd' sides ('x' | 'p' | 'r' integer? | 'rr' integer?)?
SUFFIX := ('kh' | 'kl' | 'dh' | 'dl') integer? | ('>=' | '<=') integer
"""
self.notation = notation
self.tokens = tokens
self.pos = 0
self.rng = rng
def bad(self):
return ValueError(f'Bad dice notation: {self.notation!r}')
def peek(self):
if self.pos < len(self.tokens):
return self.tokens[self.pos]
return None
def take(self):
token = self.peek()
if token is None:
raise self.bad()
self.pos += 1
return token
def expr(self):
roll = self.term()
while True:
token = self.peek()
if token is None or token[0] not in ('+', '-'):
return roll
self.pos += 1
if token[0] == '+':
roll = roll.plus(self.term())
else:
roll = roll.minus(self.term())
def term(self):
roll = self.unary()
while True:
token = self.peek()
if token is None or token[0] not in ('*', '/', '//', '%'):
return roll
self.pos += 1
operand = self.unary()
if token[0] == '*':
roll = roll.times(operand)
elif token[0] == '/':
roll = roll.div(operand)
elif token[0] == '//':
roll = roll.div(operand, rounding='down')
else:
roll = roll.mod(operand)
def unary(self):
token = self.peek()
if token is not None and token[0] == '+':
self.pos += 1
return self.atom()
if token is not None and token[0] == '-':
self.pos += 1
nxt = self.peek()
if nxt is not None and nxt[0] == 'int':
self.pos += 1
return self.constant(-nxt[1])
return self.atom()._negated()
return self.atom()
def atom(self):
roll = self.primary()
while True:
token = self.peek()
if token is None:
return roll
if token[0] == 'suffix':
self.pos += 1
_, suffix, count = token
if count:
n = int(count)
else:
n = 1
methods = {
'kh': roll.keep_highest,
'kl': roll.keep_lowest,
'dh': roll.drop_highest,
'dl': roll.drop_lowest,
}
try:
roll = methods[suffix](n)
except TypeError, ValueError:
raise self.bad() from None
elif token[0] in ('>=', '<='):
self.pos += 1
target = self.peek()
if target is None or target[0] != 'int':
raise self.bad()
self.pos += 1
if token[0] == '>=':
method = roll.successes
else:
method = roll.failures
try:
roll = method(target[1])
except TypeError:
raise self.bad() from None
else:
return roll
def primary(self):
token = self.take()
kind = token[0]
if kind == '(':
roll = self.expr()
if self.take()[0] != ')':
raise self.bad()
return roll
if kind == 'die':
return self.dice(token)
if kind == 'int':
return self.constant(token[1])
raise self.bad()
def constant(self, value):
roll = Roll((Constant(value, self.rng),))
roll._prec = _PREC_ATOM
return roll
def dice(self, token):
_, count, sides, explode = token
if count:
count = int(count)
else:
count = 1
if count < 1:
raise self.bad()
if sides in ('f', 'F'):
if explode:
raise self.bad()
die = _fudge_die(self.rng)
else:
sides = int(sides)
if sides < 1:
raise self.bad()
explode = (explode or '').lower()
try:
if explode == 'x':
die = Exploding(sides, rng=self.rng)
elif explode == 'p':
die = Exploding(sides, math_correct=True, rng=self.rng)
elif explode.startswith('rr'):
die = Rerolling(sides, int(explode[2:] or 1), once=False, rng=self.rng)
elif explode.startswith('r'):
die = Rerolling(sides, int(explode[1:] or 1), rng=self.rng)
else:
die = Standard(sides, self.rng)
except ValueError:
raise self.bad() from None
roll = Roll([die] * count)
roll._prec = _PREC_ATOM
return roll
[docs]
def parse(notation, rng=None):
"""Parse dice notation into a ``Roll`` instance.
Notation is arithmetic over dice terms with standard operator precedence: ``*``, ``/``,
``//``, ``%`` bind tighter than ``+``, ``-``; parentheses override. A term is ``NdS``
(N S-sided dice), ``NdF`` (N Fudge dice), or an integer constant. ``NdS`` may carry an
``x`` (exploding) or ``p`` (penetrating) suffix, building an ``Exploding`` instead of
``Standard``, or an ``r`` (reroll once) or ``rr`` (reroll until out of range) suffix
with optional range (default 1), building a ``Rerolling``; none are valid on Fudge
dice. A keep/drop suffix ``kh``, ``kl``, ``dh``, ``dl`` with optional count (default 1)
may follow a dice term or parenthesized group, keeping/dropping the N highest/lowest
die values and summing the kept. A counting suffix ``>=N`` or ``<=N`` may likewise
follow, counting die values >= / <= N (``successes``/``failures``) instead of summing.
Suffixes bind tightest::
parse('3d6')
parse('2d10+5')
parse('3d6+2d4-1')
parse('(d6*2+3)/d6')
parse('4d6dl') # 4d6 drop lowest
parse('2d20kh') # 2d20 keep highest
parse('(3d6+2d4)kh2')
parse('d6x') # exploding d6
parse('3d6p') # three penetrating (Hackmaster math-corrected) d6, summed
parse('4d6xkh3') # four exploding d6, keep highest 3
parse('4d6r') # four d6, each rerolling 1s once
parse('d10rr2') # d10, rerolling 1s and 2s until it rolls 3+
parse('6d10>=7') # six d10, count of dice rolling 7 or higher
parse('6d10<=1') # six d10, count of dice rolling 1
``/`` is ``Roll.div()`` with defaults (float division rounded half up to an int);
``//`` is ``Roll.div(rounding='down')`` (floor division). ``d`` and ``f`` are
case-insensitive,
whitespace is tolerated. The result is built with the ``Roll`` compose methods, so it is
identical to the equivalent method chain, e.g. ``parse('(d6*2+3)/d6')`` and
``d6.times(2).plus(3).div(d6)``.
:param notation: Dice notation string.
:param rng: Source of randomness given to created dice, overriding class-wide ``Die.rng``.
:return: ``Roll`` whose ``notation`` is the normalized notation string.
:raises ValueError: If notation cannot be parsed.
"""
try:
text = notation.strip()
except AttributeError:
raise ValueError(f'Bad dice notation: {notation!r}') from None
parser = _Parser(notation, _tokenize(notation, text), rng)
if parser.peek() is None:
raise parser.bad()
roll = parser.expr()
if parser.peek() is not None:
raise parser.bad()
return roll
[docs]
def roll(notation, rng=None):
"""Parse dice notation, roll it once.
Convenience for ``parse(notation).roll()``; parsing anew each call, prefer
``parse()`` when making the same roll repeatedly.
:param notation: Dice notation string, e.g. ``'3d6+2'``.
:param rng: Source of randomness given to created dice, overriding class-wide ``Die.rng``.
:return: Result of the roll.
:raises ValueError: If notation cannot be parsed.
"""
return parse(notation, rng).roll()