"""Group Die instances into a single roll."""
import itertools
import math
import numbers
import operator
from collections import Counter, namedtuple
from .die import Constant, Die, _Rollable
Odds = namedtuple('Odds', ('value', 'ways', 'probability'))
Rolled = namedtuple('Rolled', ('results', 'value'))
# Operator precedence of a Roll's notation, for parenthesizing composed notation.
_PREC_ADD = 1
_PREC_MUL = 2
_PREC_ATOM = 3
def _lift(other):
"""Coerce ``other`` (Roll, Die, or number) into a ``Roll``.
Numbers become ``Constant`` dice and ride along in the composed Roll's dice list, so
odds/roll-state machinery handles them uniformly; consequently ``Roll.dice``
includes constants, e.g. ``parse('(d6*2+3)/d6').dice`` is (d6, 2, 3, d6).
:raises TypeError: If other is not a Roll, Die, or number.
"""
if isinstance(other, Roll):
return other
if isinstance(other, Die):
lifted = Roll((other,))
elif isinstance(other, numbers.Number):
lifted = Roll((Constant(other),))
else:
raise TypeError(f"cannot compose Roll with '{type(other).__name__}'")
lifted._prec = _PREC_ATOM
return lifted
def _plain(die):
"""True when die's rolls come straight from its faces, so negating faces negates rolls.
False for dice with custom roll behavior (e.g. Exploding). ``Constant`` overrides
``_roll_once`` only to skip the rng; it still rolls straight from its face.
"""
return type(die)._roll_once in (Die._roll_once, Constant._roll_once)
def _rounded(value, rounding, ndigits):
"""Round ``value`` to ``ndigits`` decimal places (see ``Roll.div`` for full behavior)."""
if ndigits is None:
return value
scale = 10**ndigits
scaled = value * scale
if rounding == 'up':
result = math.ceil(scaled)
elif rounding == 'down':
result = math.floor(scaled)
else:
result = math.floor(scaled + 0.5)
if ndigits:
return result / scale
return result
[docs]
class Roll(_Rollable):
def __init__(self, dice=(), func=sum, name=''):
"""Groups a set of Die instances into a single roll.
Two six-sided dice, summed::
die.Roll([die.Standard(6), die.Standard(6)])
One six-sided die, multiplied by 2, composed from a ready-made die::
die.d6.times(2)
A Roll's dice are fixed at construction; there is no in-place mutation. To change the dice,
compose a new Roll (``obj.plus(die)``) or rebuild one (``Roll(obj.dice + (die,))`` — but
note that only carries over ``dice``, not ``func``/``name``, and a compose-built Roll
rebuilt this way loses its operator structure, e.g. rebuilding a ``times(2)`` Roll yields
a plain summed Roll).
obj() and obj.roll(count, func ) both roll every die ``count`` times (default 1) and return
the outer ``func`` (default ``sum``) applied to the list of ``count`` results, each of which
is the constructor's ``func`` applied to that roll's individual die values.
Roll retains every roll. Subscript it to reach older rolls' aggregate values
(``obj[-1]`` is the most recent, matching ``value``, ``obj[0]`` the first); slicing and
iteration (``for value in obj``, ``list(obj)``, ``sum(obj)``) yield them in roll order.
The ``history`` property holds the full ``Rolled(results, value)`` namedtuples instead
(``results`` the per-die (face, value) tuple, ``value`` the aggregate;
``history[-1]`` matches the ``results``/``value`` properties). Empty until the first
roll, so ``obj[0]`` raises IndexError before then. ``len(obj)`` is the number of
retained rolls; ``clear()`` empties the history.
Compose methods ``plus``, ``minus``, ``times``, ``div``, ``mod`` (on ``Die`` too)
each return a NEW Roll combining this roll with a Die, Roll, or number; chains bind
tight, left to right::
die.d6.times(2).plus(3).div(die.d6) # ((d6*2)+3)/d6
Keep/drop compose methods ``keep_highest``, ``keep_lowest``, ``drop_highest``,
``drop_lowest`` each return a NEW Roll over the same dice keeping/dropping the
``n`` (default 1) highest/lowest die values and summing the kept::
die.parse('4d6').drop_lowest() # 4d6dl, sum of the 3 highest
die.d20.plus(die.d20).keep_highest() # (d20+d20)kh, single highest
Counting compose methods ``successes``, ``failures`` each return a NEW Roll over
the same dice counting die values >= / <= a target (dice pools)::
die.parse('6d10').successes(7) # 6d10>=7, count of dice rolling 7+
die.parse('6d10').failures(1) # 6d10<=1, count of dice rolling 1
``chance`` (on ``Die`` too) reports the cumulative percentage (0.0-100.0) chance of
a roll at or above / at or below a value; exactly one of ``gte``/``lte`` is given::
die.parse('2d6').chance(gte=10) # percent chance of rolling 10 or higher
die.d20.chance(lte=5) # percent chance of rolling 5 or lower
Operators (every one rolls, same as ``Die``):
- str(obj), int(obj), float(obj) roll all dice and return the coerced result.
- +, -, *, /, //, %, ** roll all dice and use that result; they only reliably
succeed when the Roll is numeric (``numeric`` is True when every die is).
- >, >=, <, <=, ==, != also roll — equality included. Consequences: Roll are not
hashable; comparing or sorting rolls gives unstable, unrepeatable results.
Compare ``dice``/``notation`` for structural equality.
Properties (all read only):
- notation: dice notation derived from the Roll's contents; use this, not
str(obj), for the notation text, since str(obj) rolls. For a plain summed Roll
it is composed from the contained dice, grouped (e.g. '3d6+2'); for a Roll
built by the compose methods it is the composed expression (e.g.
'(d6*2+3)/d6'). ``'<unsupported>'`` when underivable: an empty Roll, or a
constructor-supplied custom ``func``. When every die's notation is itself
parseable, ``Roll.from_notation(obj.notation)`` recreates an equivalent Roll
(same dice multiset and odds), with caveats: dice may be reordered by
grouping, and dice with custom configuration behind a standard notation (e.g.
``Exploding`` with a custom ``explode_to``) are recreated with defaults.
- description: textual description composed from the contained dice descriptions.
- dice: tuple of ``Die`` instances in Roll.
- numeric: [boolean] True if all Die in Roll can be numerically summed.
- odds: list of ``Odds(value, ways, probability)``, one per distinct result.
- history: list of past-roll ``Rolled(results, value)`` records, empty until the
first roll.
Attributes:
- name: free-text label (plain read/write attribute, default ''); shown by
repr(), never parsed, independent of notation.
Properties tracking the most recent roll (all read only, raise RuntimeError until
``roll()``, or any operator that implicitly rolls has been called):
- results: tuple of (face, value), one per die, from the most recent roll.
- value: aggregated (``func``-applied) result of the most recent roll.
Differences from ``Die``:
- No ``faces``, ``values``, ``items``, or ``sides`` properties (all possible
results); use ``dice`` (tuple of component ``Die`` instances) instead.
- ``__init__`` does not raise on an empty/default ``dice=()``, unlike ``Die`` which
requires at least one face.
:param dice: List of dice this roll is composed of.
:param func: [sum] Combines this roll's rolled dice values into one result, func([...]).
:param name: [''] Free-text label.
"""
self.name = name
self._func = func
# Immutable: dice never change after construction, so the derived notation, the
# compose-stashed _notation, and the cached _odds can never go stale.
self._dice = tuple(dice)
self._notation = None
self._odds = None
self._history = []
self._prec = None
[docs]
@classmethod
def from_notation(cls, notation):
"""Create a ``Roll`` from standard dice notation.
:param notation: Dice notation string, e.g. ``'3d6+2'``.
:return: New ``Roll`` instance.
:raises ValueError: If notation cannot be parsed.
"""
from .notation import parse # noqa: PLC0415 (circular import: notation imports Roll)
return parse(notation)
def __repr__(self):
label = self.name or self.notation
return f'<{self.__class__.__name__}({label}, {self._dice})>'
@property
def notation(self):
"""Dice notation derived from the Roll's contents; '<unsupported>' when underivable."""
if self._notation is not None:
return self._notation
if self._func is not sum or not self._dice:
return '<unsupported>'
return self._group_notation()
@property
def description(self):
"""Textual description composed from the contained ``Die`` descriptions."""
if not self._dice:
return 'Roll of no dice.'
bits = [f'{count} x {die.description.rstrip(".")}' for die, count in self._die_groups()]
return f'Roll of {"; ".join(bits)}.'
def _die_groups(self):
"""List of [die, count] pairs, consecutive-insensitive, grouped by identical dice.
Identical means same faces AND same notation; the latter separates dice whose faces
match but whose roll behavior differs (d6 vs exploding d6x vs penetrating d6p).
"""
# Can't use Counter (Die is deliberately unhashable) nor ==/sorting (both roll).
groups = list()
for die in self._dice:
for group in groups:
if group[0] is die or (
group[0].notation == die.notation and group[0].items == die.items
):
group[1] += 1
break
else:
groups.append([die, 1])
return groups
def _operand(self):
"""(text, precedence) of this Roll when embedded in composed notation."""
if self._prec is not None:
prec = self._prec
elif len(self._dice) <= 1:
prec = _PREC_ATOM
else:
prec = _PREC_ADD
if self._notation is not None:
text = self._notation
else:
text = self._group_notation()
return text, prec
def _group_notation(self):
"""Parseable additive notation synthesized from dice, e.g. '3d6+2d4-1'.
Negated dice (notation '-X') join with '-'; constants repeat ('2+2') rather than
count-prefix ('22').
"""
parts = list()
for die, count in self._die_groups():
text = die.notation
sign = '+'
if text.startswith('-'):
sign = '-'
text = text[1:]
if count > 1 and isinstance(die, Constant):
parts.extend([(sign, text)] * count)
elif count > 1:
parts.append((sign, f'{count}{text}'))
else:
parts.append((sign, text))
if not parts:
return '0'
sign, text = parts[0]
bits = [text if sign == '+' else f'-{text}']
bits.extend(f'{sign}{text}' for sign, text in parts[1:])
return ''.join(bits)
@property
def numeric(self):
"""True if all ``Die`` in Roll are numeric, and so can be numerically summed."""
return all(die.numeric for die in self._dice)
@property
def dice(self):
"""Tuple of ``Die`` instances in Roll.
Includes composed-in constants (``Constant`` dice): ``d6.plus(2).dice`` is (d6, 2).
"""
return self._dice
@property
def odds(self):
"""List of ``Odds(value, ways, probability)``, one per distinct result.
Computed from each die's face values, so odds are wrong for dice whose rolls
don't come straight from their faces (``Exploding``, ``Rerolling``).
"""
if self._odds is None:
self._calc_odds()
return self._odds
def _calc_odds(self):
"""Calculate the absolute probability of every possible result."""
if not self._dice:
self._odds = []
return
counts = Counter(
self._func(list(combo))
for combo in itertools.product(*(die.values for die in self._dice))
)
total = counts.total()
self._odds = sorted(Odds(value, ways, ways / total) for value, ways in counts.items())
@property
def value(self):
"""Aggregated (``func``-applied) result of the most recent roll."""
self._check_rolled()
return self._history[-1].value
@property
def results(self):
"""Tuple of (face, value), one per die, from the most recent roll."""
self._check_rolled()
return self._history[-1].results
def _roll_once(self):
"""Roll all the dice once.
:return: func (constructor param) applied to list of individual die roll values.
"""
values = list()
results = list()
# Capture each die's result immediately: the same Die instance may appear more than
# once in _dice (e.g. parse('3d6')), and result only reports its most recent roll.
for die in self._dice:
values.append(die.roll())
results.append(die.result)
value = self._func(values)
self._history.append(Rolled(tuple(results), value))
return value
def _binop_notation(self, other, symbol, prec, right_tight):
"""Notation of ``self symbol other``, parenthesizing operands that bind looser.
right_tight parenthesizes an equal-precedence right operand, for non-associative
operators (-, /, //, %, and * over rounded division).
"""
left, left_prec = self._operand()
right, right_prec = other._operand()
if left_prec < prec:
left = f'({left})'
if right_prec < prec or (right_tight and right_prec == prec):
right = f'({right})'
return f'{left}{symbol}{right}'
def _merge_sum(self, dice):
"""New summed Roll of this roll's dice plus ``dice``; notation derives from the dice."""
result = Roll(list(self._dice) + list(dice))
result._prec = _PREC_ADD
return result
def _wrap(self, other, op, notation, prec):
"""New Roll of both rolls' dice whose func applies ``op`` to the two rolls' results."""
left_func = self._func
right_func = other._func
split = len(self._dice)
def func(values):
return op(left_func(values[:split]), right_func(values[split:]))
result = Roll(list(self._dice) + list(other._dice), func=func)
result._notation = notation
result._prec = prec
return result
def _negated(self):
"""New Roll whose result is the negation of this roll's result."""
text, prec = self._operand()
if prec < _PREC_ATOM:
text = f'({text})'
notation = f'-{text}'
if self._func is sum and self.numeric and all(_plain(d) for d in self._dice):
result = Roll([d._negated() for d in self._dice])
else:
inner = self._func
def func(values):
return -inner(values)
result = Roll(list(self._dice), func=func)
result._notation = notation
result._prec = _PREC_ATOM
return result
[docs]
def plus(self, other):
"""Compose new Roll: this roll's result plus ``other``'s.
:param other: Die, Roll, or number.
:return: New ``Roll``; neither operand is modified.
:raises TypeError: If other is not a Die, Roll, or number.
"""
if isinstance(other, numbers.Number) and other < 0:
return self.minus(-other)
other = _lift(other)
if self._func is sum and other._func is sum:
return self._merge_sum(other._dice)
notation = self._binop_notation(other, '+', _PREC_ADD, right_tight=False)
return self._wrap(other, operator.add, notation, _PREC_ADD)
[docs]
def minus(self, other):
"""Compose new Roll: this roll's result minus ``other``'s.
:param other: Die, Roll, or number.
:return: New ``Roll``; neither operand is modified.
:raises TypeError: If other is not a Die, Roll, or number.
"""
if isinstance(other, numbers.Number) and other < 0:
return self.plus(-other)
other = _lift(other)
if (
self._func is sum
and other._func is sum
and other.numeric
and all(_plain(d) for d in other._dice)
):
return self._merge_sum([d._negated() for d in other._dice])
notation = self._binop_notation(other, '-', _PREC_ADD, right_tight=True)
return self._wrap(other, operator.sub, notation, _PREC_ADD)
[docs]
def times(self, other):
"""Compose new Roll: this roll's result multiplied by ``other``'s.
:param other: Die, Roll, or number.
:return: New ``Roll``; neither operand is modified.
:raises TypeError: If other is not a Die, Roll, or number.
"""
other = _lift(other)
notation = self._binop_notation(other, '*', _PREC_MUL, right_tight=True)
return self._wrap(other, operator.mul, notation, _PREC_MUL)
[docs]
def div(self, other, rounding='half', ndigits=0):
"""Compose new Roll: this roll's result divided by ``other``'s, then rounded.
Division is float (true) division; the quotient is then rounded to ``ndigits``
decimal places per ``rounding``:
- ``'half'`` (default): round half up. Fractions of exactly .5 round toward
positive infinity (2.5 -> 3, -2.5 -> -2); everything else rounds to nearest.
- ``'up'``: fractions round up; quotient is ceiled at ``ndigits`` places
(2.01 -> 3, -2.99 -> -2 at ndigits=0).
- ``'down'``: fractions round down; quotient is floored at ``ndigits`` places
(2.99 -> 2, -2.01 -> -3 at ndigits=0). ``div(x, rounding='down')`` is floor
(//) division.
With ``ndigits=0`` (the default) results are ints; with ``ndigits > 0``, floats
rounded to that many decimal places. ``ndigits=None`` skips rounding entirely and
results are raw float quotients (``rounding`` is then meaningless and raises).
:param other: Die, Roll, or number.
:param rounding: ['half'] How fractions round: 'half', 'up', or 'down'.
:param ndigits: [0] Decimal places rounded to; None for no rounding.
:return: New ``Roll``; neither operand is modified.
:raises TypeError: If other is not a Die, Roll, or number.
:raises ValueError: If rounding is not 'half', 'up', or 'down'; rounding is not
'half' with ndigits=None; or ndigits is not None or a non-negative integer.
"""
if rounding not in ('half', 'up', 'down'):
raise ValueError(f"rounding must be 'half', 'up', or 'down', got {rounding!r}")
if ndigits is None:
if rounding != 'half':
raise ValueError(
"ndigits=None skips rounding, incompatible with rounding='up'/'down'",
)
else:
try:
valid = ndigits == int(ndigits) and ndigits >= 0
except TypeError, ValueError:
valid = False
if not valid:
raise ValueError(f'ndigits must be a non-negative integer or None, got {ndigits!r}')
ndigits = int(ndigits)
other = _lift(other)
if rounding == 'down' and ndigits == 0:
symbol = '//'
else:
symbol = '/'
notation = self._binop_notation(other, symbol, _PREC_MUL, right_tight=True)
def op(left, right):
return _rounded(left / right, rounding, ndigits)
return self._wrap(other, op, notation, _PREC_MUL)
[docs]
def mod(self, other):
"""Compose new Roll: this roll's result modulo ``other``'s.
:param other: Die, Roll, or number.
:return: New ``Roll``; neither operand is modified.
:raises TypeError: If other is not a Die, Roll, or number.
"""
other = _lift(other)
notation = self._binop_notation(other, '%', _PREC_MUL, right_tight=True)
return self._wrap(other, operator.mod, notation, _PREC_MUL)
def _keep_drop(self, suffix, n):
"""New Roll over the same dice, keep/drop the ``n`` highest/lowest values, sum the kept."""
if not self.numeric:
raise TypeError('cannot keep/drop on a non-numeric Roll')
if self._func is not sum:
raise TypeError('keep/drop requires a plain summed Roll')
try:
valid = n == int(n) and n >= 1
except TypeError, ValueError:
valid = False
if not valid:
raise ValueError(f'n must be a positive integer, got {n!r}')
n = int(n)
count = len(self._dice)
if suffix in ('kh', 'kl') and n > count:
raise ValueError(f'cannot keep {n} of {count} dice')
if suffix in ('dh', 'dl') and n >= count:
raise ValueError(f'cannot drop {n} of {count} dice')
if suffix == 'kh':
kept = slice(count - n, None)
elif suffix == 'kl':
kept = slice(None, n)
elif suffix == 'dh':
kept = slice(None, count - n)
else:
kept = slice(n, None)
def func(values):
return sum(sorted(values)[kept])
text, prec = self._operand()
if prec < _PREC_ATOM:
text = f'({text})'
notation = f'{text}{suffix}'
if n > 1:
notation = f'{notation}{n}'
result = Roll(list(self._dice), func=func)
result._notation = notation
result._prec = _PREC_ATOM
return result
[docs]
def keep_highest(self, n=1):
"""Compose new Roll: the ``n`` highest of this roll's die values, summed.
``results`` of the new Roll still reports every die; ``value`` is the
kept sum.
:param n: [1] Number of dice kept.
:return: New ``Roll``; this roll is not modified.
:raises TypeError: If Roll is non-numeric or not a plain summed pool.
:raises ValueError: If n is not a positive integer or exceeds the number of dice.
"""
return self._keep_drop('kh', n)
[docs]
def keep_lowest(self, n=1):
"""Compose new Roll: the ``n`` lowest of this roll's die values, summed.
``results`` of the new Roll still reports every die; ``value`` is the
kept sum.
:param n: [1] Number of dice kept.
:return: New ``Roll``; this roll is not modified.
:raises TypeError: If Roll is non-numeric or not a plain summed pool.
:raises ValueError: If n is not a positive integer or exceeds the number of dice.
"""
return self._keep_drop('kl', n)
[docs]
def drop_highest(self, n=1):
"""Compose new Roll: this roll's die values with the ``n`` highest dropped, summed.
``results`` of the new Roll still reports every die; ``value`` is the
kept sum.
:param n: [1] Number of dice dropped.
:return: New ``Roll``; this roll is not modified.
:raises TypeError: If Roll is non-numeric or not a plain summed pool.
:raises ValueError: If n is not a positive integer or not fewer than the number of dice.
"""
return self._keep_drop('dh', n)
[docs]
def drop_lowest(self, n=1):
"""Compose new Roll: this roll's die values with the ``n`` lowest dropped, summed.
``results`` of the new Roll still reports every die; ``value`` is the
kept sum.
:param n: [1] Number of dice dropped.
:return: New ``Roll``; this roll is not modified.
:raises TypeError: If Roll is non-numeric or not a plain summed pool.
:raises ValueError: If n is not a positive integer or not fewer than the number of dice.
"""
return self._keep_drop('dl', n)
def _count_test(self, symbol, target, test):
"""New Roll over the same dice whose result counts die values passing ``test``."""
if not self.numeric:
raise TypeError('cannot count successes/failures on a non-numeric Roll')
if self._func is not sum:
raise TypeError('success/failure counting requires a plain summed Roll')
if not isinstance(target, numbers.Number):
raise TypeError(f'target must be a number, got {target!r}')
def func(values):
return sum(1 for value in values if test(value))
text, prec = self._operand()
if prec < _PREC_ATOM:
text = f'({text})'
result = Roll(list(self._dice), func=func)
result._notation = f'{text}{symbol}{target}'
result._prec = _PREC_ATOM
return result
[docs]
def successes(self, target):
"""Compose new Roll: how many of this roll's die values are >= ``target``.
The success-counting dice pool of World of Darkness, Shadowrun, and kin.
``results`` of the new Roll still reports every die; ``value`` is the count.
:param target: Number a die value must equal or exceed to count.
:return: New ``Roll``; this roll is not modified.
:raises TypeError: If Roll is non-numeric, not a plain summed pool, or target is
not a number.
"""
return self._count_test('>=', target, lambda value: value >= target)
[docs]
def failures(self, target):
"""Compose new Roll: how many of this roll's die values are <= ``target``.
Counterpart of ``successes`` for counting botches/failures.
``results`` of the new Roll still reports every die; ``value`` is the count.
:param target: Number a die value must equal or fall below to count.
:return: New ``Roll``; this roll is not modified.
:raises TypeError: If Roll is non-numeric, not a plain summed pool, or target is
not a number.
"""
return self._count_test('<=', target, lambda value: value <= target)
[docs]
def chance(self, gte=None, lte=None):
"""Cumulative percentage (0.0-100.0) chance of a roll at or beyond a bound.
Exactly one of ``gte``/``lte`` must be given: ``gte`` is the chance of rolling that
value or higher, ``lte`` that value or lower. Computed from ``odds``, so it carries
the same caveat: probabilities come from the dice's face values and are wrong for
dice whose rolls don't (``Exploding``, ``Rerolling``). An empty Roll has no
outcomes, so its chance is 0.0.
:param gte: Chance a roll is >= this value ("value or higher").
:param lte: Chance a roll is <= this value ("value or lower").
:return: Percentage, a float in 0.0-100.0.
:raises ValueError: If neither or both of gte/lte are given.
:raises TypeError: If the given bound is not a number, or the Roll is non-numeric.
"""
if (gte is None) == (lte is None):
raise ValueError('exactly one of gte, lte must be given')
bound = gte if gte is not None else lte
if not isinstance(bound, numbers.Number):
raise TypeError(f'bound must be a number, got {bound!r}')
if not self.numeric:
raise TypeError('cannot compute chance on a non-numeric Roll')
if gte is not None:
total = sum(o.probability for o in self.odds if o.value >= gte)
else:
total = sum(o.probability for o in self.odds if o.value <= lte)
return total * 100