Skip to content

Rollin’ with rollers and rolls

Experimental

This functionality should be considered experimental. Be warned that future release may introduce incompatibilities or remove it altogether. Feedback, suggestions, and contributions are welcome and appreciated.

dyce provides additional primitives for generating and inspecting rolls of weighted random outcomes without requiring the overhead of enumeration.

1
>>> from dyce import R

R objects represent rollers. Rollers produce Roll objects. Roll objects are sequences of RollOutcome objects, which represent weighted random values.

Each object can be a node in a tree-like structure. Rollers, for example, can represent scalars, histograms, pools, operators, etc., and can be assembled into trees representing more complex calculations. Rolls can derive from other rolls, forming trees that are generally analogous to the roller trees that generated them. Similarly, roll outcomes can derive from other roll outcomes.

The simplest roller we can create represents a single value. Each roll it generates has that value as its sole outcome. Let’s see what that looks like (now with tasty entity relationship diagrams).

Rollers, rolls, and outcomes, oh my!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> from dyce.r import ValueRoller
>>> r_1 = ValueRoller(1)
>>> roll = r_1.roll()
>>> roll.total()
1
>>> tuple(roll.outcomes())
(1,)
>>> roll
Roll(
  r=ValueRoller(value=1, annotation=''),
  roll_outcomes=(
    RollOutcome(
      value=1,
      sources=(),
    ),
  ),
  source_rolls=(),
)

Hopefully, that’s relatively straightforward. Let’s look at some more substantial examples.

Emulating a hundred-sided die using two ten-sided dice

d00 & d10

In many games it is common to emulate a hundred-sided die using a “ones” ten-sided die (faces numbered \([{ {0}, {1}, \ldots , {9} }]\)) and a “tens” ten-sided die (faces numbered \([{ {00}, {10}, \ldots , {90} }]\)). Let’s try to model that as a roller and use it to generate a roll.

We start by creating two histograms1 representing our two ten-sided dice (d00 for our “tens” die and d10 for our “ones“ die).

1
2
3
>>> from dyce import H
>>> d10 = H(10) - 1
>>> d00 = 10 * d10

Next, we create a roller using the R.from_values class method.

1
2
3
4
5
6
7
8
>>> r_d100 = R.from_values(d00, d10) ; r_d100
PoolRoller(
  sources=(
    ValueRoller(value=H({0: 1, 10: 1, 20: 1, 30: 1, 40: 1, 50: 1, 60: 1, 70: 1, 80: 1, 90: 1}), annotation=''),
    ValueRoller(value=H({0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}), annotation=''),
  ),
  annotation='',
)

Well, wouldya look at that? That durned class method created a whole roller tree, which is actually three rollers.

  1. One ValueRoller for the d00 histogram;
  2. Another for the d10 histogram; and
  3. A PoolRoller for aggregating them both.

Tip

We could have also composed an identical tree using roller implementations from dyce.r instead of the R.from_values convenience method.

1
2
3
>>> from dyce.r import PoolRoller, ValueRoller
>>> r_d100 == PoolRoller(sources=(ValueRoller(d00), ValueRoller(d10)))
True

Let’s use our new roller to create a roll and retrieve its total.

1
2
3
>>> roll = r_d100.roll()
>>> roll.total()
69

No surprises there. Let’s dig a little deeper and ask for the roll’s outcome values.

1
2
>>> tuple(roll.outcomes())
(60, 9)

As we mentioned before, the top level of our roller tree is a PoolRoller, which aggregates (or “pools”) rolls from its sources. For our roll, the aggregated outcomes are 60 are 9.

What does our pooled roll look like?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
>>> roll
Roll(
  r=PoolRoller(
    sources=(
      ValueRoller(value=H({0: 1, 10: 1, 20: 1, 30: 1, 40: 1, 50: 1, 60: 1, 70: 1, 80: 1, 90: 1}), annotation=''),
      ValueRoller(value=H({0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}), annotation=''),
    ),
    annotation='',
  ),
  roll_outcomes=(
    RollOutcome(
      value=60,
      sources=(),
    ),
    RollOutcome(
      value=9,
      sources=(),
    ),
  ),
  source_rolls=(
    Roll(
      r=ValueRoller(value=H({0: 1, 10: 1, 20: 1, 30: 1, 40: 1, 50: 1, 60: 1, 70: 1, 80: 1, 90: 1}), annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=60,
          sources=(),
        ),
      ),
      source_rolls=(),
    ),
    Roll(
      r=ValueRoller(value=H({0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}), annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=9,
          sources=(),
        ),
      ),
      source_rolls=(),
    ),
  ),
)

