Skip to content

Modeling Risus combat

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.

Modeling a single combat round

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.simplefilter("ignore", 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(scaled=True, width=65))
              avg |   -0.63
              std |    0.73
<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] = ...,
        their_pool_sizes: Sequence[int] = ...,
    ) -> ScenariosDataframesT: ...

    def us_vs_them_heatmap_subplot(
        vs_dfs: ScenariosDataframesT,
        cmap_name: str = ...,
        plt_total_rows: int = ...,
        plt_cur_row: int = ...,
    ) -> 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
from typing import cast

import matplotlib as mpl
import matplotlib.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] = []
    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.from_counts(
                dict.fromkeys(Versus, 0),
                us_vs_them_func(our_pool_size, their_pool_size),
                preserve_zero_counts=True,
            )
            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()  # ruff: ignore[redefined-loop-name]
        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 <code>dyce</code>!

1
2
3
4
5
vs_dfs = vs_scenarios_dataframes(Versus.single_round_us_vs_them)
us_vs_them_heatmap_subplot(vs_dfs, cmap_name="magma")
fig = plt.gcf()
fig.suptitle("Modeling the Risus combat mechanic after the first roll")
fig.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
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:
        raise ValueError("at least one participant must have a non-empty pool")

    # 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]:
        # 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 lean on dyce 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, (  # ruff: ignore[assert-false, pytest-assert-always-false]
                        f"unrecognized this_round.outcome {this_round.outcome}"
                    )

        return expand(
            _resolve_next_round_from_this_round,
            this_round_results,
            min_path_probability=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 <code>dyce</code>!

1
2
3
4
5
6
7
8
9
vs_dfs = vs_scenarios_dataframes(
    risus_combat_driver,
    our_pool_rel_sizes=tuple(range(-1, 3)),
    their_pool_sizes=range(2, 6),
)
us_vs_them_heatmap_subplot(vs_dfs, cmap_name="magma")
fig = plt.gcf()
fig.suptitle("Modeling an entire standard combat in Risus")
fig.set_size_inches(8.0, 3.2)

Plot: Modeling an entire standard combat in Risus

Modeling different combat resolution methods

Best-of-Set with and without the Goliath Rule

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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
)

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)).at(-1)
    their_best = (their_pool_size @ P(6)).at(-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,
    ) -> list[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,
) -> list[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 <code>dyce</code>!

1
2
3
4
5
6
7
8
9
viz_multi_round_goliath_helper(best_of_set_single_round_us_vs_them)
fig = plt.gcf()
fig.suptitle(
    "Full “Best-of-Set” 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)"
)
fig.set_size_inches(9.6, 9.6)

Plot: Full “Best-of-Set” combat results based on starting pool sizes; Standard (top row); Best-of-Set w/o the Goliath Rule (middle row); Best-of-Set w/ the Goliath Rule (bottom row)

Evens-Up with and without the Goliath Rule

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] = 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 outcome in that distribution that is even, our streak ended in a miss (i.e., zero or more HIT_EXPLODE values followed by a MISS). For every outcome that is odd, out streak ended in a non-exploding hit that will need to be tallied (i.e., zero or more HIT_EXPLODE values followed by a HIT).

An outcome of 0 indicates a MISS. An outcome of 1 indicates a HIT. An outcome of 2 indicates a HIT_EXPLODE followed by a MISS. An outcome of 5 indicates a HIT_EXPLODE followed by another HIT_EXPLODE followed by a HIT.

In other words, dividing by two will tell us how many exploding hits we had along the way. The remainder will tell us whether exploding ended in a miss or a hit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
d_evens_up = H.from_counts(
    (
        outcome // 2  # a tally of any exploded hits
        + outcome % 2,  # any final hit
        count,
    )
    for outcome, count in d_evens_up_raw_exploded.items()
)
# A clever alternative
assert d_evens_up == (d_evens_up_raw_exploded + 1) // 2

print(d_evens_up.format(scaled=True, width=65))
avg |    0.60
std |    0.69
  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 <code>dyce</code>!

1
2
3
4
5
6
7
8
9
viz_multi_round_goliath_helper(evens_up_single_round_us_vs_them)
fig = plt.gcf()
fig.suptitle(
    "Full “Evens-Up” 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)"
)
fig.set_size_inches(9.6, 9.6)

Plot: Full “Evens-Up” combat results based on starting pool sizes; Standard (top row); Evens-Up w/o the Goliath Rule (middle row); Evens-Up w/ the Goliath Rule (bottom row)

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!