"""Die classes: base, standard, numeric, constant, exploding, and symbolic."""
import numbers
import random
from collections import namedtuple
_UNSET = object()
Result = namedtuple('Result', ('face', 'value'))
class _Rollable:
"""Shared rolling behavior of ``Die`` and ``Roll``: operators and ``roll()``.
Single source of truth for the operator semantics both document: every operator
rolls (via subclass ``_roll_once()``) and uses that result.
"""
def __call__(self, count=1, func=_UNSET):
"""Roll ``count`` times, combining results with ``func``.
Defers to ``roll()``'s own default when ``func`` isn't given, so subclasses only
need to override ``roll()`` to change their default ``func``.
"""
if func is _UNSET:
return self.roll(count)
return self.roll(count, func)
def __str__(self):
return str(self.roll())
def __int__(self):
"""Rolls."""
return int(self.roll())
def __float__(self):
"""Rolls."""
return float(self.roll())
def __index__(self):
"""Rolls. This is called for alist[d6]."""
return int(self.roll())
def __gt__(self, other):
"""Rolls."""
return self.roll() > other
def __ge__(self, other):
"""Rolls."""
return self.roll() >= other
def __lt__(self, other):
"""Rolls."""
return self.roll() < other
def __le__(self, other):
"""Rolls."""
return self.roll() <= other
def __add__(self, other):
"""Rolls."""
return self.roll() + other
def __radd__(self, other):
"""Rolls."""
return self.roll() + other
def __sub__(self, other):
"""Rolls."""
return self.roll() - other
def __rsub__(self, other):
"""Rolls."""
return other - self.roll()
def __mul__(self, other):
"""Rolls."""
return self.roll() * other
def __rmul__(self, other):
"""Rolls."""
return self.roll() * other
def __truediv__(self, other):
"""Rolls."""
return self.roll() / other
def __rtruediv__(self, other):
"""Rolls."""
return other / self.roll()
def __floordiv__(self, other):
"""Rolls."""
return self.roll() // other
def __rfloordiv__(self, other):
"""Rolls."""
return other // self.roll()
def __mod__(self, other):
"""Rolls."""
return self.roll() % other
def __rmod__(self, other):
"""Rolls."""
return other % self.roll()
def __pow__(self, other):
"""Rolls."""
return self.roll() ** other
def __rpow__(self, other):
"""Rolls."""
return other ** self.roll()
__hash__ = None
def __eq__(self, other):
"""Rolls."""
return self.roll() == other
def __ne__(self, other):
"""Rolls."""
return self.roll() != other
def __getitem__(self, index):
"""Value(s) of past rolls, by index into the roll history; does NOT roll.
``obj[-1]`` is the most recent roll's value, ``obj[0]`` the first's; a slice gives a
list of values, and iteration yields them in roll order. Empty until the first roll,
so ``obj[0]`` raises IndexError before then. Full (face, value)/(results, value)
records are on ``history``.
"""
if isinstance(index, slice):
return [rolled.value for rolled in self._history[index]]
return self._history[index].value
@property
def history(self):
"""List of full roll records, in roll order; does NOT roll.
Each is a namedtuple: ``Result(face, value)`` for a ``Die``, ``Rolled(results, value)``
for a ``Roll``; ``history[-1]`` matches the most-recent-roll properties. Empty until
the first roll. A copy: mutating it does not affect the retained history.
"""
return list(self._history)
def __len__(self):
"""Number of rolls in the roll history; does NOT roll."""
return len(self._history)
def _check_rolled(self):
if not self._history:
raise RuntimeError('roll() has not been called')
def clear(self):
"""Empty the roll history.
Afterward the roll-state properties raise RuntimeError again, as before the
first roll.
"""
self._history.clear()
def roll(self, count=1, func=sum):
"""Roll ``count`` times.
:param count: [1] Number of times to roll.
:param func: [sum] Applied to the list of ``count`` rolled values.
:return: func applied to list of roll values.
"""
values = [self._roll_once() for _ in range(count)]
return func(values)
[docs]
class Die(_Rollable):
rng = random
def __init__(self, notation, faces, rng=None):
"""Base Die class.
A standard six sided die::
die.Die('d6', (('1', 1), ('2', 2), ('3', 3), ('4', 4), ('5', 5), ('6', 6)))
Rolling:
- obj.roll() and obj() roll the die ``count`` times (default 1) and return
``func`` (default ``sum``) applied to the list of rolled values.
- The default ``sum`` raises TypeError on a non-numeric die (``numeric`` is True
when every face value is a number); pass a ``func`` that handles the values, or
use ``Symbolic``, which overrides the default (see ``Symbolic.roll``).
- The die retains every roll. Subscript it to reach older rolls' 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 ``Result(face, value)``
namedtuples instead. Empty until the first roll, so ``obj[0]`` raises IndexError
before then. ``len(obj)`` is the number of retained rolls; ``clear()`` empties
the history.
Operators (every one rolls):
- str(obj), int(obj), float(obj) roll the die and return the coerced result.
- +, -, *, /, //, %, ** roll the die and use that result; they only reliably
succeed when the die is numeric.
- >, >=, <, <=, ==, != also roll — equality included. Consequences: Die are not
hashable; comparing or sorting dice gives unstable, unrepeatable results.
Compare ``items`` for structural (identical faces) equality.
Composing (does NOT roll):
- ``plus``, ``minus``, ``times``, ``div``, ``mod`` each return a new ``Roll``
combining this die with a Die, Roll, or number, e.g.
``d6.times(2).plus(3).div(d6)`` is the Roll ((d6*2)+3)/d6. Chains bind tight,
left to right. See ``Roll`` for details.
- ``keep_highest``, ``keep_lowest``, ``drop_highest``, ``drop_lowest`` each return a new
``Roll`` keeping/dropping the ``n`` (default 1) highest/lowest die values and
summing the kept. See ``Roll`` for details.
- ``successes``, ``failures`` each return a new ``Roll`` counting rolled values
>= / <= a target. See ``Roll`` for details.
- ``chance`` returns the cumulative percentage chance of a roll at or above
(``gte=``) / at or below (``lte=``) a value. See ``Roll.chance``.
Properties (all read only):
- notation: short textual identifier; use this, not str(obj), for the notation
text, since str(obj) rolls.
- description: textual description.
- faces: list of face texts.
- items: list of (face, value) tuples.
- numeric: [boolean] True if all of die's faces map to numeric **values**.
- sides: number of sides die has.
- values: list of face values.
- history: list of past-roll ``Result(face, value)`` records, empty until the
first roll.
Properties tracking the most recent roll (all read only, raise RuntimeError until
``roll()`` or any operator that implicitly rolls has been called):
- result: (face, value) of the most recently rolled face.
- face: face text of the most recently rolled face.
- value: value of the most recently rolled face.
Attributes:
- name: free-text label (plain read/write attribute, default ''); shown by
repr(), never parsed, independent of notation.
Class attributes:
- rng: Source of randomness shared by every Die instance; default is the
``random`` module. Assign e.g. ``die.Die.rng = random.Random(seed)`` to affect
every die at once. Overridden per-instance by the ``rng`` constructor param;
``del obj.rng`` reverts an overridden die to the class-wide ``rng``.
:param notation: Identifies Die of this type.
:param faces: List of (text, value), one per face.
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If notation is empty/None or faces is empty.
"""
if notation is None:
raise ValueError('Invalid notation')
self._notation = str(notation).strip()
if not self._notation:
raise ValueError('Invalid notation')
self._faces = tuple(faces)
if not self._faces:
raise ValueError('Invalid faces')
self._numeric = all(isinstance(v, numbers.Number) for v in self.values)
if rng is not None:
self.rng = rng
# Description is not passed as parameter because it's likely to be dynamically set by
# subclasses based on faces.
faces_str = ','.join(f'"{x[0]}"={x[1]}' for x in self._faces)
self._description = f'Die with {self.sides} faces [{faces_str}].'
self._history = []
self.name = ''
def __repr__(self):
return f'<{self.__class__.__name__}({self.name or self.notation}, {self._faces})>'
@property
def numeric(self):
"""True if all of die's faces map to numeric values."""
return self._numeric
@property
def notation(self):
"""Human readable expression of die."""
return self._notation
@property
def description(self):
"""Textual description of die faces."""
return self._description
@property
def sides(self):
"""Count of die faces."""
return len(self._faces)
@property
def faces(self):
"""List of face texts."""
return [x[0] for x in self._faces]
@property
def values(self):
"""List of face values."""
return [x[1] for x in self._faces]
@property
def items(self):
"""List of face (text, value) tuples."""
return list(self._faces)
@property
def face(self):
"""Face text of the most recently rolled face."""
self._check_rolled()
return self._history[-1][0]
@property
def value(self):
"""Value of the most recently rolled face."""
self._check_rolled()
return self._history[-1][1]
@property
def result(self):
"""``Result(face, value)`` of the most recently rolled face."""
self._check_rolled()
return self._history[-1]
def _roll_once(self):
"""Roll die once, appending (face, value) to history.
:return: Value of roll.
"""
item = self.rng.choice(self._faces)
self._history.append(Result(*item))
return item[1]
def _negated(self):
"""New die with the same faces but negated values; notation sign-toggles ('d6'/'-d6')."""
if self._notation.startswith('-'):
notation = self._notation[1:]
else:
notation = f'-{self._notation}'
spec = [(text, -value, 1) for text, value in self._faces]
return NumericBased(notation, spec, self.__dict__.get('rng'))
def _as_roll(self):
"""Lift this die into a single-die ``Roll``."""
from .rolls import Roll # noqa: PLC0415 (circular import: rolls imports Die)
return Roll((self,))
[docs]
def plus(self, other):
"""Compose new Roll: this die's roll plus ``other`` (Die, Roll, or number)."""
return self._as_roll().plus(other)
[docs]
def minus(self, other):
"""Compose new Roll: this die's roll minus ``other`` (Die, Roll, or number)."""
return self._as_roll().minus(other)
[docs]
def times(self, other):
"""Compose new Roll: this die's roll multiplied by ``other`` (Die, Roll, or number)."""
return self._as_roll().times(other)
[docs]
def div(self, other, rounding='half', ndigits=0):
"""Compose new Roll: this die's roll divided by ``other`` (Die, Roll, or number).
Float division then rounding; see ``Roll.div`` for rounding/ndigits behavior.
"""
return self._as_roll().div(other, rounding, ndigits)
[docs]
def mod(self, other):
"""Compose new Roll: this die's roll modulo ``other`` (Die, Roll, or number)."""
return self._as_roll().mod(other)
[docs]
def keep_highest(self, n=1):
"""Compose new Roll keeping the ``n`` highest of this die's rolled values, summed."""
return self._as_roll().keep_highest(n)
[docs]
def keep_lowest(self, n=1):
"""Compose new Roll keeping the ``n`` lowest of this die's rolled values, summed."""
return self._as_roll().keep_lowest(n)
[docs]
def drop_highest(self, n=1):
"""Compose new Roll dropping the ``n`` highest of this die's values, summing the rest."""
return self._as_roll().drop_highest(n)
[docs]
def drop_lowest(self, n=1):
"""Compose new Roll dropping the ``n`` lowest of this die's values, summing the rest."""
return self._as_roll().drop_lowest(n)
[docs]
def successes(self, target):
"""Compose new Roll counting this die's rolled values >= ``target``."""
return self._as_roll().successes(target)
[docs]
def failures(self, target):
"""Compose new Roll counting this die's rolled values <= ``target``."""
return self._as_roll().failures(target)
[docs]
def chance(self, gte=None, lte=None):
"""Cumulative percentage (0.0-100.0) chance of a roll >= ``gte`` or <= ``lte``.
Exactly one of ``gte``/``lte`` must be given. See ``Roll.chance``.
"""
return self._as_roll().chance(gte, lte)
[docs]
class NumericBased(Die):
def __init__(self, notation, spec, rng=None):
"""Die with possibly non-numeric face texts, but each face has an associated numeric value.
Fudge (www.fudgerpg.com) die::
die.NumericBased('dF', (('+', 1, 2), ('', 0, 2), ('-', -1, 2)))
:param notation: Identifies dice of this type.
:param spec: List of tuples (facetext, value, count).
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If spec is malformed or any value is non-numeric or count < 1.
"""
def _check_face(text, value, count):
if count < 1 or not isinstance(value, numbers.Number):
raise ValueError
return str(text), value
faces = list()
try:
for text, value, count in spec:
face = _check_face(text, value, count)
faces.extend(face for _ in range(count))
except TypeError, ValueError:
raise ValueError('Bad die specification') from None
super().__init__(notation, faces, rng)
faces_str = ','.join(f'"{x[0]}"={x[1]}' for x in self._faces)
self._description = f'NumericBased die with {self.sides} faces, [{faces_str}].'
[docs]
class Numeric(NumericBased):
def __init__(self, notation, spec, rng=None):
"""Die with non-sequential, numeric faces, possibly repeating.
A backgammon doubling die::
die.Numeric('backgammon', (2, 4, 8, 16, 32, 64))
:param notation: Uniquely identifies dice of this type.
:param spec: List of numbers, one per face of die.
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If spec is not iterable of numbers.
"""
try:
faces = [(x, x, 1) for x in spec]
except TypeError:
raise ValueError('Bad die specification') from None
super().__init__(notation, faces, rng)
self._description = f'Numeric die with {self.sides} faces [{",".join(self.faces)}].'
[docs]
class Constant(Numeric):
def __init__(self, value, rng=None):
"""One-faced die that always rolls ``value``; never consumes randomness.
The "2" in 'd6+2'::
die.Constant(2)
Composed and parsed numeric constants (``d6.plus(2)``, ``parse('d6+2')``) are
``Constant`` instances.
:param value: Number the die always rolls; also its notation.
:param rng: Source of randomness, accepted for signature consistency; never used.
:raises ValueError: If value is not a number.
"""
super().__init__(str(value), (value,), rng)
self._description = f'Constant {value}.'
def _roll_once(self):
"""Roll die once, appending (face, value) to history; no randomness involved.
:return: Value of roll.
"""
item = self._faces[0]
self._history.append(Result(*item))
return item[1]
def _negated(self):
"""New ``Constant`` with negated value (base would degrade to ``NumericBased``)."""
return Constant(-self._faces[0][1], self.__dict__.get('rng'))
[docs]
class Standard(Numeric):
def __init__(self, sides, rng=None):
"""Die with sequential, numeric faces.
The common six sided die::
die.Standard(6)
:param sides: Number of faces die has, integral and > 0.
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If sides is not a positive integer.
"""
def _check_sides(sides):
if sides != int(sides) or sides < 1:
raise ValueError
return int(sides)
try:
sides = _check_sides(sides)
except TypeError, ValueError:
raise ValueError('Bad number of sides') from None
super().__init__(f'd{sides}', range(1, sides + 1), rng)
self._description = f'Numeric die with {self.sides} faces numbered 1 through {self.sides}.'
[docs]
class Exploding(Standard):
def __init__(
self,
sides,
explode_range=1,
explode_to=None,
math_correct=False,
explode_range_low=0,
explode_to_low=None,
rng=None,
):
"""Die that explodes: rolling into the explode range rolls ``explode_to`` again, recursing.
A d6 that explodes on 6, rolling itself again::
die.Exploding(6)
Hackmaster style "math corrected" dice subtract one from every rolled-again value,
and may explode down to a smaller die::
die.Exploding(
20, explode_to=die.Exploding(6, math_correct=True), math_correct=True,
)
Rolemaster style double-ended (open-ended) d100 explodes up on 96-100 (add) and down
on 1-5 (subtract), recursively::
die.Exploding(100, explode_range=5, explode_range_low=5)
:param sides: Number of faces on die, integral and > 0.
:param explode_range: [1] Explode when roll is > (sides - explode_range).
:param explode_to: [None] ``Die`` rolled (recursively) again on explosion. ``None``
means roll this die again.
:param math_correct: [False] Subtract 1 from every rolled-again (not initial) value.
Incompatible with ``explode_range_low``.
:param explode_range_low: [0] Explode (subtracting) when roll is <= explode_range_low.
``0`` disables low-end explosion.
:param explode_to_low: [None] ``Die`` rolled (recursively) again, and subtracted, on
low-end explosion. ``None`` means roll this die again.
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If sides isn't an integer > 0; explode_to or explode_to_low isn't a
Die or None; explode_range >= sides; explode_range_low < 0; explode_range and
explode_range_low combined >= sides; or math_correct is True with
explode_range_low > 0.
"""
super().__init__(sides, rng)
if explode_range >= self.sides:
raise ValueError(f'explode_range must be < sides, got {explode_range} >= {self.sides}')
if explode_range_low < 0:
raise ValueError(f'explode_range_low must be >= 0, got {explode_range_low}')
if explode_range + explode_range_low >= self.sides:
raise ValueError(
'explode_range and explode_range_low combined must be < sides, got '
f'{explode_range} + {explode_range_low} >= {self.sides}',
)
if math_correct and explode_range_low > 0:
raise ValueError('math_correct is not supported with low-end (double-ended) explosion')
if explode_to is not None and not isinstance(explode_to, Die):
raise ValueError(f'explode_to must be a Die instance or None, got {explode_to!r}')
if explode_to_low is not None and not isinstance(explode_to_low, Die):
raise ValueError(
f'explode_to_low must be a Die instance or None, got {explode_to_low!r}',
)
if explode_to is None:
explode_to = self
if explode_to_low is None:
explode_to_low = self
self._explode_range = explode_range
self._explode_to = explode_to
self._math_correct = math_correct
self._explode_range_low = explode_range_low
self._explode_to_low = explode_to_low
if math_correct:
self._notation = f'd{self.sides}p'
correction = ' (math corrected)'
else:
self._notation = f'd{self.sides}x'
correction = ''
self._description = (
f'Exploding numeric die, {self.sides} faces numbered 1 through {self.sides}, '
f'explodes past {self.sides - explode_range} rolling {explode_to.notation}{correction}'
)
if explode_range_low > 0:
self._description += (
f', and explodes at or below {explode_range_low} subtracting a roll of '
f'{explode_to_low.notation}'
)
self._description += '.'
def _roll_once(self):
"""Roll die; if roll is in the explode range, roll ``explode_to``/``explode_to_low`` again.
``result``/``face`` reflect the initial face rolled; ``value``/``result[1]`` reflect the
total after any explosions (what this method returns).
:return: Sum (or difference, on low-end explosion) of the initial roll and any explosions.
"""
item = self.rng.choice(self._faces)
value = item[1]
# Explosions recurse through explode_to.roll(); when explode_to is this die, those
# sub-rolls append to its own history. Discard them and record just the settled result,
# so history keeps one (initial face, final value) entry per roll, matching result.
start = len(self._history)
if value <= self._explode_range_low:
final = value - self._explode_to_low.roll()
elif value + self._explode_range <= self.sides:
final = value
else:
extra = self._explode_to.roll()
if self._math_correct:
extra -= 1
final = value + extra
del self._history[start:]
self._history.append(Result(item[0], final))
return final
[docs]
class Rerolling(Standard):
def __init__(self, sides, reroll_range=1, once=True, rng=None):
"""Die that rerolls low values: rolling into the reroll range rolls again.
A d6 that rerolls 1s once, keeping the second roll (D&D Great Weapon Fighting)::
die.Rerolling(6)
A d6 that rerolls 1s and 2s until it rolls a 3 or better::
die.Rerolling(6, reroll_range=2, once=False)
Only the kept roll enters the roll history; rerolled-away values are discarded, so
each ``roll()`` adds exactly one entry. ``die.Rerolling(6, reroll_range=2, once=False)``
that rolls/rerolls 1, 2, 1, 5 keeps only 5 -- ``len(obj) == 1``, ``obj[-1]`` is ``5``,
and ``obj.history[-1]`` is ``('5', 5)``.
:param sides: Number of faces on die, integral and > 0.
:param reroll_range: [1] Reroll when roll is <= reroll_range.
:param once: [True] Reroll a single time, keeping the second roll even if it is
also in the reroll range; False rerolls until the roll is out of the range.
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If sides isn't an integer > 0, or reroll_range isn't an integer
with 1 <= reroll_range < sides.
"""
super().__init__(sides, rng)
try:
valid = reroll_range == int(reroll_range) and 1 <= reroll_range < self.sides
except TypeError, ValueError:
valid = False
if not valid:
raise ValueError(
f'reroll_range must be an integer, >= 1 and < sides, got {reroll_range!r}',
)
self._reroll_range = int(reroll_range)
self._once = once
suffix = 'r' if once else 'rr'
self._notation = f'd{self.sides}{suffix}'
if self._reroll_range > 1:
self._notation += str(self._reroll_range)
again = 'once' if once else 'until above it'
self._description = (
f'Rerolling numeric die, {self.sides} faces numbered 1 through {self.sides}, '
f'rerolls at or below {self._reroll_range} {again}.'
)
def _roll_once(self):
"""Roll die; while roll is in the reroll range, roll again (once, or until out of it).
Only the kept roll enters the history; rerolled-away values are discarded.
:return: Value of the kept roll.
"""
item = self.rng.choice(self._faces)
while item[1] <= self._reroll_range:
item = self.rng.choice(self._faces)
if self._once:
break
self._history.append(Result(*item))
return item[1]
[docs]
class Symbolic(Die):
def __init__(self, notation, spec, rng=None):
"""Die with "faces", and "values" that aren't necessarily numeric.
A coin flip::
die.Symbolic('coin', (('H', 'heads', 1), ('T', 'tails', 1)))
:param notation: Uniquely identifies dice of this type.
:param spec: die specification; list of tuples (face_text, face_value, count).
face_value must be convertible into string but otherwise is free to get funky,
count is number of faces with these text/value.
:param rng: Source of randomness for this die only, overriding class-wide ``Die.rng``.
:raises ValueError: If spec is malformed or any count < 1.
"""
def _check_count(count):
if count < 1:
raise ValueError
return count
faces = list()
try:
for text, value, count in spec:
faces.extend((text, value) for _ in range(_check_count(count)))
except TypeError, ValueError:
raise ValueError('Bad die specification') from None
super().__init__(notation, faces, rng)
faces_str = ','.join(f'"{x[0]}"={x[1]}' for x in self._faces)
self._description = f'Symbolic die with {self.sides} faces [{faces_str}].'
@staticmethod
def _scalar_or_list(values):
if len(values) == 1:
return values[0]
return list(values)
[docs]
def roll(self, count=1, func=None):
"""Roll ``count`` times.
When the die is numeric, behaves exactly like ``Die.roll()`` (``func`` defaults to
``sum``). When it isn't, ``func`` instead defaults to returning the single value at
``count=1``, or a list of values otherwise, since the values aren't summable.
:param count: [1] Number of times to roll.
:param func: [None] Applied to the list of ``count`` rolled values.
:return: func applied to list of roll values.
"""
if func is None:
func = sum if self.numeric else self._scalar_or_list
return super().roll(count, func)