Roll ERD from emulating a d100

Let’s break that down so it doesn’t feel like trying to drink from a fire hose.

Calling the R.roll method on our PoolRoller resulted in a Roll object. Actually, it resulted in a roll tree (analogous to our roller tree). Each Roll object in that tree has:

The RollOutcome objects also form trees (in our case, simple ones). Each one has:

  • A single value, retrieved via its value property;
  • Zero or more source outcomes from which the value was derived, retrieved via its sources property; and
  • A reference back to the roll that generated it, retrieved via its source_roll property (omitted from the diagram for the sake of readability).

Tip

You might be wondering to yourself, “Self, one wonders, can one have a pool of pools?” Such questions command the response, “Why the heck not? Try it!”

1
2
3
4
5
6
>>> two_r_d100s = PoolRoller(sources=(r_d100, r_d100))
>>> roll_two = two_r_d100s.roll()
>>> roll_two.total()
63
>>> tuple(roll_two.outcomes())
(40, 2, 20, 1)

So the answer is a resounding, “Of course. What devious entity would prohibit such a thing? Please identify that creature so we may flog it until it achieves enlightenment,” “Yes.”

Composing rollers with arithmetic

Rollers support arithmetic operators.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
>>> d12 = H(12)
>>> r_d12_add_4 = ValueRoller(d12) + 4 ; r_d12_add_4
BinarySumOpRoller(
  bin_op=<built-in function add>,
  left_source=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1}), annotation=''),
  right_source=ValueRoller(value=4, annotation=''),
  annotation='',
)
>>> r_d12_add_4.roll()
Roll(
  r=BinarySumOpRoller(...),
  roll_outcomes=(
    RollOutcome(
      value=11,
      sources=(
        RollOutcome(
          value=7,
          sources=(),
        ),
        RollOutcome(
          value=4,
          sources=(),
        ),
      ),
    ),
  ),
  source_rolls=(
    Roll(
      r=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1}), annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=7,
          sources=(),
        ),
      ),
      source_rolls=(),
    ),
    Roll(
      r=ValueRoller(value=4, annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=4,
          sources=(),
        ),
      ),
      source_rolls=(),
    ),
  ),
)

Roll ERD for arithmetic composition

Dropping dice from prior rolls – keeping the best three of 3d6 and 1d8

The trifecta of roller trees, roll trees, and outcome trees might appear complicated or redundant. Everything serves a purpose.2

Consider excluding (or “dropping”) dice from a roll. How would we account for that? Let’s see how to generate rolls that keep the best three outcomes from rolling three six-sided dice and one eight-sided die.

We start by using the R.from_value class method to create ValueRollers for histograms representing our six- and eight-sided dice.

1
2
3
4
5
6
>>> d6 = H(6)
>>> d8 = H(8)
>>> r_d6 = R.from_value(d6) ; r_d6
ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}), annotation='')
>>> r_d8 = R.from_value(d8) ; r_d8
ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}), annotation='')

For homogeneous pools, we can use the matrix multiplication operator.

1
2
3
4
5
6
>>> r_3d6 = 3@r_d6 ; r_3d6
RepeatRoller(
  n=3,
  source=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}), annotation=''),
  annotation='',
)

Finally, we’ll create a SelectionRoller by calling the R.select_from_sources method on our other rollers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> r_best_3_of_3d6_d8 = R.select_from_sources((slice(1, None),), r_3d6, r_d8) ; r_best_3_of_3d6_d8
SelectionRoller(
  which=(slice(1, None, None),),
  sources=(
    RepeatRoller(
      n=3,
      source=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}), annotation=''),
      annotation='',
    ),
    ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}), annotation=''),
  ),
  annotation='',
)

Oh boy! Aren’t you super excited to try this thing out?

1
2
3
>>> roll = r_best_3_of_3d6_d8.roll()
>>> tuple(roll.outcomes())
(1, 5, 6)

There are indeed three values, despite starting with four dice. Given that the lowest value we see is a 1, we might assume that the eliminated value is also a 1. But, we all know what happens when one assumes.

xkcd: When You Assume

Recall that in roll trees, a roll may have references to other rolls (its “source rolls”) from which it derives. We should be able to get information about the dropped die by traversing that tree. Let’s see if we can validate our assumption by looking at the outcomes from our roll’s direct source.

1
2
3
>>> from itertools import chain
>>> tuple(chain.from_iterable(source_roll.outcomes() for source_roll in roll.source_rolls))
(6, 1, 1, 5)

Yup! We were right! There’s the other 1, plain as day. Our work here is do—

