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
>>> prob_of_complication = H.foreach(
...   lambda outcome: OUTCOME_TO_RARITY_MAP[outcome],
...   outcome=H(8) + H(12),
... )
>>> {outcome: f"{float(prob):6.2%}" for outcome, prob in prob_of_complication.distribution()}
{<Complication.COMMON: 1>: '41.67%',
 <Complication.UNCOMMON: 2>: '27.08%',
 <Complication.RARE: 3>: '18.75%',
 <Complication.VERY_RARE: 4>: '12.50%'}

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
16
>>> from typing import cast
>>> 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(cast(int, outcome)).name: f"{float(prob):6.2%}"
...     for outcome, prob in (prob_complication_in_nd6).distribution()
...   }
...   print("{} -> {}".format(n, complications_for_nd6))
1 -> {'NONE': '83.33%', 'COMMON': ' 6.94%', 'UNCOMMON': ' 4.51%', 'RARE': ' 3.12%', 'VERY_RARE': ' 2.08%'}
2 -> {'NONE': '69.44%', 'COMMON': '12.73%', 'UNCOMMON': ' 8.28%', 'RARE': ' 5.73%', 'VERY_RARE': ' 3.82%'}
3 -> {'NONE': '57.87%', 'COMMON': '17.55%', 'UNCOMMON': '11.41%', 'RARE': ' 7.90%', 'VERY_RARE': ' 5.27%'}
4 -> {'NONE': '48.23%', 'COMMON': '21.57%', 'UNCOMMON': '14.02%', 'RARE': ' 9.71%', 'VERY_RARE': ' 6.47%'}
5 -> {'NONE': '40.19%', 'COMMON': '24.92%', 'UNCOMMON': '16.20%', 'RARE': '11.21%', 'VERY_RARE': ' 7.48%'}
6 -> {'NONE': '33.49%', 'COMMON': '27.71%', 'UNCOMMON': '18.01%', 'RARE': '12.47%', 'VERY_RARE': ' 8.31%'}

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 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 H.foreach. 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
>>> from dyce import H, as_int
>>> from numerary.types import RealLike
>>> from enum import IntEnum, auto
>>> from typing import Iterator, Tuple, cast
>>> d6 = H(6)
>>> d10 = H(10)

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

>>> iron_distributions_by_mod = {
...   mod: H.foreach(lambda action: 2@(d10.lt(action)), action=d6 + mod)
...   for mod in range(5)
... }
>>> for mod, iron_distribution in iron_distributions_by_mod.items():
...   print("{:+} -> {}".format(mod, {
...     IronResult(cast(int, outcome)).name: f"{float(prob):6.2%}"
...     for outcome, prob in iron_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%'}

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

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 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 d10.

1
2
3
4
>>> h = H(10).lt(5) + H(10).lt(5) ; h
H({0: 36, 1: 48, 2: 16})
>>> h.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
3
>>> # 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.

Now for a twist. In cooperative or solo play, 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.

H.foreach 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
>>> class IronSoloResult(IntEnum):
...   FAILURE = 0
...   WEAK_SUCCESS = auto()
...   STRONG_SUCCESS = auto()
...   SPECTACULAR_SUCCESS = auto()
...   SPECTACULAR_FAILURE = -1

>>> def iron_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
...   if beats_first_challenge and beats_second_challenge:
...     return IronSoloResult.SPECTACULAR_SUCCESS if doubles else IronSoloResult.STRONG_SUCCESS
...   elif beats_first_challenge or beats_second_challenge:
...     return IronSoloResult.WEAK_SUCCESS
...   else:
...     return IronSoloResult.SPECTACULAR_FAILURE if doubles else IronSoloResult.FAILURE

>>> H.foreach(
...   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})

By defining our dependent term function to include mod as a parameter with a default argument, we can use partial to manipulate it, which is helpful for visualization.

Plot: Ironsworn distributions

Source: plot_ironsworn.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from collections import defaultdict
from enum import IntEnum, auto
from functools import partial

from dyce import H


class IronSoloResult(IntEnum):
    SPECTACULAR_FAILURE = -1
    FAILURE = auto()
    WEAK_SUCCESS = auto()
    STRONG_SUCCESS = auto()
    SPECTACULAR_SUCCESS = auto()


def do_it(style: str) -> None:
    import matplotlib.pyplot
    import matplotlib.ticker

    d6 = H(6)
    d10 = H(10)

    def iron_solo_dependent_term(action, first_challenge, second_challenge, mod=0):
        modded_action = action + mod
        beats_first = modded_action > first_challenge
        beats_second = modded_action > second_challenge
        doubles = first_challenge == second_challenge

        if beats_first and beats_second:
            return (
                IronSoloResult.SPECTACULAR_SUCCESS
                if doubles
                else IronSoloResult.STRONG_SUCCESS
            )
        elif beats_first or beats_second:
            return IronSoloResult.WEAK_SUCCESS
        else:
            return (
                IronSoloResult.SPECTACULAR_FAILURE
                if doubles
                else IronSoloResult.FAILURE
            )

    ax = matplotlib.pyplot.axes()
    by_result = defaultdict(list)
    mods = list(range(0, 5))
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    ax.yaxis.set_major_formatter(matplotlib.ticker.PercentFormatter(xmax=1))

    for mod in mods:
        results_for_mod = H.foreach(
            partial(iron_solo_dependent_term, mod=mod),
            action=d6,
            first_challenge=d10,
            second_challenge=d10,
        )
        distribution_for_mod = dict(results_for_mod.distribution())

        for result in IronSoloResult:
            result_val = float(distribution_for_mod.get(result, 0))
            by_result[result].append(result_val)

    labels = [str(mod) for mod in mods]
    bottoms = [0.0 for _ in mods]

    for result in IronSoloResult:
        result_vals = by_result[result]
        assert len(result_vals) == len(mods)
        ax.bar(labels, result_vals, bottom=bottoms, label=result.name)
        bottoms = [
            bottom + result_val for bottom, result_val in zip(bottoms, result_vals)
        ]

    ax.legend()
    ax.set_xlabel("Modifier", color=text_color)
    ax.set_title("Ironsworn distributions", color=text_color)

Advanced topic – modeling Risis

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
 9
10
11
12
13
14
15
16
17
18
>>> for them in range(3, 6):
...   print("---")
...   for us in range(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}")
---
3d6 vs 3d6: {..., -1: 45.36%, 0:  9.28%, 1: 45.36%}
4d6 vs 3d6: {..., -1: 19.17%, 0:  6.55%, 1: 74.28%}
5d6 vs 3d6: {..., -1:  6.07%, 0:  2.99%, 1: 90.93%}
---
4d6 vs 4d6: {..., -1: 45.95%, 0:  8.09%, 1: 45.95%}
5d6 vs 4d6: {..., -1: 22.04%, 0:  6.15%, 1: 71.81%}
6d6 vs 4d6: {..., -1:  8.34%, 0:  3.26%, 1: 88.40%}
---
5d6 vs 5d6: {..., -1: 46.37%, 0:  7.27%, 1: 46.37%}
6d6 vs 5d6: {..., -1: 24.24%, 0:  5.79%, 1: 69.96%}
7d6 vs 5d6: {..., -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.

Plot: Modeling the Risus combat mechanic after the first roll

Source: plot_risus_first_round.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from typing import List, Tuple

from dyce import H


def do_it(style: str) -> None:
    import matplotlib.pyplot

    col_names = ["Loss", "Tie", "Win"]
    col_ticks = list(range(len(col_names)))
    num_scenarios = 3
    text_color = "white" if style == "dark" else "black"

    for i, them in enumerate(range(3, 3 + num_scenarios)):
        ax = matplotlib.pyplot.subplot(1, num_scenarios, i + 1)
        row_names: List[str] = []
        rows: List[Tuple[float, ...]] = []
        num_rows = 3

        for us in range(them, them + num_rows):
            row_names.append(f"{us}d6 …")
            rows.append((us @ H(6)).vs(them @ H(6)).distribution_xy()[-1])

        ax.imshow(rows)
        ax.set_title(f"… vs {them}d6", color=text_color)
        ax.set_xticks(col_ticks)
        ax.set_xticklabels(col_names, color=text_color, rotation=90)
        ax.set_yticks(list(range(len(rows))))
        ax.set_yticklabels(row_names, color=text_color)

        for y in range(len(row_names)):
            for x in range(len(col_names)):
                ax.text(
                    x,
                    y,
                    f"{rows[y][x]:.0%}",
                    ha="center",
                    va="center",
                    color="w",
                )

Modeling entire multi-round combats

With a little elbow finger 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).

 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
>>> from dyce import H, P
>>> import sys
>>> from enum import IntEnum, auto
>>> from typing import Callable, Dict, Tuple
>>> if sys.version_info >= (3, 9):
...   from functools import cache
... else:
...   from functools import lru_cache
...   cache = lru_cache(maxsize=None)

>>> class Risus(IntEnum):
...   LOSS = -1
...   DRAW = auto()
...   WIN = auto()

>>> @cache
... def risus_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:
...   if us < 0 or them < 0:
...     raise ValueError(f"cannot have negative numbers (us: {us}, them: {them})")
...   if us == 0 and them == 0:
...     return H({0: 1})  # should not happen unless combat(0, 0) is called from the start
...
...   def _resolve(us: int, them: int) -> H:
...     if us == 0: return H({-1: 1})  # we are out of dice, they win
...     if them == 0: return H({1: 1})  # they are out of dice, we win
...     this_round = us_vs_them_func(us, them)
...
...     def _next_round(this_round_outcome) -> H:
...       if this_round_outcome == Risus.LOSS: return _resolve(us - 1, them)  # we lost this round, and one die
...       elif this_round_outcome == Risus.WIN: return _resolve(us, them - 1)  # they lost this round, and one die
...       elif this_round_outcome == Risus.DRAW: return H({})  # ignore (immediately re-roll) all ties
...       else: assert False, f"unrecognized this_round_outcome {this_round_outcome}"
...
...     return H.foreach(_next_round, this_round_outcome=this_round)
...   return _resolve(us, them)

>>> for t in range(3, 6):
...   print("---")
...   for u in range(t, t + 3):
...     results = risus_combat_driver(
...       u, t,
...       lambda u, t: (u@H(6)).vs(t@H(6))
...     ).format(width=0)
...     print(f"{u}d6 vs {t}d6: {results}")
---
3d6 vs 3d6: {..., -1: 50.00%, 1: 50.00%}
4d6 vs 3d6: {..., -1: 10.50%, 1: 89.50%}
5d6 vs 3d6: {..., -1:  0.66%, 1: 99.34%}
---
4d6 vs 4d6: {..., -1: 50.00%, 1: 50.00%}
5d6 vs 4d6: {..., -1: 12.25%, 1: 87.75%}
6d6 vs 4d6: {..., -1:  1.07%, 1: 98.93%}
---
5d6 vs 5d6: {..., -1: 50.00%, 1: 50.00%}
6d6 vs 5d6: {..., -1: 13.66%, 1: 86.34%}
7d6 vs 5d6: {..., -1:  1.49%, 1: 98.51%}

There’s lot going on there. Let’s dissect it.

16
17
18
19
20
21
22
@cache
def risus_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:
  ...

Our “driver” takes three arguments:

  1. How many dice we have left (us);
  2. How many dice the opposition has left (them); and
  3. 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 opposition’s victory
    • An outcome of 1 signals our victory.
    • An outcome of 0 signals a tie.

The @cache decorator 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.

22
23
24
25
  if us < 0 or them < 0:
    raise ValueError(f"cannot have negative numbers (us: {us}, them: {them})")
  if us == 0 and them == 0:
    return H({0: 1})  # should not happen unless combat(0, 0) is called from the start

We make some preliminary checks that guard access to our recursive implementation so that it can be a little cleaner.

27
28
  def _resolve(us: int, them: int) -> H:
    ...
39
  return _resolve(us, them)

Skipping over its implementation for now, we define a our memoized recursive implementation (_resolve) and then call it with our initial arguments.

27
28
29
  def _resolve(us: int, them: int) -> H:
    if us == 0: return H({-1: 1})  # we are out of dice, they win
    if them == 0: return H({1: 1})  # they are out of dice, we win

Getting back to that implementation, these are our base cases. 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.

30
    this_round = us_vs_them_func(us, them)

Then, we compute the outcomes for this round using the provided resolution function.

32
33
    def _next_round(this_round_outcome) -> H:
      ...
38
    return H.foreach(_next_round, this_round_outcome=this_round)

Keeping in mind that we’re inside our recursive implementation, we define a dependent term specifically for use with H.foreach. This allows us to take our computation for this round, and “fold in” subsequent rounds.

32
33
34
35
    def _next_round(this_round_outcome) -> H:
      if this_round_outcome < 0: return _resolve(us - 1, them)  # we lost this round, and one die
      elif this_round_outcome > 0: return _resolve(us, them - 1)  # they lost this round, and one die
      else: return H({})  # 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).

44
45
46
47
    ... risus_combat_driver(
      u, t,
      lambda u, t: (u@H(6)).vs(t@H(6))
    ) ...

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, 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.

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
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
>>> def deadly_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.foreach(
...     lambda outcome: (us < them) - (us > them) if outcome == 0 else outcome,
...     outcome=h,
...   )
...   return h

>>> for t in range(3, 6):
...   print("---")
...   for u in range(t, t + 3):
...     results = risus_combat_driver(u, t, deadly_combat_vs).format(width=0)
...     print(f"{u}d6 vs {t}d6: {results}")
---
3d6 vs 3d6: {..., -1: 50.00%, 1: 50.00%}
4d6 vs 3d6: {..., -1: 36.00%, 1: 64.00%}
5d6 vs 3d6: {..., -1: 23.23%, 1: 76.77%}
---
4d6 vs 4d6: {..., -1: 50.00%, 1: 50.00%}
5d6 vs 4d6: {..., -1: 40.67%, 1: 59.33%}
6d6 vs 4d6: {..., -1: 30.59%, 1: 69.41%}
---
5d6 vs 5d6: {..., -1: 50.00%, 1: 50.00%}
6d6 vs 5d6: {..., -1: 44.13%, 1: 55.87%}
7d6 vs 5d6: {..., -1: 36.89%, 1: 63.11%}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
>>> import operator
>>> MISS = 0
>>> HIT = 1
>>> HIT_EXPLODE = 2
>>> d_evens_up_raw = H((
...   MISS,  # 1
...   HIT,  # 2
...   MISS,  # 3
...   HIT,  # 4
...   MISS,  # 5
...   HIT_EXPLODE,  # 6
... ))

>>> d_evens_up_exploded = d_evens_up_raw.explode(max_depth=3) ; d_evens_up_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
10
11
12
13
>>> def decode_hits(outcome):
...   return (outcome + 1) // 2  # equivalent to outcome // 2 + outcome % 2

>>> d_evens_up = H.foreach(decode_hits, outcome=d_evens_up_exploded)
>>> print(d_evens_up.format())
avg |    0.60
std |    0.69
var |    0.48
  0 |  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
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
>>> from functools import partial

>>> def evens_up_vs(us: int, them: int, goliath: bool = False) -> H:
...   h = (us@d_evens_up).vs(them@d_evens_up)
...   if goliath:
...     h = H.foreach(lambda outcome: (us < them) - (us > them) if outcome == 0 else outcome, outcome=h)
...   return h

>>> for t in range(3, 5):
...   print("-----------   ---- With Goliath Rule -----   --- Without Goliath Rule ---")
...   for u in range(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}")
-----------   ---- With Goliath Rule -----   --- Without Goliath Rule ---
3d6 vs 3d6:   {..., -1: 50.00%, 1: 50.00%}   {..., -1: 50.00%, 1: 50.00%}
4d6 vs 3d6:   {..., -1: 29.51%, 1: 70.49%}   {..., -1: 19.08%, 1: 80.92%}
5d6 vs 3d6:   {..., -1: 12.32%, 1: 87.68%}   {..., -1:  4.57%, 1: 95.43%}
-----------   ---- With Goliath Rule -----   --- Without Goliath Rule ---
4d6 vs 4d6:   {..., -1: 50.00%, 1: 50.00%}   {..., -1: 50.00%, 1: 50.00%}
5d6 vs 4d6:   {..., -1: 30.52%, 1: 69.48%}   {..., -1: 21.04%, 1: 78.96%}
6d6 vs 4d6:   {..., -1: 13.68%, 1: 86.32%}   {..., -1:  5.88%, 1: 94.12%}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> from dyce import H, 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 h, outcome: h if outcome == 1 else outcome)
>>> 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

Visualization:

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

Source: plot_4d6_variants.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from anydyce.viz import plot_line

from dyce import H, P


def do_it(style: str) -> None:
    import matplotlib.pyplot

    res1 = 3 @ H(6)
    p_4d6 = 4 @ P(6)
    res2 = p_4d6.h(slice(1, None))
    d6_reroll_first_one = H(6).substitute(
        lambda h, outcome: h if outcome == 1 else outcome
    )
    p_4d6_reroll_first_one = 4 @ P(d6_reroll_first_one)
    res3 = p_4d6_reroll_first_one.h(slice(1, None))
    p_4d6_reroll_all_ones = 4 @ P(H(5) + 1)
    res4 = p_4d6_reroll_all_ones.h(slice(1, None))
    res5 = 2 @ H(6) + 6
    res6 = 4 @ H(4) + 2

    ax = matplotlib.pyplot.axes()
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    plot_line(
        ax,
        [
            ("3d6", res1),  # marker="D"
            ("4d6 - discard lowest", res2),  # marker="s"
            ("4d6 - re-roll first 1, discard lowest", res3),  # marker="^"
            (
                "4d6 - re-roll all 1s (i.e., 4d5 + 1), discard lowest",
                res4,
            ),  # marker="*"
            ("2d6 + 6", res5),  # marker="x"
            ("4d4 + 2", res6),  # marker="o"
        ],
    )

    for line, marker in zip(ax.lines, "Ds^*xo"):
        line.set_marker(marker)

    ax.legend()
    ax.set_title("Comparing various take-three-of-4d6 methods", color=text_color)

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
>>> 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:

Plot: Attack with saving throw for half damage

Source: plot_burning_arch.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from anydyce.viz import plot_line

from dyce import H


def do_it(style: str) -> None:
    import matplotlib.pyplot

    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)

    ax = matplotlib.pyplot.axes()
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    plot_line(ax, [("", damage_half_on_save)])
    ax.set_title("Attack with saving throw for half damage", color=text_color)

An alternative using the H.foreach class method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> import operator
>>> H.foreach(
...   lambda outcome: (
...     burning_arch_damage // 2
...     if operator.__ge__(outcome, 10)
...     else burning_arch_damage
...   ),
...   outcome=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(width=0))
{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(width=0))
{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(width=0))
{..., False: 28.75%, True: 71.25%}
1
2
3
4
>>> # VAR h = (1d4 >= 2) OR !(1d20 == 2)
>>> h = H(4).ge(2) | H(20).ne(2)
>>> print(h.format(width=0))
{..., 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(width=0))
{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
>>> # MAX(4d7, 2d10)
>>> max_h = P(4@H(7), 2@H(10)).h(-1)
>>> print(max_h.format(width=0))
{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(width=0))
{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
>>> from dyce import H
>>> single_attack = 2@H(6) + 5

>>> def gwf(h: H, outcome):
...   return h if outcome in (1, 2) else outcome

>>> 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%, ...}

Example 1 visualization:

Plot: Comparing a normal attack to an enhanced one

Source: plot_great_weapon_fighting.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from anydyce.viz import plot_burst, plot_line

from dyce import H


def do_it(style: str) -> None:
    import matplotlib.pyplot

    single_attack = 2 @ H(6) + 5

    def gwf(h: H, outcome):
        return h if outcome in (1, 2) else outcome

    great_weapon_fighting = 2 @ (H(6).substitute(gwf)) + 5

    text_color = "white" if style == "dark" else "black"
    label_sa = "Normal attack"
    label_gwf = "“Great Weapon Fighting”"
    ax_plot = matplotlib.pyplot.subplot2grid((1, 2), (0, 0))
    ax_plot.tick_params(axis="x", colors=text_color)
    ax_plot.tick_params(axis="y", colors=text_color)
    plot_line(ax_plot, [(label_sa, single_attack), (label_gwf, great_weapon_fighting)])
    ax_plot.lines[0].set_color("tab:green")
    ax_plot.lines[1].set_color("tab:blue")
    ax_plot.legend()
    ax_burst = matplotlib.pyplot.subplot2grid((1, 2), (0, 1))
    plot_burst(
        ax_burst,
        h_inner=great_weapon_fighting,
        h_outer=single_attack,
        title=f"{label_sa}\nvs.\n{label_gwf}",
        inner_color="RdYlBu_r",
        outer_color="RdYlGn_r",
        text_color=text_color,
    )

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
>>> normal_hit = H(12) + 5
>>> critical_hit = 3@H(12) + 5
>>> advantage = (2@P(20)).h(-1)

>>> def crit(outcome):
...   if outcome == 20: return critical_hit
...   elif outcome + 5 >= 14: return normal_hit
...   else: return 0

>>> advantage_weighted = H.foreach(crit, outcome=advantage)

Example 2 visualization:

Plot: Advantage-weighted attack with critical hits

Source: plot_advantage.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from anydyce.viz import plot_line

from dyce import H, P


def do_it(style: str) -> None:
    import matplotlib.pyplot

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

    def crit(outcome):
        if outcome == 20:
            return critical_hit
        elif outcome + 5 >= 14:
            return normal_hit
        else:
            return 0

    advantage_weighted = H.foreach(crit, outcome=advantage)

    ax = matplotlib.pyplot.axes()
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    plot_line(
        ax,
        [
            ("Normal hit", normal_hit),
            ("Critical hit", critical_hit),
            ("Advantage-weighted", advantage_weighted),
        ],
    )
    ax.legend()
    ax.set_title("Advantage-weighted attack with critical hits", color=text_color)

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

Source:

1
output [highest 3 of 10d [explode d10]] named "10k3"

Translation:

1
2
>>> from dyce import H, P
>>> res = (10@P(H(10).explode(max_depth=3))).h(slice(-3, None))

Visualization:

Plot: Taking the three highest of ten exploding d10s

Source: plot_d10_explode.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from anydyce.viz import plot_line

from dyce import H, P


def do_it(style: str) -> None:
    import matplotlib.pyplot

    ax = matplotlib.pyplot.axes()
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    plot_line(
        ax,
        [
            (
                f"{depth} rerolls",
                (10 @ P(H(10).explode(max_depth=depth))).h(slice(-3, None)),
            )
            for depth in range(5, -1, -1)
        ],
    )

    for line in ax.lines:
        line.set_marker("")

    ax.legend()
    ax.set_title("Taking the three highest of ten exploding d10s", color=text_color)

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
11
12
>>> from dyce import P
>>> from dyce.p import RollT

>>> def dupes(roll: RollT):
...     dupes = 0
...     for i in range(1, len(roll)):
...         if roll[i] == roll[i - 1]:
...             dupes += 1
...     return dupes

>>> res_15d6 = P.foreach(dupes, roll=15@P(6))
>>> res_8d10 = P.foreach(dupes, roll=8@P(10))

Visualization:

Plot: Chances of rolling <i>n</i> duplicates

Source: plot_dupes.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from anydyce.viz import plot_scatter

from dyce import P
from dyce.p import RollT


def do_it(style: str) -> None:
    import matplotlib.pyplot

    def dupes(roll: RollT):
        dupes = 0
        for i in range(1, len(roll)):
            if roll[i] == roll[i - 1]:
                dupes += 1
        return dupes

    res_15d6 = P.foreach(dupes, roll=15 @ P(6))
    res_8d10 = P.foreach(dupes, roll=8 @ P(10))

    matplotlib.pyplot.rcParams["lines.markersize"] *= 2
    ax = matplotlib.pyplot.axes()
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    plot_scatter(ax, [("15d6", res_15d6), ("8d10", res_8d10)], alpha=1.0)
    ax.legend()
    ax.set_title("Chances of rolling $n$ duplicates", color=text_color)

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

Source:

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

Translation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> from dyce import H, P

>>> def roll_and_keep(p: P, k: int):
...   assert p.is_homogeneous
...   max_d = max(p[-1]) if p else 0
...   for roll, count in p.rolls_with_counts():
...     total = sum(roll[-k:]) + sum(1 for outcome in roll[:-k] if outcome == max_d)
...     yield total, count

>>> H(roll_and_keep(6@P(6), 3))
H({3: 1, 4: 6, 5: 21, 6: 78, 7: 207, ..., 17: 5535, 18: 2500, 19: 375, 20: 30, 21: 1})

Visualization:

Plot: Roll-and-keep mechanic comparison

Source: plot_roll_and_keep.py
Binder
 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
# ======================================================================================
# Copyright and other protections apply. Please see the accompanying LICENSE file for
# rights and restrictions governing use of this software. All rights not expressly
# waived or licensed are reserved. If that file is missing or appears to be modified
# from its original, then please contact the author before viewing or using this
# software in any capacity.
# ======================================================================================

from __future__ import annotations

from typing import Iterator, Tuple

from anydyce.viz import plot_line

from dyce import H, P


def do_it(style: str) -> None:
    import matplotlib.pyplot

    def roll_and_keep(p: P, k: int):
        assert p.is_homogeneous
        max_d = max(p[-1]) if p else 0

        for roll, count in p.rolls_with_counts():
            total = sum(roll[-k:]) + sum(1 for outcome in roll[:-k] if outcome == max_d)
            yield total, count

    d, k = 6, 3

    ax = matplotlib.pyplot.axes()
    text_color = "white" if style == "dark" else "black"
    ax.tick_params(axis="x", colors=text_color)
    ax.tick_params(axis="y", colors=text_color)
    marker_start = 0

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

    plot_line(ax, tuple(_roll_and_keep_hs()), alpha=0.75)

    for i in range(marker_start, len(ax.lines)):
        ax.lines[i].set_marker(".")

    marker_start = len(ax.lines)

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

    plot_line(ax, tuple(_normal()), alpha=0.25)

    for i in range(marker_start, len(ax.lines)):
        ax.lines[i].set_marker("o")

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

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.p import RollT

>>> def brawl(roll_a: RollT, roll_b: RollT):
...   a_successes = sum(1 for v in roll_a if v >= roll_b[-1])
...   b_successes = sum(1 for v in roll_b if v >= roll_a[-1])
...   return a_successes - b_successes

Rudimentary visualization using built-in methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> from dyce import P
>>> res = P.foreach(brawl, roll_a=3@P(6), roll_b=3@P(6))
>>> print(res.format())
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(roll_a: RollT, roll_b: RollT):
...   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])
...   result = a_successes - b_successes or (roll_a > roll_b) - (roll_a < roll_b)
...   return result

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
>>> res = P.foreach(brawl_w_optional_swap, roll_a=3@P(6), roll_b=3@P(6))
>>> print(res.format())
avg |    2.36
std |    0.88
var |    0.77
 -1 |   1.42% |
  0 |   0.59% |
  1 |  16.65% |########
  2 |  23.19% |###########
  3 |  58.15% |#############################

>>> res = P.foreach(brawl_w_optional_swap, roll_a=4@P(6), roll_b=4@P(6))
>>> print(res.format())
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% |################