Skip to content

Applications and translations

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.

Checking Angry’s math on the Tension Pool

In the Angry GM’s publication of the PDF version of his Tension Pool mechanic, he includes some probabilities. Can dyce check his work? You bet!

Let’s reproduce his tables (with slightly different names to provide context).

d6s in pool Angry’s probability of at least one 1 showing
1 16.7%
2 30.6%
3 42.1%
4 51.8%
5 59.8%
6 66.5%

How do we do compute these results using dyce?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> from dyce import H
>>> one_in_d6 = H(6).eq(1)
>>> for n in range(1, 7):
...     ones_in_nd6 = n @ one_in_d6
...     at_least_one_one_in_nd6 = ones_in_nd6.ge(1)
...     print(f"{n}: {at_least_one_one_in_nd6[1] / at_least_one_one_in_nd6.total:6.2%}")
1: 16.67%
2: 30.56%
3: 42.13%
4: 51.77%
5: 59.81%
6: 66.51%

So far so good. Let’s keep going.

1d8 + 1d12 Rarity or Severity
2-4 Very Rare or Extreme
5-6 Rare or Major
7-8 Uncommon or Moderate
9-13 Common or Minor
14-15 Uncommon or Moderate
16-17 Rare or Major
18-20 Very Rare or Extreme

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, with a little nudging, is very convenient.

 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
>>> from enum import IntEnum