What? You want to know which die we eliminated? We can see that, too!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
>>> roll
Roll(
  r=SelectionRoller(
    which=(slice(1, None, None),),
    sources=(
      RepeatRoller(
        n=3,
        source=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}), annotation=''),
        annotation='',
      ),
      ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}), annotation=''),
    ),
    annotation='',
  ),
  roll_outcomes=(
    ...

*snip* ✂️

147
148
  ),
)

Oof. ☝️ That was … a lot. Let’s visualize!

Roll ERD for dropping outcomes

Holy entangled relationship diagrams, Batman! One thing you may notice about our top-level roll is that it has four outcomes. One of those kids is not like the others. Specifically, it has a value of None. That’s our dropped outcome!

1
2
3
4
5
6
>>> len(roll) == 4
True
>>> roll[-1].value is None
True
>>> tuple(roll_outcome.value for roll_outcome in roll)
(1, 5, 6, None)

Info

A roll outcome with a value of None is akin to a “tombstone”. It conveys one whose sources were present in immediately prior rolls but excluded from the current roll. Such roll outcomes must have at least one source.

1
2
3
4
5
>>> from dyce.r import RollOutcome
>>> RollOutcome(value=None)
Traceback (most recent call last):
  ...
ValueError: value can only be None if sources is non-empty

The RollOutcome.euthanize method provides a convenient shorthand.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> RollOutcome(42).euthanize()
RollOutcome(
  value=None,
  sources=(
    RollOutcome(
      value=42,
      sources=(),
    ),
  ),
)

However, because such a roll signals its absence from the current roll, its value is not included by the Roll.outcomes method.

We can programmatically verify that the excluded outcome originated from one of the six-sided dice.

1
2
3
4
5
6
7
>>> excluded = roll[-1]
>>> excluded.value is None
True
>>> excluded.sources[0].value
1
>>> excluded.sources[0].r is r_d6
True

We can also verify that the 5 came from the eight-sided die.

1
2
3
4
5
>>> five = roll[1]
>>> five.value
5
>>> five.r is r_d8
True

Alternatively, could have also used our old friend the P object to eliminate the RepeatRoller for a similar, but structurally simpler result.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> from dyce import P
>>> R.select_from_values((slice(1, None),), 3@P(d6), d8).roll()
Roll(
  r=SelectionRoller(
    which=(slice(1, None, None),),
    sources=(
      ValueRoller(value=3@P(H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1})), annotation=''),
      ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}), annotation=''),
    ),
    annotation='',
  ),
  roll_outcomes=(
    ...

*snip* ✂️

79
80
  ),
)

Alternate Roll ERD for dropping outcomes

In this case, our results are still mostly traceable, since our pool is homogeneous. However, results from P.roll are sorted, meaning they lose association with their source histograms. This risks ambiguity. Consider:

1
2
>>> P(6, 8).roll()
(4, 6)

Is the 4 from the d6 or d8? 🤔💭 No one knows.

1
2
3
4
>>> R.from_value(P(6, 8))  # doctest: +SKIP
: UserWarning: using a heterogeneous pool (P(6, 8)) is not recommended where traceability is important
  ...
ValueRoller(value=P(6, 8), annotation='')

Filtering and substitution

dyce provides two additional rollers for outcome manipulation.

FilterRollers euthanize outcomes that don’t meet provided criteria.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
>>> r_filter = R.filter_from_values_iterable(
...   lambda outcome: bool(outcome.is_odd().value),
...   range(6),
... ) ; r_filter
FilterRoller(
  predicate=<function <lambda> at ...>,
  sources=(
    ValueRoller(value=0, annotation=''),
    ValueRoller(value=1, annotation=''),
    ValueRoller(value=2, annotation=''),
    ValueRoller(value=3, annotation=''),
    ValueRoller(value=4, annotation=''),
    ValueRoller(value=5, annotation=''),
  ),
  annotation='',
)
>>> roll = r_filter.roll()
>>> tuple(roll.outcomes())
(1, 3, 5)
>>> roll
Roll(
  r=...,
  roll_outcomes=(
    RollOutcome(
      value=None,
      sources=(
        RollOutcome(
          value=0,
          sources=(),
        ),
      ),
    ),
    RollOutcome(
      value=1,
      sources=(),
    ),
    RollOutcome(
      value=None,
      sources=(
        RollOutcome(
          value=2,
          sources=(),
        ),
      ),
    ),
    RollOutcome(
      value=3,
      sources=(),
    ),
    RollOutcome(
      value=None,
      sources=(
        RollOutcome(
          value=4,
          sources=(),
        ),
      ),
    ),
    RollOutcome(
      value=5,
      sources=(),
    ),
  ),
  source_rolls=(...),
)

