Source code for opend6_tools.magic2.dice

"""
A DSL for dice definitions.

The most important global object is ``D``, which is the basis for the DieCode DSL.

Example:

>>> from opend6_tools.magic2.dice import D
>>> 3*D + 2
3*D+2

..  autoclass:: DieCode
    :members:
    :special-members:
    :member-order:  bysource

..  autoclass:: Roll
    :members:
    :undoc-members:

..  autoclass:: CriticalSuccess
    :show-inheritance:
    :members:

..  autoclass:: CriticalFailure
    :show-inheritance:
    :members:

"""

from dataclasses import dataclass
from decimal import Decimal
from random import randint
import re
from typing import Any, ClassVar

from humre import (
    optional_group,
    starts_and_ends_with,
)


[docs] @dataclass class Roll: """A single roll of the dice.""" die: list[int] #: The die values pips: int #: Additional pips to add rolls: int #: Number of rolls (>1 for Critical Success) success: ClassVar[bool] = False fail: ClassVar[bool] = False @property def total(self) -> int: """Total of Die plus bonus pips""" return sum(self.die) + self.pips def __str__(self) -> str: return f"{self.total}"
[docs] @dataclass class CriticalSuccess(Roll): """A single roll that was a Critical Success -- Wild Die was 6.""" success = True def __str__(self) -> str: return f"{self.total}{'!' * (self.rolls - 1)}"
[docs] @dataclass class CriticalFailure(Roll): """A single roll that was a Critical Failure -- Wild Die was 1.""" fail = True @property def low_total(self) -> int: return sum(self.die) - max(self.die) + self.pips def __str__(self) -> str: return f"{self.low_total}? ({self.total})"
[docs] class DieCode: r""" Specifications for collections of dice. Computes the overall measure, :math:`3 \times d + p`. >>> from opend6_tools.magic2.dice import D, DieCode >>> r = 3*D+2 >>> r.measure Decimal('11') >>> repr(r) '3*D+2' Can also parse a text specification. >>> t = DieCode.parse_str("3D+2") >>> t.measure Decimal('11') >>> repr(t) '3*D+2' A degenerate case of only pips, not clear why someone might need this. >>> DieCode.parse_str("2") 0*D+2 >>> 0*D + 2 0*D+2 >>> from random import seed >>> seed(42) >>> physique = 4*D >>> [str(physique.roll()) for _ in range(12)] ['17!', '13!', '12? (18)', '6? (11)', '10', '13', '23', '15', '16!', '10', '9', '12'] """ # It is OpenD6, but, we might want to reuse this for other systems. faces: ClassVar[int] = 6
[docs] def __init__(self, n: int | Decimal = 1, adj: int | Decimal = 0) -> None: """Create a working DieCode instance, usually the global ``D``.""" self.n = int(n) #: Number of dice self.adj = int(adj) #: Additional pips
[docs] def __repr__(self) -> str: return f"{int(self.n):d}*D{self.adj:+d}" if self.adj else f"{int(self.n):d}*D"
[docs] def __str__(self) -> str: if self.n and self.adj: return f"{self.n}D+{self.adj}" elif self.n: return f"{self.n}D" elif self.adj: return f"+{self.adj}" else: return ""
[docs] def __mul__(self, other: Any) -> "DieCode": """Implement the ``*`` operator for ``D`` and a number.""" match other: case int() | Decimal(): return DieCode(int(self.n * other), self.adj * other) case _: # pragma: no cover return NotImplemented # pragma: no cover
__rmul__ = __mul__
[docs] def __add__(self, other: Any) -> "DieCode": """Implement the ``+`` operator for ``D`` and a number.""" match other: case int() | Decimal(): dice, pips = divmod(self.adj + other, 3) return DieCode(int(self.n + dice), int(pips)) case DieCode() as d: return DieCode.from_pips(self.measure + d.measure) case _: # pragma: no cover return NotImplemented # pragma: no cover
__radd__ = __add__ def __sub__(self, other: Any) -> "DieCode": match other: case int() as p: return DieCode.from_pips(self.measure - p) case DieCode() as d: return DieCode.from_pips(self.measure - d.measure) case _: # pragma: no cover return NotImplemented def __bool__(self) -> bool: return bool(self.n) or bool(self.adj)
[docs] def __eq__(self, other: Any) -> bool: match other: case DieCode() as die_code: return self.measure == die_code.measure case int() as n: return self.measure == n case _: # pragma: no cover return NotImplemented
@property def d(self) -> int: """Legacy attribute reference, now a property.""" return self.n
[docs] @classmethod def from_pips(cls, pips: int | Decimal) -> "DieCode": """ Converts number of pips to Die + Pips. :param pips: pips value :return: DieCode """ return cls(*divmod(pips, 3))
@property def measure(self) -> Decimal: """The overall measure associated with this DieCode.""" return Decimal(self.n * 3 + self.adj)
[docs] @classmethod def parse_str(cls, text: str) -> "DieCode": """ Parse a string representation of a DieCode object, example: ``"3D+2"``. The syntax has three closely-related forms: ``[+]n[*]D+n`` | ``[+]n[*]D`` | ``+n`` Pragmatically, ``+`` and ``*`` are essentially whitespace. The meaningful tokens are strings of digits and ``D``. The three interesting forms are nDn, nD, and n. """ text_clean = re.sub(r"[\s\+\*]", "", text.strip()) pattern = starts_and_ends_with( optional_group(r"\d+") + optional_group(r"D") + optional_group(r"\d+") ) if (match := re.match(pattern, text_clean)) is None: raise ValueError(f"invalid dice expression {text!r}") if match.group(1) and match.group(2) and match.group(3): dice = int(match.group(1)) pips = int(match.group(3)) elif match.group(1) and match.group(2): dice = int(match.group(1)) pips = 0 elif match.group(1) and not match.group(2): dice = 0 pips = int(match.group(1)) else: # pragma: no cover raise ValueError(f"invalid dice expression {text!r}") return dice * D + pips
[docs] def roll(self) -> Roll: """The "Wild Die" roll algorithm. This returns several things: 1. Ordinary result: a :py:class:`Roll` instance. 2. Critical Success, wild die was 6. A :py:class:`CriticalSuccess` instance. 3. Critical Failure, wild die was 1. Two totals (with and without the highest die value.) A :py:class:`CriticalFailure` instance. """ # Last Die is the "wild die". count = 1 roll = [randint(1, self.faces) for d in range(self.n)] if roll[-1] == 1: # Critical Failure -- two totals: with and without highest die value. return CriticalFailure(roll, self.adj, rolls=count) elif roll[-1] == self.faces: # Critical Success -- Reroll wild die. while roll[-1] == self.faces: count += 1 roll.append(randint(1, self.faces)) return CriticalSuccess(roll, self.adj, rolls=count) else: # Ordinary roll. return Roll(roll, self.adj, rolls=count)
D = DieCode()