8
\$\begingroup\$

I'm trying to write a custom explode function, which has a limit on the number of rerolls available.

A basic limit of limiting the number of rerolls per die is easy enough. The problem is trying to model a total number of rerolls across all dice.

So, if I have a limit of 3 rerolls, and roll 3d6, exploding on a 6, then if I get:

6 > 6 > 6 > 4
6
6

That first 6 uses up the 3 available rerolls, and the remaining 6's don't get any.

The way I'm implementing it is by using a recursive function to handle the explosion mechanics, with a limit on how many times it can recurse. Unfortunately, this limit isn't something I can track outside the function since the function is being called with all possible die values, and the overall logic just doesn't work.

I've tried several different approaches, but the results range from no better to much worse.

It mostly seems to revolve around not being able to generate all possible die result sequences while inside a function (where I can control the LIMIT calculation) because that seems to be reserved to function calls (which can't return meaningful modified parameter values since they collate multiple calls).

Here's my current code:

YES: 1
NO: 0
EXPLODES: 0
EXTRA_EMPOWERS: 0

function: explode ROLL:n LIMIT:n ISREROLL {
  if LIMIT > 0 {
    if ROLL = 6 {
      EXPLODES: LIMIT - 1
      result: 6 + [explode d6 EXPLODES NO]
    }
    if ROLL < 4 & EXTRA_EMPOWERS > 0 & !ISREROLL {
      EXTRA_EMPOWERS: EXTRA_EMPOWERS - 1
      result: [explode d6 LIMIT YES]
    }
  }
  
  result: ROLL
}

function: burst ROLL:s cha CHA empowered EMPOWERED {
  if EMPOWERED {
    USED: [count {1,2,3} in ROLL]
    EXTRA_EMPOWERS: [highest of CHA - USED and 0]
  }
  else {
    EXTRA_EMPOWERS: 0
  }
  
  EXPLODES: CHA
  TOTAL: 0

  loop N over {1..#ROLL} {
    if N@ROLL = 6 {
      TOTAL: TOTAL + [explode 6 EXPLODES NO]
    }
    else if N@ROLL < 4 & EMPOWERED {
      TOTAL: TOTAL + [explode 1d6 EXPLODES YES]
    }
    else {
      TOTAL: TOTAL + N@ROLL
    }
  }

  result: TOTAL
}


output [burst 1d6 cha 3 empowered NO] named "Level 1"
output [burst 2d6 cha 4 empowered NO] named "Level 5"
\
output [burst 3d6 cha 5 empowered NO] named "Level 11"
output [burst 4d6 cha 5 empowered NO] named "Level 17"
output [burst 4d6 cha 6 empowered NO] named "Level 20"

output [burst 1d6 cha 3 empowered YES] named "Level 1 empowered"
output [burst 2d6 cha 4 empowered YES] named "Level 5 empowered"
output [burst 3d6 cha 5 empowered YES] named "Level 11 empowered"
output [burst 4d6 cha 5 empowered YES] named "Level 17 empowered"
output [burst 4d6 cha 6 empowered YES] named "Level 20 empowered"
\

The issue applies to both EXTRA_EMPOWERS and EXPLODES/LIMIT variables, but fixing either should allow fixing both.

Is there a way to do this in AnyDice?

\$\endgroup\$

2 Answers 2

7
\$\begingroup\$

Explode only

Since the explosions are shared among the pool, you'll need to track the entire pool, which calls for using type sequence for the roll. Then, you can explicitly track how many explosions are remaining and recurse.

function: explode ROLL:s limit LIMIT:n {
  X: [lowest of [count 6 in ROLL] and LIMIT]
  if X = 0 {
    result: ROLL
  } else {
    result: ROLL + [explode Xd6 limit LIMIT - X]
  }
}

output [explode 3d6 limit 3]
output 3d[explode d6]

On anydice.com.

Depending on your limit/precision needs, you may also want to set e.g.

set "maximum function depth" to 20

Explode and empower together

However, the output is a distribution over numbers, not sequences, so you can't simply chain the result from empowers into explosions. There's also some ambiguity about how the two are combined: do they have to applied in a specific order, or can the player choose in what order to interleave them? Given the choice, the player would prefer to explode first whenever possible, since this gives them more choices of which dice to empower. This is useful because the player would prefer to empower a lower die rather than a higher die. In fact, the player would only want to feed in one empower at a time, checking to see whether it explodes.

A simple example: You initially roll a 3 and a 6 on 2d6 with 1 Empower and 1 Explode.

  • If you Empower first, the mean total is the initial 6 + 3.5 on the Empower + 3.5 on the explode = 13.
  • If you Explode first, you have six cases depending on what you roll on the Exploded die. On 3-6 you reroll the initial 3 as before. However, on 1-2 you can reroll the Exploded die instead of the initial die. So the cases are:
    1. initial 6 + initial 3 + 3.5 on the Empower
    2. initial 6 + initial 3 + 3.5 on the Empower
    3. initial 6 + 3.5 on the Empower + exploded 3
    4. initial 6 + 3.5 on the Empower + exploded 4
    5. initial 6 + 3.5 on the Empower + exploded 5
    6. initial 6 + 3.5 on the Empower + exploded 6

The mean of the non-Empowered die is 4, so the mean increases by 0.5 for Exploding first.

Using Icepool

I think it's possible in principle to evaluate this in AnyDice but it would be more cumbersome to do so, and performance is likely to suffer without memoization. Here's a solution using my own Icepool Python package:

from icepool import d6, map_function
from functools import cache

@map_function
@cache
def burst(fresh_rolls, empowered_rolls, explode, empower):
    if explode > 0 and fresh_rolls and fresh_rolls[-1] == 6:
        return 6 + burst(d6.pool(1) + fresh_rolls[:-1], empowered_rolls, explode - 1, empower)
    elif explode > 0 and empowered_rolls and empowered_rolls[-1] == 6:
        return 6 + burst(d6.pool(1) + fresh_rolls, empowered_rolls[:-1], explode - 1, empower)
    elif empower > 0 and fresh_rolls and fresh_rolls[0] < 4:
        return burst(fresh_rolls[1:], d6.pool(1) + empowered_rolls, explode, empower - 1)
    else:
        return sum(fresh_rolls) + sum(empowered_rolls)
        
output(burst(d6.pool(3), (), 0, 0), 'base')
output(burst(d6.pool(3), (), 3, 0), 'explode')
output(burst(d6.pool(3), (), 0, 3), 'empower')
output(burst(d6.pool(3), (), 3, 3), 'both')

Here @map_function turns an ordinary function into something similar to how AnyDice functions are evaluated: when given an argument of Pool type, the function is evaluated on all possible sorted tuples of outcomes that can come out of the pool. (Note that Icepool sorts in ascending order.) We then choose one die to reroll if possible:

  • If we can explode a 6, we bank the 6 and add a new die to our roll. Here we can make a pool out of a d6 and the existing rolls. In AnyDice you would have to send the d6 and existing rolls to the recursive call as separate arguments and then merge and sort them there.
  • Otherwise, if we can empower a 1, 2, or 3, we remove the lowest die from the roll and roll it again. We track empowered dice separately since they can't be empowered again.
  • If neither are possible, we sum the remaining dice.

You can try the script online here. This is based on Icepool v0.29.0.

Graph.

\$\endgroup\$
5
  • \$\begingroup\$ You'd want Empower before Explode. Each die can only be rerolled via Empower once. If that reroll is a 6, it's a candidate for exploding. If the Explode die is low enough, you'd want to use Empower to reroll it as well, for yet another chance to get a 6 and continue exploding. In general you only want to reroll a 1/2/3, so that determination can be fixed. \$\endgroup\$
    – dsmith
    Commented Apr 30, 2023 at 0:03
  • \$\begingroup\$ Anyway, this is definitely a proper solution for the non-Empowered version, so I'll accept it as such. I'll look more into the Empower portion of the code later. \$\endgroup\$
    – dsmith
    Commented Apr 30, 2023 at 0:16
  • \$\begingroup\$ I've added an example of Exploding first having a higher mean than Empowering first. (Unless there is some rule that prevents this?) \$\endgroup\$ Commented Apr 30, 2023 at 0:22
  • 1
    \$\begingroup\$ Unless the rules prevent it, you may also want to do multiple alternating Explode and Empower stages. Explode all the 6s as far as possible, Empower one die at a time until you get a 6, Explode it as far as possible, repeat. \$\endgroup\$ Commented Apr 30, 2023 at 0:29
  • \$\begingroup\$ The IcePool engine looks like a great alternative. The current script looks like it's flawed (it appears to be able to reroll the same die multiple times with Empower), but I'm going to test out if I can get it working in there. \$\endgroup\$
    – dsmith
    Commented May 1, 2023 at 12:54
5
\$\begingroup\$

Here's a slightly more generic function I wrote before realizing that HighDiceRoller had already given essentially the same answer:

function: explode at most LIMIT:n of DICE:d {
  MAX: [maximum of 1@DICE]
  result: [explode helper DICE LIMIT MAX]
}

function: explode helper ROLL:s LIMIT:n MAX:n {
  N: [lowest of LIMIT and ROLL = MAX]
  if N < 1 { result: ROLL }
  else { result: ROLL + [explode helper NdMAX (LIMIT-N) MAX] }
}

The main difference is that my version doesn't have the maximum die roll (6) hardcoded, but automatically extracts it from the dice pool you pass in. The disadvantage is that it needs a helper function to do that.


Note that the function above assumes that your dice are "simple", i.e. all sides equally likely and numbered consecutively starting from 1. If you try to use custom dice, the function will accept them, but will reroll any explosions using simple M-sided dice, where M is the maximum side value of your custom dice.

Fixing that limitation requires tweaking the function call syntax a bit, because AFAIK there's no way in AnyDice to extract a single custom die from a pool of several such dice. Basically this means that we have to pass the die and the pool size as separate parameters, e.g. like this:

function: explode at most LIMIT:n of N:n x DIE:d {
  MAX: [maximum of DIE]
  result: [explode helper NdDIE DIE LIMIT MAX]
}

function: explode helper ROLL:s DIE:d LIMIT:n MAX:n {
  N: [lowest of LIMIT and ROLL = MAX]
  if N < 1 { result: ROLL }
  else { result: ROLL + [explode helper NdDIE DIE (LIMIT-N) MAX] }
}

You'd then call this function e.g. like this:

output [explode at most 2 of 3 x d6]

to roll exploding 3d6 with at most 2 rerolls across all the dice.

(FWIW, the x in the function name is there simply to prevent AnyDice's parser from getting too easily confused. Without it, you'd have to write the function call as something like [explode at most 2 of 3 (d6)] to prevent AnyDice from parsing 3 d6 as a single dice roll.)

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .