dyce provides two core primitives for enumeration1.
1
>>>fromdyceimportH,P
H objects represent histograms for modeling discrete outcomes.
They encode discrete probability distributions as integer counts without any denominator.
P objects represent pools (ordered sequences) of histograms.
If all you need is to aggregate outcomes (sums) from rolling a bunch of dice (or perform calculations on aggregate outcomes), H objects are probably sufficient.
If you need to select certain histograms from a group prior to computing aggregate outcomes (e.g., taking the highest and lowest of each possible roll of n dice), that’s where P objects come in.
As a wise person whose name has been lost to history once said: “Language is imperfect. If at all possible, shut up and point.”
So with that illuminating (or perhaps impenetrable) introduction out of the way, let’s dive into some examples!
Basic examples
A six-sided die can be modeled as:
12
>>>H(6)H(6)
H(n) is shorthand for explicitly enumerating outcomes \([{{1} .. {n}}]\), each with a frequency of 1.
12
>>>H(6)==H({1:1,2:1,3:1,4:1,5:1,6:1})True
Tuples with repeating outcomes are accumulated.
A six-sided “2, 3, 3, 4, 4, 5” die can be modeled as:
12
>>>H((2,3,3,4,4,5))H({2:1,3:2,4:2,5:1})
A fudge die can be modeled as:
12
>>>H((-1,0,1))H({-1:1,0:1,1:1})
Python’s matrix multiplication operator (@) is used to express the number of a particular die (roughly equivalent to the “d” operator in common notations). The outcomes of rolling two six-sided dice (2d6) are:
The results show there is one way to make 18, two ways to make 21, three ways to make 24, etc.
Histograms provide rudimentary formatting for convenience.
Histograms should be sufficient for most calculations.
However, pools are useful for “taking” (selecting) only some of each roll’s outcomes.
This is done by providing one or more index arguments to the P.h method or the P.rolls_with_counts method.
Indexes can be integers, slices, or a mix thereof.
Outcome indexes are ordered from least to greatest with negative values counting from the right, as one would expect (i.e., [0], [1], …, [-2], [-1]).
Summing the least two faces when rolling three six-sided dice would be:
1234
>>>3@P(6)P(6,6,6)>>>(3@P(6)).h(0,1)# see warning below about parenthesesH({2:16,3:27,4:34,5:36,6:34,7:27,8:19,9:12,10:7,11:3,12:1})
Mind your parentheses
Parentheses are needed in the above example because @ has a lower precedence than . and […].
123456
>>>2@P(6).h(1)# equivalent to 2@(P(6).h(1))Traceback(mostrecentcalllast):...IndexError:tupleindexoutofrange>>>(2@P(6)).h(1)H({1:1,2:3,3:5,4:7,5:9,6:11})
Taking the least, middle, or greatest face when rolling three six-sided dice would be:
>>>d10=H(10)-1;d10# a common “d10” with faces [0 .. 9]H({0:1,1:1,2:1,3:1,4:1,5:1,6:1,7:1,8:1,9:1})>>>h=P(4,6,8,d10,12,20).h(0,-1)>>>print(h.format(width=65,scaled=True))avg|13.48std|4.40var|19.391|0.00%|2|0.01%|3|0.06%|4|0.30%|#5|0.92%|#####6|2.03%|###########7|3.76%|####################8|5.57%|##############################9|7.78%|###########################################10|8.99%|##################################################11|8.47%|###############################################12|8.64%|################################################13|8.66%|################################################14|6.64%|####################################15|5.62%|###############################16|5.16%|############################17|5.00%|###########################18|5.00%|###########################19|5.00%|###########################20|5.00%|###########################21|4.50%|#########################22|2.01%|###########23|0.73%|####24|0.18%|
Both histograms and pools support various comparison operations as well as substitution.
The odds of observing all even faces when rolling \(n\) six-sided dice, for \(n\) in \([1..6]\) is:
The outer ring and corresponding labels can be overridden for interesting, at-a-glance displays.
Overrides apply counter-clockwise, starting from the 12 o‘clock position.
The outer ring can also be used to compare two histograms directly.
Ever been curious how your four shiny new fudge dice stack up against your trusty ol’ double six-siders?
Well wonder no more!
The dyce abides.
Risus and its many community-developed alternative rules not only make for entertaining reading, but are fertile ground for stressing ergonomics and capabilities of any discrete outcome modeling tool.
We can easily model its opposed combat system for various starting configurations through the first round.
1 2 3 4 5 6 7 8 9101112131415161718
>>>fortheminrange(3,6):...print("---")...forusinrange(them,them+3):...first_round=(us@H(6)).vs(them@H(6))# -1 is a loss, 0 is a tie, 1 is a win...results=first_round.format(width=0)...print(f"{us}d6 vs {them}d6: {results}")---3d6vs3d6:{...,-1:45.36%,0:9.28%,1:45.36%}4d6vs3d6:{...,-1:19.17%,0:6.55%,1:74.28%}5d6vs3d6:{...,-1:6.07%,0:2.99%,1:90.93%}---4d6vs4d6:{...,-1:45.95%,0:8.09%,1:45.95%}5d6vs4d6:{...,-1:22.04%,0:6.15%,1:71.81%}6d6vs4d6:{...,-1:8.34%,0:3.26%,1:88.40%}---5d6vs5d6:{...,-1:46.37%,0:7.27%,1:46.37%}6d6vs5d6:{...,-1:24.24%,0:5.79%,1:69.96%}7d6vs5d6:{...,-1:10.36%,0:3.40%,1:86.24%}
This highlights the mechanic’s notorious “death spiral”, which we can visualize as a heat map.
With a little elbowfinger grease, we can roll up our…erm…fingerless gloves and even model various starting configurations through to completion to get a better sense of the impact of any initial disparity (in this case, applying dynamic programming to avoid redundant computations).
>>>fromtypingimportCallable,Dict,Tuple>>>defrisus_combat_driver(...us:int,...them:int,...us_vs_them_func:Callable[[int,int],H],...)->H:...ifus<0orthem<0:...raiseValueError("cannot have negative numbers (us: {}, them: {})".format(us,them))...ifus==0andthem==0:...returnH({0:1})# should not happen unless combat(0, 0) is called from the start...already_solved:Dict[Tuple[int,int],H]={}......def_resolve(us:int,them:int)->H:...if(us,them)inalready_solved:returnalready_solved[(us,them)]...elifus==0:returnH({-1:1})# we are out of dice, they win...elifthem==0:returnH({1:1})# they are out of dice, we win...this_round=us_vs_them_func(us,them)......def_next_round(_:H,outcome)->H:...ifoutcome<0:return_resolve(us-1,them)# we lost this round, and one die...elifoutcome>0:return_resolve(us,them-1)# they lost this round, and one die...else:returnH({})# ignore (immediately reroll) all ties......already_solved[(us,them)]=this_round.substitute(_next_round)...returnalready_solved[(us,them)]......return_resolve(us,them)>>>fortinrange(3,6):...print("---")...foruinrange(t,t+3):...results=risus_combat_driver(...u,t,...lambdau,t:(u@H(6)).vs(t@H(6))...).format(width=0)...print(f"{u}d6 vs {t}d6: {results}")---3d6vs3d6:{...,-1:50.00%,1:50.00%}4d6vs3d6:{...,-1:10.50%,1:89.50%}5d6vs3d6:{...,-1:0.66%,1:99.34%}---4d6vs4d6:{...,-1:50.00%,1:50.00%}5d6vs4d6:{...,-1:12.25%,1:87.75%}6d6vs4d6:{...,-1:1.07%,1:98.93%}---5d6vs5d6:{...,-1:50.00%,1:50.00%}6d6vs5d6:{...,-1:13.66%,1:86.34%}7d6vs5d6:{...,-1:1.49%,1:98.51%}
Using our risus_combat_driver from above, we can model the less death-spirally “Best of Set” alternative mechanic from The Risus Companion with the optional “Goliath Rule”.
1 2 3 4 5 6 7 8 9101112131415161718192021
>>>defdeadly_combat_vs(us:int,them:int)->H:...best_us=(us@P(6)).h(-1)...best_them=(them@P(6)).h(-1)...h=best_us.vs(best_them)...# Goliath rule for resolving ties...h=h.substitute(lambdah,outcome:(us<them)-(us>them)ifoutcome==0elseoutcome)...returnh>>>fortinrange(3,5):...print("---")...foruinrange(t,t+3):...results=risus_combat_driver(u,t,deadly_combat_vs).format(width=0)...print(f"{u}d6 vs {t}d6: {results}")---3d6vs3d6:{...,-1:50.00%,1:50.00%}4d6vs3d6:{...,-1:36.00%,1:64.00%}5d6vs3d6:{...,-1:23.23%,1:76.77%}---4d6vs4d6:{...,-1:50.00%,1:50.00%}5d6vs4d6:{...,-1:40.67%,1:59.33%}6d6vs4d6:{...,-1:30.59%,1:69.41%}
Modeling the “Evens Up” alternative dice mechanic is currently beyond the capabilities of dyce without additional computation.
This is for two reasons.
First, dyce only provides mechanisms to approximate outcomes through a fixed number of iterations (not an infinite series).
Most of the time, this is good enough.
Second, with one narrow exception, dyce only provides a mechanism to substitute outcomes, not counts.
Both of these limitations can be circumvented where distributions can be computed and encoded as histograms.
For this mechanic, we can observe that a single six-sided die (1d6) has a \(\frac{1}{2}\) chance of coming up even, thereby earning a “success”.
We can also observe that it has a \(\frac{1}{6}\) chance of showing a six, earning an additional roll.
That second roll has a \(\frac{1}{2}\) chance of coming up even, as well as a \(\frac{1}{6}\) chance of earning another roll, and so on.
In other words, the number of successes you can expect to roll are:
So what is that? We probably don’t know unless we do math for a living, or at least as an active hobby.
(The author does neither, which is partially what motivated the creation of this library.)
Computing the value to the first hundred iterations offers a clue.
It appears convergent around \(\frac{3}{5}\).
Let’s see if we can validate that.
An article from MathIsFun.com provides useful guidance.
The section on geometric series is easily adapted to our problem.
\[
S - \frac{1}{6}S = \frac{5}{6}S = \frac{1}{2}
\]
\[
S = \frac{6}{10} = \frac{3}{5}
\]
Well, butter my butt and call me a biscuit! Math really is fun! 🧈 🤠 🧮
Info
The Archimedean visualization technique mentioned in the aforementioned article also adapts well to this case.
It involves no algebra and is left as an exercise to the reader…at least one with nothing more pressing to do.
Armed with this knowledge, we can now model “Evens Up” using our risus_combat_driver from above.
1 2 3 4 5 6 7 8 910111213141516171819202122
>>>fromfunctoolsimportpartial>>>d6_evens_exploding_on_six=H({1:3,0:2})# 3 dubyas, 2 doughnuts>>>defevens_up_vs(us:int,them:int,goliath:bool=False)->H:...h=(us@d6_evens_exploding_on_six).vs(them@d6_evens_exploding_on_six)...ifgoliath:...h=h.substitute(lambdah,outcome:(us<them)-(us>them)ifoutcome==0elseoutcome)...returnh>>>fortinrange(3,5):...print("---")...foruinrange(t,t+3):...results=risus_combat_driver(u,t,partial(evens_up_vs,goliath=True)).format(width=0)...print(f"{u}d6 vs {t}d6: {results}")---3d6vs3d6:{...,-1:50.00%,1:50.00%}4d6vs3d6:{...,-1:27.49%,1:72.51%}5d6vs3d6:{...,-1:9.27%,1:90.73%}---4d6vs4d6:{...,-1:50.00%,1:50.00%}5d6vs4d6:{...,-1:28.50%,1:71.50%}6d6vs4d6:{...,-1:10.50%,1:89.50%}
Time to get meta-evil on those outcomes!
dyce offers best-effort support for arbitrary number-like outcomes, including primitives from symbolic expression packages such as SymPy.
Be aware that performance can be quite slow, however.
dyce remains opinionated about ordering.
For non-critical contexts, dyce will attempt a “natural” ordering based on the string representation of each outcome if relative values are indeterminate.
This is to accommodate symbolic expressions whose relative values are often unknowable.
1 2 3 4 5 6 7 8 91011121314151617
>>>expr=sympy.abc.x<sympy.abc.x*3;exprx<3*x>>>bool(expr)# nopeTraceback(mostrecentcalllast):...TypeError:cannotdeterminetruthvalueofRelational>>>s=sympy.abc.x-1>>>d3x=H(3)*sympy.abc.x>>>d3x2=H(i*sympy.abc.xforiinrange(3,0,-1))>>>d3x==d3x2# still results in consistent orderingTrue>>>P(d3x,d3x2)P(H({2*x:1,3*x:1,x:1}),H({2*x:1,3*x:1,x:1}))>>>sympy.abc.x*3ind3xTrue>>>sympy.abc.x-1ind3xFalse
SymPy, for example, does not attempt simple relative comparisons between symbolic expressions, even where they are unambiguously resolvable.
Instead, it relies on the caller to invoke its proprietary solver APIs.
dyce, of course, is happily ignorant of all that keenness.
(As it should be.)
In practice, that means that certain operations won’t work with symbolic expressions where correctness depends on ordering outcomes according to relative value (e.g., dice selection from pools).
dyce also provides additional primitives (R objects and their kin) which are useful for producing weighted randomized rolls without the overhead enumeration.
These are covered seperately. ↩