Rollin’ with rollers and rolls
Experimental
This functionality should be considered experimental. Be warned that future release may introduce incompatibilities or remove it altogether. Suggestions and contributions are welcome.
dyce
provides additional primitives useful for generating and inspecting rolls of weighted random outcomes without requiring the overhead of enumeration.
1 |
|
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).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Hopefully, that’s relatively straightforward. Let’s look at some more substantial examples.
Emulating a hundred-sided die using two ten-sided dice
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 |
|
Next, we create a roller using the R.from_values
class method.
1 2 3 4 5 6 7 8 |
|
Well, wouldya look at that? That durned class method created a whole roller tree, which is actually three rollers.
- One
ValueRoller
for thed00
histogram; - Another for the
d10
histogram; and - 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 |
|
Let’s use our new roller to create a roll and retrieve its total.
1 2 3 |
|
No surprises there. Let’s dig a little deeper and ask for the roll’s outcome values.
1 2 |
|
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
.
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 |
|
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.”
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 43 44 45 46 47 48 49 50 51 52 |
|
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:
- A reference to the
R
object in the roller tree that generated it, retrieved via itsr
property; - Zero or more
RollOutcome
objects, retrieved by accessing the roll as a sequence (i.e., via__getitem__
,__len__
); and - Zero or more source rolls, retrieved via its
sources
property.
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 weak reference back to the roll that generated it, retrieved via its
roll
property (omitted from the diagram for the sake of readability).
Composing rollers with arithmetic
Rollers support arithmetic operators.
1 2 3 4 5 6 7 8 |
|
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 |
|
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 ValueRoller
s for histograms representing our six- and eight-sided dice.
1 2 3 4 5 6 |
|
For homogeneous pools, we can use the matrix multiplication operator.
1 2 3 4 5 6 |
|
Finally, we’ll create a SelectionRoller
by calling the R.select_from_rs
method on our other rollers.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Oh boy! Aren’t you super excited to try this thing out?
1 2 3 |
|
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.
Recall that in roll trees, a roll may have references to other rolls (its “sources”) 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 |
|
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 |
|
*snip* ✂️
147 148 |
|
Oof. ☝️ That was … a lot. Let’s visualize!
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 |
|
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.
This implies that such a roll outcome must have at least one source.
That constraint is enforced.
1 2 3 4 5 |
|
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 8 9 10 11 12 13 14 |
|
We can also verify that the 5
came from the eight-sided die.
1 2 3 4 5 6 7 8 9 10 |
|
Alternatively, could have also used our old friend the P
object to eliminate the RepeatRoller
for a similar, but slightly simpler result.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
*snip* ✂️
79 80 |
|
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 |
|
Is the 3
from the d6
or d8
? 🤔💭
No one knows.
1 2 3 4 |
|
Lean(er) rolls — what if we don’t care about all this transparency?
What if all we wanted to do was generate some rolls, but we’re not interested in their detailed origin stories or that ridiculous entanglement of cross-references all the way down?
In almost all cases, P
and H
objects will get you were you need to go.
Start there.
(See also the section on performance below.)
If you really want to use rollers to generate rolls without histories for some reason, Roll.__init__
will surgically remove any source references from its arguments if its roller argument r is annotated with None
.
This has the effect that rollers annotated with None
will appear to generate “flat” rolls.
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 |
|
This isn’t magic.
It is merely a way to cleave away any accumulated history at the point a Roll
object is constructed by an appropriately annotated roller.
To prevent all history accumulation for a roller tree, every roller in that tree must be annotated with None
.
Warning
Technically, this violates the immutability of roll outcomes.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
dyce
does not generally contemplate creation of rolls or roll outcomes outside the womb of R.roll
implementations.
Roll
and RollOutcome
objects generally mate for life, being created exclusively for (and in close proximity to) one another.
A roll manipulating a roll outcome’s internal state post construction may seem unseemly, but that intimacy is a fundamental part of their primordial ritual.
More practically, it frees each roller from having to do its own cleaving.
That being said, you’re an adult. Do what you want. Just know that if you’re going to construct your own roll outcomes and pimp them out to different rolls all over town, they might come back with some parts missing.
(See also the RollOutcome.roll
property.)
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.
1 2 3 4 5 6 7 8 |
|
That’s not bad. Is a significant improvement possible?
1 2 3 4 5 6 7 8 9 10 |
|
That’s pretty close, even when including the repeated sum
.
What about those frequencies?
Do we need them?
As is the case with most traditional dice, ours are all 1
s in this example.
Can we eliminate that redundancy?
1 2 |
|
That’s …
Pooling histograms clearly adds some overhead over a targeted, native solution. What about rollers?
1 2 3 4 |
|
In this particular case, our roller takes about ten times longer than our histogram pool and about forty times longer than our bespoke one-liner.
It is unsurprising that a roller is slower, since the math is deferred until R.roll
time.
But there are also likely inefficiencies from generalization and tree creation.
In other cases, rollers may be more competitive with their histogram pool analogies.
1 2 3 4 5 6 7 8 9 |
|
All that being said, for periodic rolls simulating handfuls (not thousands) of 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 us know, and we can collaborate on how to improve it.
Further exploration
Consider reviewing the roller API.
-
If you’re not already familiar with histograms, consider skimming the counting tutorial. ↩
-
We may still be discovering what those are. We have the utmost faith such purposes exist, even if they have yet to reveal themselves. If you discover one, consider contributing an example. ↩