6
\$\begingroup\$

I'm trying to get the probabilities for the following:

1:Roll xd6 vs xd6.

2: The first pool is a regular pool, the second pool rerolls 1s(even on rerolls) and explodes on 6s(The exploding die does not reroll on a 1, but it can explode again).

3: Starting with the highest die in the 1st pool, check it vs the highest die in the 2nd pool If the second pool die is equal or higher, eliminate both dice.

4: Continue until there are no dice in the one of the pools, or every dice in the first pool is larger than the dice remaining in the second pool.

5: Output the odds for the highest die remaining in the first pool.

Basically checking the highest single result in the 1st pool after using the 2nd pool to "cover" dice in the first pool.

I have the following function written that appears to work (When running on a local anydice interpreter).

function: attack ADICE:s vs DDICE:s RR:s {
  ATTACK:{}
  DEFENSE:{}
  DROLLS: {}
  RRINDEX: 0
  loop N over {1..#DDICE + 25} 
  { 
    if (#DROLLS - RRINDEX + 1)@DDICE = 1 
    {
      if #DROLLS = 0
      {
        DROLLS: {DROLLS, RR}
        RRINDEX: RRINDEX + 1
      }else{
        DROLLS: {{0..(#DROLLS - 1)}@DROLLS, RR}
        RRINDEX: RRINDEX + 1
      }
    }else{
      DROLLS: {DROLLS, (#DROLLS - RRINDEX + 1)@DDICE}
    }
  }
  
  loop N over {1..(#DROLLS + 25)} 
  { 
    if N@DROLLS = 6{DROLLS: {DROLLS, RR }}
  }
  
  DROLLS: [sort DROLLS]
  
  loop N over {1..#ADICE} 
  { 
    if #DEFENSE >= #DROLLS {ATTACK: {ATTACK, N@ADICE}}
    else
    {
      if N@ADICE > (#DEFENSE + 1)@DROLLS {ATTACK: {ATTACK, N@ADICE}}
      if N@ADICE <= (#DEFENSE + 1)@DROLLS {DEFENSE: {DEFENSE, (#DEFENSE + 1)@DROLLS}}
    }
    
  }
result: 1@ATTACK
}

I originally had the rerolling and exploding loops iterate an extra 20 times(for "depth"), but I ended up with discrepancies in odds (6d6 vs 5d6 had lower odds than 6d6 vs 6d6).

I increased the loops to iterate an extra 5 times further(25 depth) and it fixed my discrepancy, but it's also altering odds for lower pools more than I had anticipated.

I am not really a coder, so I would appreciate some more experienced eyes taking a look at this function to see if my logic is correct. I don't want to keep waiting for my output and redoing my spreadsheets if I screwed something up.

New contributor
FarmerCoder is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.
\$\endgroup\$
2
  • \$\begingroup\$ Just to be clear, when you explode the dice that roll a 6 in the second pool, do you add the extra rolls to the values of the dice that rolled a 6 or do you add them as extra dice to the pool? In other words, if you roll (5, 6, 6) and then roll (3, 4) on the explosion roll, does that make (5, 9, 10) or (3, 4, 5, 6, 6)? \$\endgroup\$ Commented 2 days ago
  • \$\begingroup\$ I add them as extra dice to the pool 2nd pool \$\endgroup\$ Commented 2 days ago

2 Answers 2

6
\$\begingroup\$

Problems with the original code

Your strategy appears to be to pre-roll a bunch of extra d6s in RR, and then "pop" them whenever you need to process a reroll or explosion, replacing or appending to the defense pool respectively. (I'm also taking this to mean you intend for each explosion to be a separate die rather than adding onto the initial die -- the opposite interpretation as Ilmari Karonen's answer.)

This would work well if you were processing a single roll rather than computing the full distribution. Unfortunately, there are several problems with the original code:

Sequences expanded from dice pool arguments are always sorted

When you send e.g. 20d6 to the RR parameter, RR comes out sorted inside the function. So you will incorrectly always see the 6s first. That's also why 25d6 is affecting the results more than expected, since that's 5 more opportunities to frontload additional high rolls.

"Pre-rolling" large numbers of dice in their original order is too costly

Unfortunately, there's no easy way in AnyDice to get RR in their original order. Even if you could, it would make the calculation much more expensive. For 20d6, this would increase the number of possibilities for RR

$$ \text{from} \qquad \binom{20 + 6 - 1}{20} = 53,130 \qquad \text{to} \qquad 6^{20} = 3,656,158,440,062,976 $$

So pre-rolling 20 dice was never going to work in terms of computational cost.

You're appending too many dice

When you do {DROLLS, RR}, you're appending the entire RR sequence to DROLLS rather than a single die at a time. You probably meant something like {DROLLS, RRINDEX@RR}. The handling of RRINDEX appears off as well; remember that AnyDice starts indexing at 1, not 0.

There's no reason to continue past the first kept attack die

Since the highest remaining dice in each pool are always the ones being compared, if the attack die beats the defense die, it will beat all remaining defense dice as well. So as soon as we see an uncancelled attack die, that die is the result, and there is no reason to consider the rest of the dice.

"Binning" defense dice

A more feasible way to handle this random-sized dice pool is to first determine how many defense dice (including explosions) fell into each of three categories:

  • Those that rolled a 6.
  • Those that rolled 2-5 ("mids"). We don't immediately determine exactly what number these dice rolled, only how many dice rolled within this range. These mids could come from either the initial level or the post-explosion levels.
  • Those that rolled a 1. 1s can only occur post-explosion, since all initial-level 1s are rerolled. If the initial level were the same as the exploding levels, we wouldn't need to go through this whole binning process.

Note that we choose to split between 2-5s and 1s instead of splitting between initial-level 2-5s and exploding-level 1-5s. This is because a pool of flat 1s doesn't expand into multiple possibilities. Minimizing the number of non-trivial pools is important for efficiency; the "pre-rolling" problem above can be seen as the difference between one pool of 20 dice and twenty pools of 1 die each.

Using digits of a die to represent a vector

This strategy does require a joint distribution of the three. It is possible, though a bit cumbersome, to represent a joint distribution in AnyDice using digits. We start with

d{1, 10:4, 100}

Here we place the ones in the ones place, the mids in the tens place, and the sixes in the hundreds and thousands places.

Explosions and rerolls

function: reroll N:n on ROLL:n {
 if ROLL != N { result: ROLL }
}

DEFDIE: [reroll 1 on [explode d{1, 10:4, 100}]]

Here we don't return anything if the die is to be rerolled, which in AnyDice eliminates it from the probability space as desired. Note that the reroll goes outside the explode so that it only rerolls the initial level.

From a single die to a pool

At this point, the digits of DEFDIE represent the sixes, mids, and ones produced by a single defense die. To find the combined result of several defense dice, we can simply add them together using the d operator since each individual digit adds together:

function: attack ATK:n defense DEF:n {
 result: [attack ATK defense counts 10000 + DEFdDEFDIE]
}

Here we add 10000 in order to make sure the result always has the same number of digits. AnyDice indexes digits starting from the most significant by default, so if we have a varying number of digits, we could get the wrong digits when we go to extract each component. If you want, you can index from the least significant digit instead by using

set "position order" to "lowest first"

but note that this also affects the order of sorting.

Extracting the digits

function: attack ATK:n defense counts DEFCOUNTS:n {
 SIXES: {2:10,3}@DEFCOUNTS
 if (SIXES >= ATK) {result: 0}
 result: [attack ATKd6 defense SIXES and (4@DEFCOUNTS)d(d4+1) and 5@DEFCOUNTS]
}

Here, {2:10,3}@DEFCOUNTS takes the thousands place, multiplies by 10, and adds the hundreds place. This extracts the number of sixes. We exit early if there are enough sixes to cancel all the attack dice; the inner function call is fairly expensive since it is expanding two pools at the same time.

The last two arguments likewise extract the number of mids and ones, producing a dice pool for the mids.

We need the separate call level here because we want to extract all three bin counts from the same roll of DEFCOUNTS rather than from three independent rolls.

Computing the final result

Finally, we can compare elements and compute the final result. We don't need an extra sort because the elements are already sorted.

function: attack ATK:s defense SIXES:n and MIDS:s and ONES:n {
 DEF: {6:SIXES, MIDS, 1:ONES}
 loop I over {1..#ATK} {
  if I@ATK > I@DEF {
   result: I@ATK
  }
 }
 result: 0
}

Here is the full script on AnyDice.

Icepool

Unfortunately, AnyDice times out beyond around 4 dice on each side. Here's the same strategy in my own Icepool Python probability package, which can compute larger pools in a reasonable amount of time.

First, we compute the distribution resulting from a single initial defense die. Starting with a d6, we create a Vector-valued die whose elements are the counts of 1s, 2-5s, and 6s.

from icepool import Pool, Reroll, d, map, vectorize

depth = 3

def make_def_die(roll, reroll_ones, depth):
    if depth == 0:
        return vectorize(0, 0, 0)
    if roll == 1:
        if reroll_ones:
            return Reroll
        else:
            return vectorize(0, 0, 1)
    elif roll == 6:
        # Explode to the next level. Post-explosion 1s are not rerolled.
        return vectorize(1, 0, 0) + d(6).map(make_def_die, False, depth-1)
    else:
        return vectorize(0, 1, 0)

def_die = d(6).map(make_def_die, True, depth)

depth = 3 is the most similar to the AnyDice code provided before, but you can try higher if you like.

Then, based on the binning, we can construct a corresponding mixed pool of 1s, (d4 + 1)s, and 6s using another function:

def make_def_pool(sixes, mids, ones):
    return Pool([6] * sixes + [1] * ones + [d(4) + 1] * mids + [1] * ones)

From here we can make the final computation:

def compute_attack(atk_count, def_count):
    atk_pool = d(6).pool(atk_count)
    def_pool = (def_count @ def_die).map_to_pool(make_def_pool)
    return atk_pool.sort_pair('>', def_pool, extra='keep').highest(1).sum()
  • atk_pool is just a pool of d6s.
  • Since Vectors add component wise, we can roll our desired number of def_die and add them together to get the total numbers of 1s, 2-5s, and 6s across them. This is what def_count @ def_die does. Note that depth effectively applies to each individual defense die.
  • Then we use map_to_pool and make_def_pool to convert this binning into a Pool.
  • sort_pair effectively sorts both pools, pairs them up 1:1 in descending order like RISK, keeps the elements from atk_pool that beat their corresponding defense die, and keeps the single highest. (If all attacker dice were eliminated, nothing gets summed and the result is 0.) Internally, this line uses a dynamic programming algorithm instead of actually enumerating all possible sorted rolls of the two pools, but the details are a much longer story.

Example result of output(compute_attack(4, 4)):

Graph.

You can try this in your browser here.

When a result of 1 or 2 is impossible

Ilmari Karonen's answer, which took the interpretation that explosions are added to the initial die, noted that when the number of defense dice is equal to or greater than the number of attack dice, a result of 1 or 2 is impossible. It turns out this is also true if the explosions instead produce separate dice: No attack die will go unpaired, so for an attack die of 2 to win it must be paired with a 1; however, since the defense can only roll a 1 after exploding, each 1 on defense implies at least one additional 6 was also added to the defense pool, which would have pushed the 1s out of consideration in the first place.

\$\endgroup\$
4
  • \$\begingroup\$ I’m still working my way through the answers here, but I only pass 1d6 to RR. I was under the assumption that it would evaluate as a new d6 every time I referenced it. \$\endgroup\$ Commented 2 days ago
  • \$\begingroup\$ Unfortunately that's not the case -- it will be treated as a pool of a single die, so the function will be evaluated using its expansion to a sequence with a single element, i.e. RR = {1}, RR = {2}, RR = {3}, etc. Also unfortunately, changing the type to RR:d won't work either, because the only time execution does a probabilistic fork in AnyDice is when expanding the arguments to a function call. If you try to append a d6 to a sequence, what it will actually do is append the possible values of the d6, namely the six elements {1, 2, 3, 4, 5, 6}, rather than a single roll of a d6. \$\endgroup\$ Commented 2 days ago
  • 1
    \$\begingroup\$ This is fantastic. I had found icepool, but was dreading starting from scratch. This is a great in-depth intro for me and is much appreciated. \$\endgroup\$ Commented yesterday
  • \$\begingroup\$ Glad I could help! \$\endgroup\$ Commented yesterday
6
\$\begingroup\$

Let's break this into smaller steps.

I'll start with rerolling ones and exploding on six. The rerolling is easy: if you roll a d6 and reroll it until it's at least 2, that's equivalent to rolling d5 + 1 or, equivalently, d{2..6}. (The latter is AnyDice's notation for a 5-sided die numbered from 2 to 6.)

We could pass this die to AnyDice's normal [explode DIE] function, but that would cause the explosion dice to also reroll ones. Instead, I'll reuse a slightly more flexible function from one of my earlier answers here:

function: ROLL:n add BONUS:d to TARGET:s {
  if ROLL = TARGET { result: ROLL + BONUS }
  result: ROLL
}

function: explode DIE:d as BONUS:d {
  result: [DIE add BONUS to [maximum of DIE]]
}

output [explode d5 + 1 as d6] named "d6, reroll ones, explode 6 (once)"

Note that the code above only explodes the die once. If we want to allow more explosions, we can use AnyDice's standard [explode DIE] function for the later stages, like this:

set "explode depth" to 2
output [explode d5 + 1 as [explode d6]] named "d6, reroll ones, explode 6 (at most three times)"

Next, to roll multiple exploding dice, the most efficient way is to first save one exploding die into a variable and then roll several of them:

CUSTOM_DIE: [explode d5 + 1 as [explode d6]]
output 5dCUSTOM_DIE named "5d6, reroll ones, explode 6 (at most three times)"

If you plot this in graph mode and compare it with a normal 5d6, you should see something like this:

AnyDice screenshot


Finally, the elimination mechanic. For this you need to examine the actual numbers rolled in each pool to compare them. In AnyDice the only way to examine the actual numbers rolled in a dice pool is to "freeze" the roll by passing it as a parameter to a function expecting a sequence (or a number, if you only care about the sum of the rolled dice), like this:

function: compare FIRST:s and SECOND:s {
  \ TODO: compare the values in the sequences here \
}

output [compare 5d6 and 5dCUSTOM_DIE]

OK, but what should we do inside the function? Since we want to return the highest remaining die in the first pool that remains after elimination, a natural approach would be to loop over the dice in the first pool from highest to lowest. Conveniently, that's exactly the order in which AnyDice sorts dice rolls by default, so we can just do something like this:

function: compare FIRST:s and SECOND:s {
  loop ROLL over FIRST {
    if \ ROLL is not eliminated \ {
      result: ROLL
    }
  }
  result: 0  \ all dice in the first pool were eliminated \
}

This is starting to look good. We just need to implement the elimination mechanic. For that, we'll also need to keep track of how many dice we've used up from the second pool:

function: compare FIRST:s and SECOND:s {
  USED: 0  \ how many dice from the second pool have been used? \ 
  loop ROLL over FIRST {
    if ROLL > (USED + 1)@SECOND {
      result: ROLL
    } else {
      USED: USED + 1
    }
  }
  result: 0  \ all dice in the first pool were eliminated \
}

There, that should do it! Just to make sure, we should double-check what happens if either pool runs out and make sure it's what we expect:

  1. If the first pool runs out, we fall out of the loop and return zero. We could return anything we want in this case, but zero seems like a reasonable way to indicate that all dice in the first pool were eliminated.

  2. If the second pool runs out, (USED + 1)@SECOND will evaluate to zero, since that's what AnyDice does if you ask for a sequence element that's past the end of the sequence. Conveniently, since ROLL is always greater than zero, this means we won't eliminate ROLL and will instead return it.

Note that, as written, this code will not loop until "every dice in the first pool is larger than the dice remaining in the second pool." Instead, it will stop as soon as it finds one die in the first pool that is larger than all remaining non-eliminated dice in the second pool.

But since you're only interested in the highest non-eliminated die in the first pool, stopping as soon as you find it seems reasonable; continuing the loop further would not change the highest non-eliminated die in the first pool.


By the way, a curious feature of your mechanic (assuming I understood it right; that's why I asked you to clarify it) is that it doesn't matter how many times the dice in the second pool explode! As soon as a die explodes at least once, its value will be at least 7, which is higher than any result possible on a normal d6. Thus, any exploded die in the second pool will always be high enough to eliminate any die in the first pool. And since you don't care by how much the eliminated dice were exceeded, the exact values of the exploded dice don't matter!

That's actually convenient, because running [compare 5d6 and 5dCUSTOM_DIE] with the exploded custom dice as defined above will time out (due to there being too many possible rolls with 5 of the exploded dice). But since any roll of at least 7 in the second pool is equivalent to any other, we can simplify the custom die definition to just:

CUSTOM_DIE: [explode d5 + 1 as 1]

or even down to just:

CUSTOM_DIE: d{2..5, 7}

With this change, the following code runs fine:

output [compare 4d6 and 5dCUSTOM_DIE] named "4d6 (normal) vs 5d6 (reroll 1, explode 6)"
output [compare 5d6 and 5dCUSTOM_DIE] named "5d6 (normal) vs 5d6 (reroll 1, explode 6)"
output [compare 6d6 and 5dCUSTOM_DIE] named "6d6 (normal) vs 5d6 (reroll 1, explode 6)" 

and generates the following output:

AnyDice screenshot

As a notable feature we can see that, unless the first pool has more dice than the second, the highest non-eliminated die can never be less than 3. That's because the dice in the second pool always roll at least 2 (since 1s are rerolled) and the elimination rule triggers if the die from the second pool is "equal or higher". So unless there are more dice in the first pool than in the second, rolls of 2 or less in the first pool are always eliminated.

\$\endgroup\$

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.