Dice roll stats+balance needed

also, here’s the latest version of the code I built to play around with this. i rebuilt to be able to accommodate changing the base dice. enjoy

def calc_at_least_dice(bonus, adv, disadv, base_dice=2):
  # maps the resulting dice roll to the number of times that value is rolled
  val_map = {}
  total_to_roll = base_dice+abs(adv-disadv)
  bonus_roll = 0 # 1 if adv >= disadv else -1
  adv_func = max if adv >= disadv else min
  exploded_values = [
    [i for i in range(1, 7)]
    for j in range(total_to_roll)
  ]
  roll_values = [1 for i in range(total_to_roll)]
  total_rolls = 0
  expected_roll_values = 6 ** total_to_roll
  
  while total_rolls < expected_roll_values:
    dice = [0 for i in range(base_dice)]
    accum = 0
    seen = set()
    for roll in roll_values:
      if roll in seen:
        accum += bonus_roll
      for i in range(len(dice)):
        if adv_func(roll, dice[i]) == roll:
          dice = dice[0:i] + [roll] + dice[i:-1]
          break
      seen.add(roll)
    roll_total = accum + bonus + sum(dice[0:base_dice])
    val_map[roll_total] = val_map.get(roll_total, 0) + 1
    total_rolls += 1
    next_die_plus_index = 0
    while next_die_plus_index < len(roll_values) and roll_values[next_die_plus_index] == 6:
      roll_values[next_die_plus_index] = 1
      next_die_plus_index += 1
    if next_die_plus_index >= len(roll_values):
      break
    roll_values[next_die_plus_index] += 1
  # print(val_map)
  k2 = '' if adv == disadv else f'k{base_dice}'
  # print(f'{total_to_roll}d6{k3}+{bonus}')
  # print("total\t% at least = roll")
  percentage = 0.0
  probabilities = {}
  for roll in reversed(sorted(val_map.keys())):
    num_rolled = val_map[roll]
    percentage += num_rolled / total_rolls
    probabilities[roll] = percentage
  return {
    roll: round(100.0*probabilities[roll], 2) for roll in sorted(probabilities.keys())
  }
  
probs = {}
# headers = ['roll total']
roll_types = []
base_dice = 3
for adv in range(0, 3):
  for bonus in range(10, 11):
    roll_dist = calc_at_least_dice(bonus, adv, 0, base_dice)
    k2 = '' if adv == 0 else f'k{base_dice}'
    dice_amt = f'{base_dice+adv}d6{k2}+{bonus}'
    roll_types.append(dice_amt)
    # headers.append(dice_amt)

    for roll in sorted(roll_dist.keys()):
      pct = roll_dist[roll]
      probs[roll] = probs.get(roll, {})
      probs[roll][dice_amt] = str(pct)

print(f'roll total\t' + '\t'.join(roll_types))
for roll_total in sorted(probs.keys()):
  # print(f'{roll_total}: {str(probs[roll_total])}')
  # print(str([probs[roll_total].get(dice_amt, str(0.0)) for dice_amt in roll_types]))
  values = [probs[roll_total].get(dice_amt, str(0.0)) for dice_amt in roll_types]
  print(f'{roll_total}\t' + '\t'.join(values))

prints out the likelihood that your roll will be at least the roll total listed:

roll total 3d6+10 4d6k3+10 5d6k3+10
13 100.0 100.0 100.0
14 99.54 99.92 99.99
15 98.15 99.61 99.92
16 95.37 98.84 99.73
17 90.74 97.22 99.2
18 83.8 94.29 98.05
19 74.07 89.51 95.86
20 62.5 82.48 92.05
21 50.0 73.07 86.01
22 37.5 61.65 77.46
23 25.93 48.77 66.13
24 16.2 35.49 52.56
25 9.26 23.15 37.71
26 4.63 13.04 23.42
27 1.85 5.79 11.39
28 0.46 1.62 3.55

as you can see, at 20, your likelihood of rolling at least a 20 increases by 20% with 1 advantage, and 10% again with 2, which imo helps make it much more intuitive to think about “average effectiveness of advantage”