Filtered ERD

SubstitutionRollers replace or append outcomes based on existing ones.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
>>> from dyce.r import CoalesceMode, SubstitutionRoller
>>> r_d6 = R.from_value(H(6))

>>> r_replace = SubstitutionRoller(
...   lambda outcome: r_d6.roll() if outcome.value == 1 else outcome,
...   r_d6,
...   max_depth=2,
... )
>>> r_replace.roll()
Roll(
  r=SubstitutionRoller(
    expansion_op=<function <lambda> at ...>,
    source=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}), annotation=''),
    coalesce_mode=<CoalesceMode.REPLACE: 1>,
    max_depth=2,
    annotation='',
  ),
  roll_outcomes=(
    RollOutcome(
      value=None,
      sources=(
        RollOutcome(
          value=1,
          sources=(),
        ),
      ),
    ),
    RollOutcome(
      value=None,
      sources=(
        RollOutcome(
          value=1,
          sources=(
            RollOutcome(
              value=1,
              sources=(),
            ),
          ),
        ),
      ),
    ),
    RollOutcome(
      value=2,
      sources=(
        RollOutcome(
          value=1,
          sources=(
            RollOutcome(
              value=1,
              sources=(),
            ),
          ),
        ),
      ),
    ),
  ),
  source_rolls=(...),
)

Replacement substitution ERD

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
>>> r_append = SubstitutionRoller(
...   lambda outcome: r_d6.roll() if outcome.value == 1 else outcome,
...   r_d6,
...   coalesce_mode=CoalesceMode.APPEND,
...   max_depth=2,
... )
>>> r_append.roll()
Roll(
  r=SubstitutionRoller(
    expansion_op=<function <lambda> at ...>,
    source=ValueRoller(value=H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1}), annotation=''),
    coalesce_mode=<CoalesceMode.APPEND: 2>,
    max_depth=2,
    annotation='',
  ),
  roll_outcomes=(
    RollOutcome(
      value=1,
      sources=(),
    ),
    RollOutcome(
      value=1,
      sources=(
        RollOutcome(
          value=1,
          sources=(),
        ),
      ),
    ),
    RollOutcome(
      value=2,
      sources=(
        RollOutcome(
          value=1,
          sources=(
            RollOutcome(
              value=1,
              sources=(),
            ),
          ),
        ),
      ),
    ),
  ),
  source_rolls=(...),
)

Appending substitution ERD

Performance

How much overhead do all these data structures contribute? It obviously depends on the complexity of the structure. Consider a simple example d20 + d12 + 4. Let’s do that 5,000 times, sort the results, and take every other one starting with the highest. We might use a pool, if we didn’t care about traceability. Let’s compare that to our roller.

1
2
3
4
5
%timeit (5000@P(H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1, 17: 1, 18: 1, 19: 1, 20: 1}) + H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1}) + 4)).roll()[::-2]
11.8 ms ± 39.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit (5000@(R.select_from_values((slice(None, None, -2),), H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1, 17: 1, 18: 1, 19: 1, 20: 1}), H({1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1}), 4))).roll()
110 ms ± 243 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
Source: perf_pools_vs_rollers.ipy
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from dyce import H, P, R

d20, d12 = H(20), H(12)

print(f"%timeit (5000@P({d20} + {d12} + 4)).roll()[::-2]")
p = 5000@P(d20 + d12 + 4)
%timeit p.roll()[::-2]
print()

print(f"%timeit (5000@(R.select_from_values((slice(None, None, -2),), {d20}, {d12}, 4))).roll()")
r = 5000@(R.select_from_values((slice(None, None, -2),), d20, d12, 4))
%timeit r.roll()
print()

In this particular case, our roller takes approximately ten times longer than our histogram pool. It is unsurprising that a simple roller is slower than a simple pool, at least in part because the math is deferred until R.roll time. In more sophisticated cases, rollers may be more competitive with (or even surpass) their histogram or pool analogies, especially when initialization time is taken into account.

All that being said, for periodic rolls simulating handfuls (not thousands) of operations or dice, such performance disparities probably won’t matter that much. Just use the primitives whose semantics work best for you. If ever performance becomes an issue, let me know, and we can collaborate on how to improve it.

Further exploration

Consider reviewing the roller API.


  1. If you’re not already familiar with histograms, consider skimming the counting tutorial

  2. We may still be discovering what those purposes are. We have the utmost faith they exist, even if they have yet to reveal themselves. If you discover one, consider contributing an example.