>>> class Complication(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.

Rarity or impact Angry’s probability of a Complication arising
Common or Minor 41.7%
Uncommon or Moderate 27.1%
Rare or Major 18.8%
Very Rare or Extreme 12.5%
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> from dyce import H, HResult, expand
>>> from pprint import pprint
>>> d8d12 = H(8) + H(12)

>>> def rarity(h_result: HResult[int]) -> Complication:
...     return OUTCOME_TO_RARITY_MAP[h_result.outcome]

>>> prob_of_complication: H[Complication] = expand(rarity, d8d12)
>>> pprint(
...     {
...         outcome: f"{float(prob):5.1%}"
...         for outcome, prob in prob_of_complication.probability_items()
...     }
... )
{<Complication.COMMON: 1>: '41.7%',
 <Complication.UNCOMMON: 2>: '27.1%',
 <Complication.RARE: 3>: '18.8%',
 <Complication.VERY_RARE: 4>: '12.5%'}

Lookin’ good! Now let’s put everything together.

d6s in pool None Common Uncommon Rare Very Rare
1 83.3% 7.0% 4.5% 3.1% 2.1%
2 69.4% 12.7% 8.3% 5.7% 3.8%
3 57.9% 17.6% 11.4% 7.9% 5.3%
4 48.2% 21.6% 14.0% 9.7% 6.5%
5 40.2% 24.9% 16.2% 11.2% 7.5%
6 33.5% 27.7% 18.0% 12.5% 8.3%
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> for n in range(1, 7):
...     ones_in_nd6 = n @ one_in_d6
...     at_least_one_one_in_nd6 = ones_in_nd6.ge(1)
...     prob_complication_in_nd6 = at_least_one_one_in_nd6 * prob_of_complication
...     complications_for_nd6 = {
...         Complication(outcome).name: f"{float(prob):5.1%}"
...         for outcome, prob in (prob_complication_in_nd6).probability_items()
...     }
...     print("{} -> {}".format(n, complications_for_nd6))
1 -> {'NONE': '83.3%', 'COMMON': ' 6.9%', 'UNCOMMON': ' 4.5%', 'RARE': ' 3.1%', 'VERY_RARE': ' 2.1%'}
2 -> {'NONE': '69.4%', 'COMMON': '12.7%', 'UNCOMMON': ' 8.3%', 'RARE': ' 5.7%', 'VERY_RARE': ' 3.8%'}
3 -> {'NONE': '57.9%', 'COMMON': '17.6%', 'UNCOMMON': '11.4%', 'RARE': ' 7.9%', 'VERY_RARE': ' 5.3%'}
4 -> {'NONE': '48.2%', 'COMMON': '21.6%', 'UNCOMMON': '14.0%', 'RARE': ' 9.7%', 'VERY_RARE': ' 6.5%'}
5 -> {'NONE': '40.2%', 'COMMON': '24.9%', 'UNCOMMON': '16.2%', 'RARE': '11.2%', 'VERY_RARE': ' 7.5%'}
6 -> {'NONE': '33.5%', 'COMMON': '27.7%', 'UNCOMMON': '18.0%', 'RARE': '12.5%', 'VERY_RARE': ' 8.3%'}

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 result 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 verbose way to model this is to enumerate the product of the three dice and then perform logical comparisons. However, if we recognize that our problem involves a dependent probability, we can craft a solution in terms of expand. We can also deploy a counting trick with the two d10s.

 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
>>> from dyce import H, HResult, expand
>>> from enum import IntEnum, auto
>>> d6 = H(6)
>>> d10 = H(10)
>>> action_mods = list(range(-1, 4))

>>> class IronResult(IntEnum):
...     FAILURE = 0
...     WEAK_SUCCESS = 1
...     STRONG_SUCCESS = 2

>>> def iron_dependent_term(action: HResult[int]) -> H[int]:
...     return 2 @ d10.lt(action.outcome)

>>> iron_distributions_by_action_mod = {
...     action_mod: expand(iron_dependent_term, d6 + action_mod).zero_fill(IronResult)
...     for action_mod in action_mods
... }
>>> for action_mod, iron_distribution in iron_distributions_by_action_mod.items():
...     print(
...         "{:+} -> {}".format(
...             action_mod,
...             {
...                 IronResult(outcome).name: f"{float(prob):6.2%}"
...                 for outcome, prob in iron_distribution.probability_items()
...             },
...         )
...     )
-1 -> {'FAILURE': '71.67%', 'WEAK_SUCCESS': '23.33%', 'STRONG_SUCCESS': ' 5.00%'}
+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%'}

What’s with that 2 @ d10.lt(action.outcome)?

Let’s break it down. H(10).lt(value) will tell us how often a single d10 is less than value.

1
2
    >>> H(10).lt(5)  # how often a d10 is strictly less than 5
    H({False: 6, True: 4})

By 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 d10 by “summing” them.

1
2
3
4
5
    >>> d10_lt5 = H(10).lt(5)
    >>> d10_lt5 + d10_lt5
    H({0: 36, 1: 48, 2: 16})
    >>> (d10_lt5 + d10_lt5).total
    100

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.

H’s @ operator provides a shorthand.

1
2
    >>> 2 @ d10_lt5 == d10_lt5 + d10_lt5
    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.

Now for a twist. A failure or success is particularly spectacular when the d10s come up doubles. The key to mapping that to dyce internals is recognizing that we have a dependent probability that involves three independent variables: the (modded) d6, a first d10, and a second d10.

expand is especially useful where there are multiple independent terms.

 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
from enum import IntEnum

from dyce import HResult, PResult, expand
from dyce.d import d6, p2d10

class IronDramaticResult(IntEnum):
    SPECTACULAR_FAILURE = -1
    FAILURE = 0
    WEAK_SUCCESS = 1
    STRONG_SUCCESS = 2
    SPECTACULAR_SUCCESS = 3

def iron_dramatic_dependent_term(
    action: HResult[int],
    challenges: PResult[int],
    *,
    action_mod: int = 0,
) -> IronDramaticResult:
    modded_action = action.outcome + action_mod
    assert len(challenges.roll) == 2, "pool must have exactly 2 challenge dice"
    first_challenge_outcome, second_challenge_outcome = challenges.roll
    challenge_doubles = first_challenge_outcome == second_challenge_outcome
    modded_action_beats_first_challenge = modded_action > first_challenge_outcome
    modded_action_beats_second_challenge = modded_action > second_challenge_outcome

    if modded_action_beats_first_challenge and modded_action_beats_second_challenge:
        return (
            IronDramaticResult.SPECTACULAR_SUCCESS
            if challenge_doubles
            else IronDramaticResult.STRONG_SUCCESS
        )
    elif (
        modded_action_beats_first_challenge or modded_action_beats_second_challenge
    ):
        return IronDramaticResult.WEAK_SUCCESS
    else:
        return (
            IronDramaticResult.SPECTACULAR_FAILURE
            if challenge_doubles
            else IronDramaticResult.FAILURE
        )

action_mods = list(range(-1, 4))
results_by_action_mod = {
    action_mod: expand(
        iron_dramatic_dependent_term, d6, p2d10, action_mod=action_mod
    )
    for action_mod in action_mods
}

By defining our dependent term function to include mod as a keyword-only parameter, we can pass values to it via expand, which is helpful for visualization.

Table:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import pandas as pd

data = [
    {
        outcome.name: float(prob)
        for outcome, prob in result.zero_fill(
            IronDramaticResult
        ).probability_items()
    }
    for result in results_by_action_mod.values()
]

df = pd.DataFrame(
    data,
    # TODO(posita): <https://github.com/pandas-dev/pandas/issues/54386>
    columns=[v.name for v in IronDramaticResult],
    index=action_mods,
)
df.index.name = "Action Modifier"
  SPECTACULAR_FAILURE FAILURE WEAK_SUCCESS STRONG_SUCCESS SPECTACULAR_SUCCESS
Action Modifier          
-1 8.33% 63.33% 23.33% 3.33% 1.67%
0 7.50% 51.67% 31.67% 6.67% 2.50%
1 6.50% 38.67% 39.67% 11.67% 3.50%
2 5.50% 27.67% 43.67% 18.67% 4.50%
3 4.50% 18.67% 43.67% 27.67% 5.50%

Visualization: Try dyce

1
2
3
4
5
6
from matplotlib import ticker

ax = df.plot(kind="barh", stacked=True)
ax.xaxis.set_major_formatter(ticker.PercentFormatter(xmax=1))
ax.set_title("Ironsworn distributions")
ax.legend(loc="center")

Plot: Ironsworn distributions

Modeling “The Probability of 4d6, Drop the Lowest, Reroll 1s

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from dyce import H, P, expand

p_4d6 = 4 @ P(6)
d6_reroll_first_one = expand(
    lambda result: result.h if result.outcome == 1 else result.outcome,
    H(6),
)
p_4d6_reroll_first_one = 4 @ P(d6_reroll_first_one)
p_4d6_reroll_all_ones = 4 @ P(H(5) + 1)

attr_results: dict[str, H] = {
    "3d6": 3 @ H(6),
    "4d6 - discard lowest": p_4d6.h(slice(1, None)),
    "4d6 - re-roll first 1, discard lowest": p_4d6_reroll_first_one.h(
        slice(1, None)
    ),
    "4d6 - re-roll all 1s (i.e., 4d(1d5 + 1)), discard lowest": p_4d6_reroll_all_ones.h(
        slice(1, None)
    ),
    "2d6 + 6": 2 @ H(6) + 6,
    "4d4 + 2": 4 @ H(4) + 2,
}

Visualization: Try dyce

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from matplotlib import pyplot as plt

from dyce.viz import plot_burst, plot_line

labels, hs = zip(*attr_results.items(), strict=True)

ax_lines = plt.subplot2grid((3, 3), (0, 0), colspan=3)
plot_line(*hs, labels=labels, markers="Ds^*xo", ax=ax_lines)
ax_lines.legend()
ax_lines.set_title("Comparing various take-three-of-4d6 methods")

for i, (label, h) in enumerate(attr_results.items()):
    ax_burst = plt.subplot2grid((3, 3), (1 + i // 3, i % 3))
    plot_burst(h, title=label, ax=ax_burst)
    ax_burst.set_title(ax_burst.get_title(), wrap=True)

plt.gcf().set_size_inches(9.6, 9.6)

Plot: Comparing various take-three-of-4d6 methods

Translating one example from markbrockettrobson/python_dice

Source:

1
2
3
4
5
6
7
8
9
# …
program = [
  "VAR save_roll = d20",
  "VAR burning_arch_damage = 10d6 + 10",
  "VAR pass_save = ( save_roll >= 10 ) ",
  "VAR damage_half_on_save = burning_arch_damage // (pass_save + 1)",
  "damage_half_on_save"
]
# …

Translation:

1
2
3
4
5
6
from dyce import H

save_roll = H(20)
burning_arch_damage = 10 @ H(6) + 10
pass_save = save_roll.ge(10)
damage_half_on_save = burning_arch_damage // (pass_save + 1)

Visualization: Try dyce

1
2
3
4
5
6
7
from matplotlib import ticker

from dyce.viz import plot_line

ax = plot_line(damage_half_on_save)
ax.xaxis.set_major_locator(ticker.IndexLocator(base=2, offset=0))
ax.set_title("Attack with saving throw for half damage")

Plot: Attack with saving throw for half damage

An alternative using expand:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> from dyce import H, expand
>>> import operator
>>> save_roll = H(20)
>>> burning_arch_damage = 10 @ H(6) + 10
>>> damage_half_on_save = burning_arch_damage // (save_roll.ge(10) + 1)
>>> expand(
...     lambda h_result: (
...         burning_arch_damage // 2
...         if operator.__ge__(h_result.outcome, 10)
...         else burning_arch_damage
...     ),
...     save_roll,
... ) == damage_half_on_save
True

More translations from markbrockettrobson/python_dice

1
2
3
4
>>> # VAR name = 1 + 2d3 - 3 * 4d2 // 5
>>> name = 1 + (2 @ H(3)) - 3 * (4 @ H(2)) // 5
>>> print(name.format_short())
{avg: 1.75, -1:  3.47%, 0: 13.89%, 1: 25.00%, 2: 29.17%, 3: 19.44%, 4:  8.33%, 5:  0.69%}
1
2
3
4
>>> # VAR out = 3 * ( 1 + 1d4 )
>>> out = 3 * (1 + 2 @ H(4))
>>> print(out.format_short())
{avg: 18.00, 9:  6.25%, 12: 12.50%, 15: 18.75%, 18: 25.00%, 21: 18.75%, 24: 12.50%, 27:  6.25%}
1
2
3
4
>>> # VAR g = (1d4 >= 2) AND !(1d20 == 2)
>>> g = H(4).ge(2) & H(20).ne(2)
>>> print(g.format_short())
{..., False: 28.75%, True: 71.25%}
1
2
3
4
>>> # VAR h = (1d4 >= 2) OR !(1d20 == 2)
>>> h_bool = H(4).ge(2) | H(20).ne(2)
>>> print(h_bool.format_short())
{..., False:  1.25%, True: 98.75%}
1
2
3
4
>>> # VAR abs = ABS( 1d6 - 1d6 )
>>> abs_h = abs(H(6) - H(6))
>>> print(abs_h.format_short())
{avg: 1.94, 0: 16.67%, 1: 27.78%, 2: 22.22%, 3: 16.67%, 4: 11.11%, 5:  5.56%}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> # MAX(4d7, 2d10)

>>> # this takes the max of a pool of four d7s and two d10s
>>> from dyce import P
>>> max_h = P(4 @ P(7), 2 @ P(10)).h(-1)
>>> print(max_h.format_short())
{avg: 7.78, 1:  0.00%, 2:  0.03%, 3:  0.28%, 4:  1.40%, 5:  4.80%, 6: 12.92%, 7: 29.57%, 8: 15.00%, 9: 17.00%, 10: 19.00%}

>>> # this takes the max of pool of a first die behaving like the sum of
>>> # 4d7 and a second die behaving like a the sum of 2d10, which is a
>>> # very different thing
>>> max_h = P(4 @ H(7), 2 @ H(10)).h(-1)
>>> print(max_h.format_short())
{avg: 16.60, 4:  0.00%, 5:  0.02%, 6:  0.07%, 7:  0.21%, ..., 25:  0.83%, 26:  0.42%, 27:  0.17%, 28:  0.04%}
1
2
3
4
>>> # MIN(50, d%)
>>> min_h = P(H((50,)), P(100)).h(0)
>>> print(min_h.format_short())
{avg: 37.75, 1:  1.00%, 2:  1.00%, 3:  1.00%, ..., 47:  1.00%, 48:  1.00%, 49:  1.00%, 50: 51.00%}

Translations from LordSembor/DnDice

Example 1 source:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from DnDice import d, gwf
single_attack = 2*d(6) + 5
# …
great_weapon_fighting = gwf(2*d(6)) + 5
# …
# comparison of the probability
print(single_attack.expectancies())
print(great_weapon_fighting.expectancies())
# [ 0.03,  0.06, 0.08, 0.11, 0.14, 0.17, 0.14, ...] (single attack)
# [0.003, 0.006, 0.03, 0.05, 0.10, 0.15, 0.17, ...] (gwf attack)
# …

Example 1 translation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from dyce import H, HResult, expand

single_attack = 2 @ H(6) + 5

def gwf_2014(result: HResult[int]) -> H[int] | int:
    # Re-roll either die if it is a one or two
    return result.h if result.outcome in (1, 2) else result.outcome

def gwf_2024(result: HResult[int]) -> H[int] | int:
    # Ones and twos are promoted to 3s
    return 3 if result.outcome in (1, 2) else result.outcome

h_gwf_2014 = 2 @ expand(gwf_2014, H(6)) + 5  # ty: ignore[unsupported-operator]
h_gwf_2024 = 2 @ expand(gwf_2024, H(6)) + 5  # ty: ignore[unsupported-operator]

Example 1 table:

Table source code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import pandas as pd

data = [
    {outcome: float(prob) for outcome, prob in h.probability_items()}
    for h in (single_attack, h_gwf_2014, h_gwf_2024)
]
label_sa = "Normal attack"
label_gwf_2014 = "\u201cGWF\u201d (2014)"
label_gwf_2024 = "\u201cGWF\u201d (2024)"
df = pd.DataFrame(data, index=[label_sa, label_gwf_2014, label_gwf_2024])
  7 8 9 10 11 12 13 14 15 16 17
Normal attack 3% 6% 8% 11% 14% 17% 14% 11% 8% 6% 3%
“GWF” (2014) 0% 1% 3% 5% 10% 15% 17% 20% 15% 10% 5%
“GWF” (2024) nan% nan% nan% nan% 25% 17% 19% 22% 8% 6% 3%

Example 1 visualization: Try dyce

Visualization source code
 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
from matplotlib import pyplot as plt

from dyce.viz import plot_burst, plot_line

cmap_sa = "Reds"
cmap_gwf_2014 = "Greens"
cmap_gwf_2024 = "Purples"

ax_sa = plt.subplot2grid((3, 2), (0, 0), rowspan=3)
plot_line(
    single_attack,
    h_gwf_2014,
    h_gwf_2024,
    labels=[label_sa, label_gwf_2014, label_gwf_2024],
    ax=ax_sa,
)
ax_sa.lines[0].set_color(plt.get_cmap(cmap_sa)(0.75))
ax_sa.lines[1].set_color(plt.get_cmap(cmap_gwf_2014)(0.75))
ax_sa.lines[2].set_color(plt.get_cmap(cmap_gwf_2024)(0.75))
ax_sa.legend()

ax_sa_gwf_2014 = plt.subplot2grid((3, 2), (0, 1))
plot_burst(
    h_gwf_2014,
    single_attack,
    cmap=cmap_gwf_2014,
    compare_cmap=cmap_sa,
    title=f"{label_sa}\nvs.\n{label_gwf_2014}",
    ax=ax_sa_gwf_2014,
)

ax_sa_gwf_2024 = plt.subplot2grid((3, 2), (1, 1))
plot_burst(
    h_gwf_2024,
    single_attack,
    cmap=cmap_gwf_2024,
    compare_cmap=cmap_sa,
    title=f"{label_sa}\nvs.\n{label_gwf_2024}",
    ax=ax_sa_gwf_2024,
)

ax_gwf_2014_2024 = plt.subplot2grid((3, 2), (2, 1))
plot_burst(
    h_gwf_2024,
    h_gwf_2014,
    cmap=cmap_gwf_2024,
    compare_cmap=cmap_gwf_2014,
    title=f"{label_gwf_2014}\nvs.\n{label_gwf_2024}",
    ax=ax_gwf_2014_2024,
)

plt.gcf().set_size_inches(9.6, 9.6)

Plot: Comparing a normal attack to an enhanced one

Example 2 source:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from DnDice import d, advantage, plot

normal_hit = 1*d(12) + 5
critical_hit = 3*d(12) + 5

result = d()
for value, probability in advantage():
  if value == 20:
    result.layer(critical_hit, weight=probability)
  elif value + 5 >= 14:
    result.layer(normal_hit, weight=probability)
  else:
    result.layer(d(0), weight=probability)
result.normalizeExpectancies()
# …

Example 2 translation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from dyce import H, HResult, P, expand

normal_hit = H(12) + 5
critical_hit = 3 @ H(12) + 5
advantage = (2 @ P(20)).h(-1)

def crit(result: HResult[int]) -> H[int] | int:
    if result.outcome == 20:
        return critical_hit
    elif result.outcome + 5 >= 14:
        return normal_hit
    else:
        return 0

advantage_weighted = expand(crit, advantage)

Example 2 visualization: Try dyce

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from matplotlib import ticker

from dyce.viz import plot_line

ax = plot_line(
    normal_hit,
    critical_hit,
    advantage_weighted,
    labels=["Normal hit", "Critical hit", "Advantage-weighted"],
)
ax.xaxis.set_major_locator(ticker.IndexLocator(base=2, offset=1))
ax.set_title("Advantage-weighted attack with critical hits")
ax.legend()

Plot: Advantage-weighted attack with critical hits

Translation of the accepted answer to “Roll and Keep in Anydice?

Source:

1
2
\ How best to model this in a way that allows testing 1k1 to 10k5? \
output [highest 3 of 10d [explode d10]] named "10k3"

Translation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from math import ceil

from dyce import H, P, explode_n

explode_depth = 2

def keep(p: P[int], k: int) -> H[int]:
    r"Negative k keeps lowest, otherwise keeps highest"
    return p.h(slice(-k, None) if k > 0 else slice(-k))

def nkk(n: int, k: int) -> H[int]:
    return keep(n @ P(explode_n(H(10), n=explode_depth)), k=k)

Visualization: Try dyce

Visualization source code
 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
from matplotlib import pyplot as plt
from matplotlib import ticker

from dyce.viz import plot_line

# Range: [start_k..end_k)
k_start, k_end = 3, 6
# Range: [start_n..end_n)
n_start, n_end = 5, 11
# For normalizing axes scale
all_nkk: list[H[int]] = []

for k in range(k_start, k_end):
    label_value_pairs = [(f"{n}k{k}", nkk(n, k)) for n in range(n_start, n_end)]
    labels, hs = zip(*label_value_pairs, strict=True)
    all_nkk.extend(hs)
    ax = plt.subplot2grid((k_end - k_start, 1), (k - k_start, 0))
    plot_line(*hs, labels=labels, ax=ax)
    for line in ax.lines:
        line.set_marker("")
    ax.xaxis.set_major_locator(ticker.MultipleLocator(5))
    ax.tick_params(axis="x", labelrotation=60)
    ax.set_title(f"Taking the {k} highest of $n$ exploding d10s")
    ax.legend()

max_x = max(max(h) for h in all_nkk)
max_y = max(prob for h in all_nkk for _, prob in h.probability_items())
for ax in plt.gcf().get_axes():
    ax.set_xlim(left=0, right=max_x)
    ax.set_ylim(top=ceil(max_y * 100) / 100)
plt.gcf().set_size_inches(6.4, 8.0)

Plot: Taking the *k* highest of *n* exploding d10s

Translation of the accepted answer to “How do I count the number of duplicates in anydice?

Source:

1
2
3
4
5
6
7
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
 9
10
from dyce import H, P

def count_dupes(pool: P) -> H[int]:
    return H.from_counts(
        (sum(1 for i in range(1, len(roll)) if roll[i] == roll[i - 1]), count)
        for roll, count in pool.rolls_with_counts()
    )

res_15d6 = count_dupes(15 @ P(6))
res_8d10 = count_dupes(8 @ P(10))

Visualization: Try dyce

1
2
3
4
5
from dyce.viz import plot_bar

ax = plot_bar(res_15d6, res_8d10, labels=["15d6", "8d10"])
ax.set_title("Chances of rolling $n$ duplicates")
ax.legend()

Plot: Chances of rolling *n* duplicates

Translation of “How do I implement this specialized roll-and-keep mechanic in AnyDice?

Source:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function: N:n of SIZE:n keep K:n extras add {
    result: [helper NdSIZE SIZE K]
}

function: helper ROLL:s SIZE:n K:n {
    COUNT: [count SIZE in ROLL]
    if COUNT > K { result: K*SIZE - K + COUNT }
    result: {1..K}@ROLL
}

D: 6
K: 3

loop N over {K+1..K+8} {
  output [N of D keep K extras add] named "[N]d[D] keep [K] extras add +1"
}
loop N over {K+1..K+8} {
  output {1..K}@NdD named "[N]d[D] keep [K]"
}

Translation:

 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
from collections.abc import Iterable

from dyce import H, P

def roll_and_keep(p: P[int], k: int) -> H[int]:
    assert all(h == p[0] for h in p), "pool must be homogeneous"
    max_d = max(p[-1]) if p else 0
    return H.from_counts(
        (
            sum(roll[-k:]) + sum(1 for outcome in roll[:-k] if outcome == max_d),
            count,
        )
        for roll, count in p.rolls_with_counts()
    )

d, k = 6, 3

def roll_and_keep_hs() -> Iterable[tuple[str, H[int]]]:
    for n in range(k + 1, k + 9):
        p = n @ P(d)
        yield f"{n}d{d} keep {k} add +1", roll_and_keep(p, k)

def normal() -> Iterable[tuple[str, H[int]]]:
    for n in range(k + 1, k + 9):
        p = n @ P(d)
        yield f"{n}d{d} keep {k}", p.h(slice(-k, None))

Visualization: Try dyce

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from dyce.viz import plot_line

labels1, hs1 = zip(*tuple(normal()), strict=True)
ax = plot_line(*hs1, labels=labels1, markers=".", alpha=0.75)

labels2, hs2 = zip(*tuple(roll_and_keep_hs()), strict=True)
plot_line(*hs2, labels=labels2, markers="o", alpha=0.25, ax=ax)

ax.set_title("Roll-and-keep mechanic comparison")
ax.legend(loc="upper left")

Plot: Roll-and-keep mechanic comparison

Translation of the accepted answer to “Modelling opposed dice pools with a swap

Source of basic brawl:

1
2
3
4
5
6
7
8
9
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"

Translation:

1
2
3
4
5
6
>>> from dyce.evaluation import PResult

>>> def brawl(p_result_a: PResult[int], p_result_b: PResult[int]):
...     a_successes = sum(1 for v in p_result_a.roll if v >= p_result_b.roll[-1])
...     b_successes = sum(1 for v in p_result_b.roll if v >= p_result_a.roll[-1])
...     return a_successes - b_successes

Rudimentary textual visualization using built-in methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> from dyce import P, expand
>>> p3d6 = 3 @ P(6)
>>> res = expand(brawl, p3d6, p3d6)
>>> print(res.format(width=65))
avg |    0.00
std |    1.73
var |    2.99
 -3 |   7.86% |###
 -2 |  15.52% |#######
 -1 |  16.64% |########
  0 |  19.96% |#########
  1 |  16.64% |########
  2 |  15.52% |#######
  3 |   7.86% |###

Source of brawl with an optional dice swap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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"

Translation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> def brawl_w_optional_swap(p_result_a: PResult[int], p_result_b: PResult[int]):
...     roll_a, roll_b = p_result_a.roll, p_result_b.roll
...     if roll_a[0] < roll_b[-1]:
...         roll_a, roll_b = roll_a[1:] + roll_b[-1:], roll_a[:1] + roll_b[:-1]
...         # Sort greatest-to-least after the swap
...         roll_a = tuple(sorted(roll_a, reverse=True))
...         roll_b = tuple(sorted(roll_b, reverse=True))
...     else:
...         # Reverse to be greatest-to-least
...         roll_a = roll_a[::-1]
...         roll_b = roll_b[::-1]
...     a_successes = sum(1 for v in roll_a if v >= roll_b[0])
...     b_successes = sum(1 for v in roll_b if v >= roll_a[0])
...     return a_successes - b_successes or (roll_a > roll_b) - (roll_a < roll_b)

Rudimentary visualization using built-in methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
>>> res = expand(brawl_w_optional_swap, p3d6, p3d6)
>>> print(res.format(width=65))
avg |    2.36
std |    0.88
var |    0.77
 -1 |   1.42% |
  0 |   0.59% |
  1 |  16.65% |########
  2 |  23.19% |###########
  3 |  58.15% |#############################

>>> p4d6 = 4 @ P(6)
>>> res = expand(brawl_w_optional_swap, p4d6, p4d6)
>>> print(res.format(width=65))
avg |    2.64
std |    1.28
var |    1.64
 -2 |   0.06% |
 -1 |   2.94% |#
  0 |   0.31% |
  1 |  18.16% |#########
  2 |  19.97% |#########
  3 |  25.19% |############
  4 |  33.37% |################

Advanced topic—modeling Risis

S. John Ross’s 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. Our first step is a callback for H.apply for refereeing a head-to-head contest of values:

 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
import warnings
from collections.abc import Callable, Sequence
from enum import IntEnum

from dyce import H
from dyce.d import d6
from dyce.lifecycle import ExperimentalWarning

warnings.filterwarnings("ignore", category=ExperimentalWarning)

VersusFuncT = Callable[[int, int], H["Versus"]]


class Versus(IntEnum):
    LOSE = -1
    DRAW = 0
    WIN = 1

    @staticmethod
    def raw_vs(us_outcome: int, them_outcome: int) -> "Versus":
        return (
            Versus.LOSE
            if us_outcome < them_outcome
            else Versus.WIN
            if us_outcome > them_outcome
            else Versus.DRAW
        )

    @staticmethod
    def single_round_us_vs_them(
        our_pool_size: int, their_pool_size: int
    ) -> H["Versus"]:
        return (our_pool_size @ d6).apply(Versus.raw_vs, their_pool_size @ d6)

Note

As an aside, this reasonably common “versus” pattern can be characterized in a more concise (albeit less readable) way:

1
2
3
4
5
6
7
    >>> from dyce.d import d6
    >>> ours = 2 @ d6
    >>> theirs = 3 @ d6
    >>> (ours - theirs).apply(
    ...     lambda outcome: outcome // abs(outcome) if outcome else outcome
    ... ).lowest_terms()
    H({-1: 1009, 0: 90, 1: 197})

Example use for a single round of combat:

1
2
3
4
our_pool_size = 2
their_pool_size = 3
single_round_us_vs_them = Versus.single_round_us_vs_them(our_pool_size, their_pool_size)
print(single_round_us_vs_them.format(width=65, scaled=True))
              avg |   -0.63
              std |    0.73
              var |    0.54
<Versus.LOSE: -1> |  77.85% |####################################
 <Versus.DRAW: 0> |   6.94% |###
  <Versus.WIN: 1> |  15.20% |#######

This highlights the mechanic’s notorious “death spiral”, which we can visualize as a heat map.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from typing import TYPE_CHECKING

import pandas as pd
from matplotlib.axes import Axes

ScenariosDataframesT = Sequence[pd.DataFrame]

if TYPE_CHECKING:

    def vs_scenarios_dataframes(
        us_vs_them_func: VersusFuncT,
        *,
        our_pool_rel_sizes: Sequence[int] = tuple(range(-1, 2)),
        their_pool_sizes: Sequence[int] = tuple(range(3, 6)),
    ) -> ScenariosDataframesT: ...

    def us_vs_them_heatmap_subplot(
        vs_dfs: ScenariosDataframesT,
        cmap_name: str = "viridis",
        plt_total_rows: int = 1,
        plt_cur_row: int = 0,
    ) -> list[Axes]: ...
Visualization source code
 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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from typing import cast

import matplotlib as mpl
from matplotlib import pyplot as plt
from pandas import Index


# See <https://github.com/python/mypy/issues/19169#issuecomment-2920914460>
def vs_scenarios_dataframes(  # type: ignore[no-redef]
    us_vs_them_func: VersusFuncT,
    *,
    our_pool_rel_sizes: Sequence[int] = tuple(range(-1, 2)),
    their_pool_sizes: Sequence[int] = tuple(range(3, 6)),
) -> ScenariosDataframesT:
    vs_dfs: list[pd.DataFrame] = []
    # The explicit type hint here is apparently needed by mypy for properly resolving
    # the call to h_vs.merge below.
    # TODO(posita): # noqa: TD003 - See:
    # - <https://github.com/python/mypy/issues/21317>
    h_vs: H[Versus] = H(Versus)
    for their_pool_size in their_pool_sizes:
        data: dict[str, dict[str, float]] = {}
        for our_pool_rel_size in our_pool_rel_sizes:
            our_pool_size = their_pool_size + our_pool_rel_size
            us_vs_them_results = h_vs.merge(
                us_vs_them_func(our_pool_size, their_pool_size)
            )
            data[f"{our_pool_size}d6"] = {
                outcome.name: float(prob)
                for outcome, prob in us_vs_them_results.probability_items()
            }
        df = pd.DataFrame(
            list(data.values()),
            columns=[v.name for v in Versus],
            index=list(data.keys()),
        )
        df.index.name = f"{their_pool_size}d6"
        vs_dfs.append(df)

    return vs_dfs


# See <https://github.com/python/mypy/issues/19169#issuecomment-2920914460>
def us_vs_them_heatmap_subplot(  # type: ignore[no-redef]
    vs_dfs: ScenariosDataframesT,
    cmap_name: str = "viridis",
    plt_total_rows: int = 1,
    plt_cur_row: int = 0,
) -> list[Axes]:
    axes: list[Axes] = []
    col_names = [e.name for e in Versus]
    cmap = mpl.colormaps[cmap_name]
    lo_color = cmap(100.0)
    hi_color = cmap(0.0)

    for i, df in enumerate(vs_dfs):
        df = df.copy()  # noqa: PLW2901
        df.index = Index(
            data=[f"our\n {idx} …" for idx in df.index],
            dtype=df.index.dtype,
            name=f"… vs. their {df.index.name}",
        )
        ax = plt.subplot2grid(
            (plt_total_rows, len(vs_dfs)),
            (plt_cur_row, i),
        )
        ax.imshow(df, vmin=0.0, vmax=1.0, cmap=cmap_name)
        ax.set_xticks(range(len(col_names)), labels=col_names)
        ax.tick_params(axis="x", labelrotation=60.0)
        ax.set_yticks(range(len(df.index)), labels=df.index)
        for y, (_, row) in enumerate(df.iterrows()):
            for x, val in enumerate(row):
                ax.text(
                    x,
                    y,
                    f"{val:.0%}",
                    ha="center",
                    va="center",
                    color=hi_color if val > 0.5 else lo_color,
                )
        ax.set_title(cast("str", df.index.name))
        axes.append(ax)

    return axes

Visualization: Try dyce

1
2
3
vs_dfs = vs_scenarios_dataframes(Versus.single_round_us_vs_them)
axes = us_vs_them_heatmap_subplot(vs_dfs, cmap_name="magma")
plt.gcf().set_size_inches(6.4, 2.8)

Plot: Modeling the Risus combat mechanic after the first roll

Modeling entire multi-round combats

With a little elbow finger grease, we can roll up our … erm … fingerless gloves and even model how various starting conditions affect combat completion (in this case, applying dynamic programming to avoid redundant computations).

 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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from fractions import Fraction
from functools import cache

from dyce import HResult, expand


def risus_combat_driver(
    our_pool_size: int,  # our starting pool size
    their_pool_size: int,  # their starting pool size
    single_round_us_vs_them_func: VersusFuncT = Versus.single_round_us_vs_them,
) -> H[Versus]:
    # Preliminary checks before we start recursion
    if our_pool_size < 0 or their_pool_size < 0:
        raise ValueError(
            f"cannot have negative numbers (us: {our_pool_size}, "
            f"them: {their_pool_size})"
        )
    elif our_pool_size == 0 and their_pool_size == 0:
        # This should not happen unless we are called as
        # risus_combat_driver(0, 0, ...). In other words, this is the only
        # case where a combat will result in a draw. This is because, draws
        # are re-rolled during combat, so eliminate those results below.
        return H({Versus.DRAW: 1})

    # The @cache` decorator
    # (https://docs.python.org/3/library/functools.html#functools.cache)
    # does simple memoization for us because there are redundancies. 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. In this context, `@cache` helps us avoid
    # recomputing redundant sub-trees.
    @cache
    def _resolve_us_vs_them_func(
        our_pool_size: int,  # number of dice we still have
        their_pool_size: int,  # number of dice they still have
    ) -> H[Versus]:
        assert our_pool_size != 0 or their_pool_size != 0, (
            "In this function, the case where both parties are at zero is "
            "considered an error. 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 our_pool_size is zero, "
            "their_pool_size is zero, or neither is zero."
        )
        if our_pool_size == 0:
            # Base case: we are out of dice, so they win
            return H({Versus.LOSE: 1})
        if their_pool_size == 0:
            # Base case: they are out of dice, so we win
            return H({Versus.WIN: 1})
        # Otherwise, the battle rages on ...
        this_round_results = single_round_us_vs_them_func(
            our_pool_size, their_pool_size
        )

        # Keeping in mind that we're inside our recursive implementation, we
        # define a dependent term suitable for dyce.expand, allowing us to
        # take our computation for this round, and use that machinery to
        # "fold in" subsequent rounds.
        def _resolve_next_round_from_this_round(
            this_round: HResult[Versus],
        ) -> H[Versus]:
            match this_round.outcome:
                case Versus.LOSE:
                    # We lost this round, so keep going with one fewer die
                    # in our pool (which could now be at zero dice)
                    return _resolve_us_vs_them_func(
                        our_pool_size - 1,
                        their_pool_size,
                    )
                case Versus.WIN:
                    # They lost this round, so keep going with one fewer die
                    # in their pool (which could now be at zero dice)
                    return _resolve_us_vs_them_func(
                        our_pool_size,
                        their_pool_size - 1,
                    )
                case Versus.DRAW:
                    # Ignore (i.e., immediately re-roll) all ties
                    return H({})
                case _:
                    # Should never be here
                    assert False, (  # noqa: B011, PT015
                        f"unrecognized this_round.outcome {this_round.outcome}"
                    )

        return expand(
            _resolve_next_round_from_this_round,
            this_round_results,
            precision=Fraction(1, 0x7FFFFFFF),
        )  # ty: ignore[invalid-return-type]

    return _resolve_us_vs_them_func(our_pool_size, their_pool_size)

There’s lot going on there. Thankfully, it’s heavily annotated. It’s worth going back and dissecting as a fairly nuanced application of expand.

Note

This is a complicated example that involves some fairly sophisticated programming techniques (recursion, memoization, nested functions, 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.

When called with its default arguments, risus_combat_driver satisfies the VersusFuncT interface. This means we can use it directly with our vs_scenarios_dataframes helper to enumerate resolution outcomes from various starting positions.

Visualization: Try dyce

1
2
3
4
5
6
7
vs_dfs = vs_scenarios_dataframes(
    risus_combat_driver,
    our_pool_rel_sizes=tuple(range(-1, 3)),
    their_pool_sizes=range(2, 6),
)
axes = us_vs_them_heatmap_subplot(vs_dfs, cmap_name="magma")
plt.gcf().set_size_inches(8.0, 3.2)

Plot: Modeling the Risus combat mechanic after the first roll

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 (free with membership to the IOR) with the optional “Goliath Rule” for resolving ties.

 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
def single_round_goliath(
    h_result: HResult[Versus], *, our_pool_size: int, their_pool_size: int
) -> Versus:
    r"""
    Goliath Rule: Instead of re-rolling ties, the win goes to the party with
    fewer dice in this round.
    """
    return (
        Versus(
            # Resolves to 1 if we have fewer dice, -1 if they have fewer
            # dice, and 0 if we're tied
            int(our_pool_size < their_pool_size) - int(our_pool_size > their_pool_size)
        )
        if h_result.outcome == Versus.DRAW
        else h_result.outcome
    )


assert (
    single_round_goliath(
        HResult(h=H(Versus), outcome=Versus.DRAW),
        our_pool_size=1,
        their_pool_size=2,
    )
    is Versus.WIN
)
assert (
    single_round_goliath(
        HResult(h=H(Versus), outcome=Versus.DRAW),
        our_pool_size=2,
        their_pool_size=1,
    )
    is Versus.LOSE
)
assert (
    single_round_goliath(
        HResult(h=H(Versus), outcome=Versus.DRAW),
        our_pool_size=2,
        their_pool_size=2,
    )
    is Versus.DRAW
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from dyce import P


def best_of_set_single_round_us_vs_them(
    our_pool_size: int,
    their_pool_size: int,
    *,
    with_goliath_rule: bool,
) -> H[Versus]:
    our_best = (our_pool_size @ P(6)).h(-1)
    their_best = (their_pool_size @ P(6)).h(-1)
    raw_result = our_best.apply(Versus.raw_vs, their_best)

    return (
        expand(
            single_round_goliath,
            raw_result,
            our_pool_size=our_pool_size,
            their_pool_size=their_pool_size,
        )
        if with_goliath_rule
        else raw_result
    )

Python’s functools.partial allows us to override individual function details, but still leverage our current callback machinery. This pattern will come up again below, so we’ll capture it in a helper function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from typing import Protocol


class VersusFuncGoliathT(Protocol):
    def __call__(
        self,
        our_pool_size: int,
        their_pool_size: int,
        *,
        with_goliath_rule: bool,
    ) -> H[Versus]: ...


if TYPE_CHECKING:

    def viz_multi_round_goliath_helper(
        single_round_goliath_func: VersusFuncGoliathT,
    ) -> Sequence[Axes]: ...
Visualization Goliath Rule helper source code
 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
# See <https://github.com/python/mypy/issues/19169#issuecomment-2920914460>
def viz_multi_round_goliath_helper(  # type: ignore[no-redef]
    single_round_goliath_func: VersusFuncGoliathT,
) -> Sequence[Axes]:
    from functools import partial

    all_axes: list[Axes] = []
    our_pool_rel_sizes = tuple(range(-1, 4))
    their_pool_sizes = tuple(range(2, 7))

    # Standard combat (for comparison)
    standard_vs_dfs = vs_scenarios_dataframes(
        risus_combat_driver,
        our_pool_rel_sizes=our_pool_rel_sizes,
        their_pool_sizes=their_pool_sizes,
    )
    axes_standard = us_vs_them_heatmap_subplot(
        standard_vs_dfs,
        plt_total_rows=3,
        plt_cur_row=0,
        cmap_name="viridis",
    )
    all_axes.extend(axes_standard)

    # Without the Goliath Rule
    risus_combat_driver_wo_goliath = partial(
        risus_combat_driver,
        single_round_us_vs_them_func=partial(
            single_round_goliath_func, with_goliath_rule=False
        ),
    )
    wo_goliath_dfs = vs_scenarios_dataframes(
        risus_combat_driver_wo_goliath,
        our_pool_rel_sizes=our_pool_rel_sizes,
        their_pool_sizes=their_pool_sizes,
    )
    axes_wo_goliath = us_vs_them_heatmap_subplot(
        wo_goliath_dfs,
        plt_total_rows=3,
        plt_cur_row=1,
        cmap_name="cividis",
    )
    all_axes.extend(axes_wo_goliath)

    # With the Goliath Rule
    risus_combat_driver_w_goliath = partial(
        risus_combat_driver,
        single_round_us_vs_them_func=partial(
            single_round_goliath_func, with_goliath_rule=True
        ),
    )
    w_goliath_dfs = vs_scenarios_dataframes(
        risus_combat_driver_w_goliath,
        our_pool_rel_sizes=our_pool_rel_sizes,
        their_pool_sizes=their_pool_sizes,
    )
    axes_w_goliath = us_vs_them_heatmap_subplot(
        w_goliath_dfs,
        plt_total_rows=3,
        plt_cur_row=2,
        cmap_name="plasma",
    )
    all_axes.extend(axes_w_goliath)

    return all_axes

We’ll use that Goliath Rule helper to approximate a complete “Best-of-Set” combat and compare it to a “standard” one.

Visualization: Try dyce

1
2
3
4
5
6
7
8
9
axes = viz_multi_round_goliath_helper(best_of_set_single_round_us_vs_them)
fig = plt.gcf()
fig.set_size_inches(9.6, 9.6)
fig.suptitle(
    "Full combat results based on starting pool sizes\n"
    "Standard (top row)\n"
    "Best-of-Set w/o the Goliath Rule (middle row)\n"
    "Best-of-Set w/ the Goliath Rule (bottom row)"
)

Plot: Modeling the Risus combat mechanic after the first roll

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 for a sufficient number of iterations. This is no exception.

Another limitation is that dyce only provides a mechanism to directly expand 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.

 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
from dyce import explode_n


class EvensUp(IntEnum):
    MISS = 0
    HIT = 1
    HIT_EXPLODE = 2


d_evens_up_raw = H(
    (
        EvensUp.MISS,  # 1
        EvensUp.HIT,  # 2
        EvensUp.MISS,  # 3
        EvensUp.HIT,  # 4
        EvensUp.MISS,  # 5
        EvensUp.HIT_EXPLODE,  # 6
    )
)
d_evens_up_raw_exploded = (
    explode_n(
        d_evens_up_raw,
        n=3,  # plenty deep for our needs
    )
    + 0  # make sure everything is an int
)
print(d_evens_up_raw_exploded)
H({0: 648, 1: 432, 2: 108, 3: 72, 4: 18, 5: 12, 6: 3, 7: 2, 8: 1})

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
9
def evens_up_decode_hits(outcome: int) -> int:
    # Clever math that is equivalent to:
    #     outcome // 2 +  # a tally of any exploded hits
    #     outcome % 2  # any final hit  # noqa: ERA001
    return (outcome + 1) // 2


d_evens_up = d_evens_up_raw_exploded.apply(evens_up_decode_hits).lowest_terms()
print(d_evens_up.format(width=65, scaled=True))
avg |    0.60
std |    0.69
var |    0.48
  0 |  50.00% |##################################################
  1 |  41.67% |#########################################
  2 |   6.94% |######
  3 |   1.16% |#
  4 |   0.23% |

Now we can craft an “Evens Up” implementation suitable for passing to our risus_combat_driver.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def evens_up_single_round_us_vs_them(
    our_pool_size: int,
    their_pool_size: int,
    *,
    with_goliath_rule: bool,
) -> H[Versus]:
    raw_result = (our_pool_size @ d_evens_up).apply(
        Versus.raw_vs, their_pool_size @ d_evens_up
    )

    return (
        expand(
            single_round_goliath,
            raw_result,
            our_pool_size=our_pool_size,
            their_pool_size=their_pool_size,
        )
        if with_goliath_rule
        else raw_result
    )

We’ll use that to approximate a complete “Evens Up” combat, continuing to leveraging our Goliath Rule helper from above.

Visualization: Try dyce

1
2
3
4
5
6
7
8
9
axes = viz_multi_round_goliath_helper(evens_up_single_round_us_vs_them)
fig = plt.gcf()
fig.set_size_inches(9.6, 9.6)
fig.suptitle(
    "Full combat results based on starting pool sizes\n"
    "Standard (top row)\n"
    "Evens Up w/o the Goliath Rule (middle row)\n"
    "Evens Up w/ the Goliath Rule (bottom row)"
)

Plot: Modeling the Risus combat mechanic after the first roll

Phew! What a journey! Hopefully this highlights some of dyce’s flexibility and capabilities. If you’d like help using dyce with modeling your own complicated mechanics, drop me a line!