The following examples and translations are intended to showcase dyce’s flexibility.
If you have exposure to another tool, they may also help with transition.
We need to map semantic outcomes to numbers (and back again).
How can we represent those in dyce?
One way is IntEnums.
IntEnums have a property that allows them to substitute directly for ints, which is very convenient.
>>>fromenumimportIntEnum>>>classComplication(IntEnum):...NONE=0# this will come in handy later...COMMON=1...UNCOMMON=2...RARE=3...VERY_RARE=4>>>OUTCOME_TO_RARITY_MAP={...2:Complication.VERY_RARE,...3:Complication.VERY_RARE,...4:Complication.VERY_RARE,...5:Complication.RARE,...6:Complication.RARE,...7:Complication.UNCOMMON,...8:Complication.UNCOMMON,...9:Complication.COMMON,...10:Complication.COMMON,...11:Complication.COMMON,...12:Complication.COMMON,...13:Complication.COMMON,...14:Complication.UNCOMMON,...15:Complication.UNCOMMON,...16:Complication.RARE,...17:Complication.RARE,...18:Complication.VERY_RARE,...19:Complication.VERY_RARE,...20:Complication.VERY_RARE,...}
Now let’s use our map to validate the probabilities of a particular outcome using that d8 and d12.
Well butter my butt, and call me a biscuit!
That Angry guy sure knows his math!
Modeling Ironsworn’s core mechanic
Shawn Tomlin’s Ironsworn melds a number of different influences in a fresh way.
Its core mechanic involves rolling an action die (a d6), adding a modifier, and comparing the resulting value to two challenge dice (d10s).
If the modified value from the action die is strictly greater than both challenge dice, the result is a strong success.
If it is strictly greater than only one challenge die, the result is a weak success.
If it is equal to or less than both challenge dice, it’s a failure.
A brute force way to model this is to enumerate the product of the three dice and then perform logical comparisons.
>>>fromdyceimportH,as_int>>>fromnumerary.typesimportRealLikeSCU>>>fromenumimportIntEnum,auto>>>fromtypingimportIterator,Tuple,cast>>>d6=H(6)>>>d10=H(10)>>>classIronResult(IntEnum):...FAILURE=0...WEAK_SUCCESS=auto()...STRONG_SUCCESS=auto()>>>defiron_results_brute_force(mod:int=0)->Iterator[Tuple[RealLikeSCU,int]]:...action_die=d6+mod...foraction,action_countincast(Iterator[Tuple[int,int]],action_die.items()):...forfirst_challenge,first_challenge_countincast(Iterator[Tuple[int,int]],d10.items()):...forsecond_challenge,second_challenge_countincast(Iterator[Tuple[int,int]],d10.items()):...action_beats_first_challenge=action>first_challenge...action_beats_second_challenge=action>second_challenge...ifaction_beats_first_challengeandaction_beats_second_challenge:...outcome=IronResult.STRONG_SUCCESS...elifaction_beats_first_challengeoraction_beats_second_challenge:...outcome=IronResult.WEAK_SUCCESS...else:...outcome=IronResult.FAILURE...yieldoutcome,action_count*first_challenge_count*second_challenge_count>>># By choosing our function's return type with care, we can lean on H.__init__ for>>># the accounting. (That's what it's there for!)>>>iron_distributions_by_mod={...mod:H(iron_results_brute_force(mod))formodinrange(5)...}>>>formod,iron_distributioniniron_distributions_by_mod.items():...print("{:+} -> {}".format(mod,{...IronResult(cast(int,outcome)).name:f"{float(prob):6.2%}"...foroutcome,probiniron_distribution.distribution()...}))+0->{'FAILURE':'59.17%','WEAK_SUCCESS':'31.67%','STRONG_SUCCESS':' 9.17%'}+1->{'FAILURE':'45.17%','WEAK_SUCCESS':'39.67%','STRONG_SUCCESS':'15.17%'}+2->{'FAILURE':'33.17%','WEAK_SUCCESS':'43.67%','STRONG_SUCCESS':'23.17%'}+3->{'FAILURE':'23.17%','WEAK_SUCCESS':'43.67%','STRONG_SUCCESS':'33.17%'}+4->{'FAILURE':'15.17%','WEAK_SUCCESS':'39.67%','STRONG_SUCCESS':'45.17%'}
While relatively straightforward, this approach is a little verbose.
If we recognize that our problem involves a dependent probability, we can re-characterize our solution in terms of H.substitute.
We can also deploy a counting trick with the two d10s.
Let’s break it down.
H(10).lt(value) will tell us how often a single d10 is less than value.
12
>>>H(10).lt(5)# how often a d10 is strictly less than 5H({False:6,True:4})
By summing those results (and taking advantage of the fact that, in Python, bools act like ints when it comes to arithmetic operators), we can count how often that happens with more than one interchangeable d10s.
How do we interpret those results?
36 times out of a hundred, neither d10 will be strictly less than five.
48 times out of a hundred, exactly one of the d10s will be strictly less than five.
16 times out of a hundred, both d10s will be strictly less than five.
>>># The parentheses are technically redundant, but clarify the intention>>>2@(H(10).lt(5))==H(10).lt(5)+H(10).lt(5)True
Why doesn’t 2@(H(6).gt(H(10)) work?
H(6).gt(H(10)) will compute how often a six-sided die is strictly greater than a ten-sided die.
2@(H(6).gt(H(10))) will show the frequencies that a first six-sided die is strictly greater than a first ten-sided die and a second six-sided die is strictly greater than a second ten-sided die.
This isn’t quite what we want, since the mechanic calls for rolling a single six-sided die and comparing that result to each of two ten-sided dice.
Other than resolve_dependent_probability and H.substitute, dyce does not provide an internal mechanism for handling dependent probabilities, so we either have to use that or find another way to express the dependency.
Now for a twist.
In cooperative or solo play, a failure or success is particularly spectacular when the d10s come up doubles.
The trick to mapping that to dyce internals (beyond the brute force approach) is recognizing that we have a dependent probability that involves three independent variables: the (modded) d6, a first d10, and a second d10.
resolve_dependent_probability is especially useful where there are multiple independent terms.
The interior of our dependent term ends up looking similar to our brute force solution of the simpler mechanic above, but without the low-level accounting.
>>>classIronSoloResult(IntEnum):...SPECTACULAR_FAILURE=-1...FAILURE=auto()...WEAK_SUCCESS=auto()...STRONG_SUCCESS=auto()...SPECTACULAR_SUCCESS=auto()>>>defiron_solo_dependent_term(action,first_challenge,second_challenge,mod=0):...modded_action=action+mod...beats_first_challenge=modded_action>first_challenge...beats_second_challenge=modded_action>second_challenge...doubles=first_challenge==second_challenge...ifbeats_first_challengeandbeats_second_challenge:...returnIronSoloResult.SPECTACULAR_SUCCESSifdoubleselseIronSoloResult.STRONG_SUCCESS...elifbeats_first_challengeorbeats_second_challenge:...returnIronSoloResult.WEAK_SUCCESS...else:...returnIronSoloResult.SPECTACULAR_FAILUREifdoubleselseIronSoloResult.FAILURE>>>fromdyce.himportresolve_dependent_probability>>>resolve_dependent_probability(...iron_solo_dependent_term,# mod defaults to 0...action=d6,...first_challenge=d10,...second_challenge=d10,...)H({<IronSoloResult.SPECTACULAR_FAILURE:-1>:9,<IronSoloResult.FAILURE:0>:62,<IronSoloResult.WEAK_SUCCESS:1>:38,<IronSoloResult.STRONG_SUCCESS:2>:8,<IronSoloResult.SPECTACULAR_SUCCESS:3>:3})
We can deploy a technique involving partial to parameterize our mod value, which is helpful for visualization.
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 the first round of its opposed combat system for various starting configurations.
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).
>>>fromdyceimportH,P>>>fromtypingimportCallable,Dict,Tuple>>>defrisus_combat_driver(...us:int,# number of dice we still have...them:int,# number of dice they still have...us_vs_them_func:Callable[[int,int],H],...)->H:...ifus<0orthem<0:...raiseValueError(f"cannot have negative numbers (us: {us}, them: {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 re-roll) 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%}
There’s lot going on there.
Let’s dissect it.
123456
defrisus_combat_driver(us:int,# number of dice we still havethem:int,# number of dice they still haveus_vs_them_func:Callable[[int,int],H],)->H:...
Our “driver” takes three arguments:
How many dice we have left (us);
How many dice the opposition has left (them); and
A resolution function (us_vs_them_func) that takes counts of each party’s remaining dice and returns a histogram encoding the probability of winning or losing a single round akin to the H.vs method:
An outcome of -1 signals the likelihood of the opposition’s victory
An outcome of 1 signals the likelihood of our victory.
An outcome of 0 signals the likelihood of a tie.
7 8 91011
ifus<0orthem<0:raiseValueError(f"cannot have negative numbers (us: {us}, them: {them})")ifus==0andthem==0:returnH({0:1})# should not happen unless combat(0, 0) is called from the startalready_solved:Dict[Tuple[int,int],H]={}
We make some preliminary checks that guard access to our recursive implementation so that it can be a little cleaner.
We also set up a dict to cache results we‘ve already computed.
For example, we might compute a case where we lose a die, then our opposition loses a die.
We arrive at a similar case where our opposition loses a die, then we lose a die.
Both cases would be identical from that point on.
We’ll detect this redundancy and avoid recomputing identical sub-trees.
1314
def_resolve(us:int,them:int)->H:...
27
return_resolve(us,them)
Skipping over its implementation for now, we define a our recursive implementation (_resolve) and then call it with our initial arguments.
13141516
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 winelifthem==0:returnH({1:1})# they are out of dice, we win
Getting back to that implementation, these are our base cases.
First we check to see if we’ve already solved for this case (memoization), in which case we can just return it.
Then we check whether either party has run out of dice, in which case the combat is over.
If we have none of those cases, we get to work.
Note
In this function, we do not check for the case where both parties are at zero.
Because only one party can lose a die during each round, the only way both parties can be at zero simultaneously is if they both started at zero.
Since we guard against that case in the enclosing function, we don’t have to worry about it here.
Either us is zero, them is zero, or neither is zero.
17
this_round=us_vs_them_func(us,them)
Then, we compute the outcomes for this round using the provided resolution function.
Keeping in mind that we’re inside our recursive implementation, we define a substitution function specifically for use with H.substitute.
This allows us to take our computation for this round, and “fold in” subsequent rounds.
We keep track of the result in our memoization dict before returning it.
19202122
def_next_round(__:H,outcome)->H:ifoutcome<0:return_resolve(us-1,them)# we lost this round, and one dieelifoutcome>0:return_resolve(us,them-1)# they lost this round, and one dieelse:returnH({})# ignore (immediately re-roll) all ties
Our substitution function is pretty straightforward.
Where we are asked whether we want to provide a substitution for a round we lost, we lose a die and recurse.
Where we are asked for a substitution for a round we won, our opposition loses a die and we recurse.
We ignore ties (simulating that we re-roll them in place until they are no longer ties).
At this point, we can define a simple lambda that wraps H.vs and submit it to our driver to enumerate resolution outcomes from various starting positions.
Note
This is a complicated example that involves some fairly sophisticated programming techniques (recursion, memoization, closures, etc.).
The point is not to suggest that such techniques are required to be productive.
However, it is useful to show that dyce is flexible enough to model these types of outcomes in a couple dozen lines of code.
It is high-level enough to lean on for nuanced number crunching without a lot of detailed knowledge, while still being low-level enough that authors knowledgeable of advanced programming techniques are not precluded from using them.
Modeling different combat resolution methods
Using our risus_combat_driver from above, we can craft a alternative resolution function to model the less death-spirally “Best of Set” alternative mechanic from The Risus Companion with the optional “Goliath Rule” for resolving ties.
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: tie goes to the party with fewer dice in this round...h=h.substitute(lambda__,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%}
The “Evens Up” alternative dice mechanic presents some challenges.
First, dyce’s substitution mechanism only resolves outcomes through a fixed number of iterations, so it can only approximate probabilities for infinite series.
Most of the time, the implications are largely theoretical with a sufficient number of iterations.
This is no exception.
Second, with one narrow exception, dyce only provides a mechanism to directly substitute outcomes, not counts.
This means we can’t arbitrarily increase the likelihood of achieving a particular outcome through replacement.
With some creativity, we can work around that, too.
In the case of “Evens Up”, we need to keep track of whether an even number was rolled, but we also need to keep rolling (and accumulating) as long as sixes are rolled.
This behaves a lot like an exploding die with three values (miss, hit, and hit-and-explode).
Further, we can observe that every “run” will be zero or more exploding hits terminated by either a miss or a non-exploding hit.
If we choose our values carefully, we can encode how many times we’ve encountered relevant events as we explode.
For every value that is even, we ended in a miss.
For every value that is odd, we ended in a hit that will need to be tallied.
Dividing by two and ignoring any remainder will tell us how many exploding hits we had along the way.
1 2 3 4 5 6 7 8 910111213
>>>defdecode_hits(__,outcome):...return(outcome+1)//2# equivalent to outcome // 2 + outcome % 2>>>d_evens_up=d_evens_up_exploded.substitute(decode_hits)>>>print(d_evens_up.format())avg|0.60std|0.69var|0.480|50.00%|#########################1|41.67%|####################2|6.94%|###3|1.16%|4|0.23%|
We can now approximate a complete “Evens Up” combat using our risus_combat_driver from above.
We can also deploy a trick using partial to parameterize use of the Goliath Rule.
1 2 3 4 5 6 7 8 910111213141516171819202122
>>>fromfunctoolsimportpartial>>>defevens_up_vs(us:int,them:int,goliath:bool=False)->H:...h=(us@d_evens_up).vs(them@d_evens_up)...ifgoliath:...h=h.substitute(lambda__,outcome:(us<them)-(us>them)ifoutcome==0elseoutcome)...returnh>>>fortinrange(3,5):...print("----------- ---- With Goliath Rule ----- --- Without Goliath Rule ---")...foruinrange(t,t+3):...goliath_results=risus_combat_driver(u,t,partial(evens_up_vs,goliath=True)).format(width=0)...no_goliath_results=risus_combat_driver(u,t,partial(evens_up_vs,goliath=False)).format(width=0)...print(f"{u}d6 vs {t}d6: {goliath_results}{no_goliath_results}")---------------WithGoliathRule--------WithoutGoliathRule---3d6vs3d6:{...,-1:50.00%,1:50.00%}{...,-1:50.00%,1:50.00%}4d6vs3d6:{...,-1:29.51%,1:70.49%}{...,-1:19.08%,1:80.92%}5d6vs3d6:{...,-1:12.32%,1:87.68%}{...,-1:4.57%,1:95.43%}---------------WithGoliathRule--------WithoutGoliathRule---4d6vs4d6:{...,-1:50.00%,1:50.00%}{...,-1:50.00%,1:50.00%}5d6vs4d6:{...,-1:30.52%,1:69.48%}{...,-1:21.04%,1:78.96%}6d6vs4d6:{...,-1:13.68%,1:86.32%}{...,-1:5.88%,1:94.12%}
>>>fromdyceimportH,P>>>res1=3@H(6)>>>p_4d6=4@P(6)>>>res2=p_4d6.h(slice(1,None))# discard the lowest die (index 0)>>>d6_reroll_first_one=H(6).substitute(lambda__,outcome:H(6)ifoutcome==1elseoutcome)>>>p_4d6_reroll_first_one=(4@P(d6_reroll_first_one))>>>res3=p_4d6_reroll_first_one.h(slice(1,None))# discard the lowest>>>p_4d6_reroll_all_ones=4@P(H((2,3,4,5,6)))>>>res4=p_4d6_reroll_all_ones.h(slice(1,None))# discard the lowest>>>res5=2@H(6)+6>>>res6=4@H(4)+2
>>>fromdyceimportH>>>single_attack=2@H(6)+5>>>defgwf(h:H,outcome):...returnhifoutcomein(1,2)elseoutcome>>>great_weapon_fighting=2@(H(6).substitute(gwf))+5# reroll either die if it is a one or two>>>print(single_attack.format(width=0)){...,7:2.78%,8:5.56%,9:8.33%,10:11.11%,11:13.89%,12:16.67%,13:13.89%,...}>>>print(great_weapon_fighting.format(width=0)){...,7:0.31%,8:0.62%,9:2.78%,10:4.94%,11:9.88%,12:14.81%,13:17.28%,...}
>>>importmatplotlib# doctest: +SKIP>>>fordepthinrange(6):...res=(10@P(H(10).explode(max_depth=depth))).h(slice(-3,None))...matplotlib.pyplot.plot(...*res.distribution_xy(),...marker=".",...label=f"{depth} rerolls",...)# doctest: +SKIPmatplotlib.pyplot.legend()# doctest: +SKIP>>>matplotlib.pyplot.title("Modeling taking the three highest of ten exploding d10s")# doctest: +SKIP>>>matplotlib.pyplot.show()# doctest: +SKIP
function: dupes in DICE:s {
D: 0
loop X over {2..#DICE} {
if ((X-1)@DICE = X@DICE) { D: D + 1}
}
result: D
}
Translation:
1 2 3 4 5 6 7 8 910111213
>>>fromdyceimportH,P>>>defdupes(p:P):...forroll,countinp.rolls_with_counts():...dupes=0...foriinrange(1,len(roll)):...# Outcomes are ordered, so we only have to look at one neighbor...ifroll[i]==roll[i-1]:...dupes+=1...yielddupes,count>>>res_15d6=H(dupes(15@P(6)))>>>res_8d10=H(dupes(8@P(10)))
Visualization:
1 2 3 4 5 6 7 8 910111213
>>>importmatplotlib# doctest: +SKIP>>>matplotlib.pyplot.plot(...*res_15d6.distribution_xy(),...marker="o",...label="15d6",...)# doctest: +SKIP>>>matplotlib.pyplot.plot(...*res_8d10.distribution_xy(),...marker="o",...label="8d10",...)# doctest: +SKIP>>>matplotlib.pyplot.legend()# doctest: +SKIP>>>matplotlib.pyplot.title("Chances of rolling $n$ duplicates")# doctest: +SKIP
function: brawl A:s vs B:s {
SA: A >= 1@B
SB: B >= 1@A
if SA-SB=0 {
result:(A > B) - (A < B)
}
result:SA-SB
}
output [brawl 3d6 vs 3d6] named "A vs B Damage"
function: set element I:n in SEQ:s to N:n {
NEW: {}
loop J over {1 .. #SEQ} {
if I = J { NEW: {NEW, N} }
else { NEW: {NEW, J@SEQ} }
}
result: NEW
}
function: brawl A:s vs B:s with optional swap {
if #A@A >= 1@B {
result: [brawl A vs B]
}
AX: [sort [set element #A in A to 1@B]]
BX: [sort [set element 1 in B to #A@A]]
result: [brawl AX vs BX]
}
output [brawl 3d6 vs 3d6 with optional swap] named "A vs B Damage"