Skip to content

Instantly share code, notes, and snippets.

@iemcd
Last active August 26, 2023 17:01
Show Gist options
  • Save iemcd/9660929c0c128d71a813bfb6de1a5698 to your computer and use it in GitHub Desktop.
Save iemcd/9660929c0c128d71a813bfb6de1a5698 to your computer and use it in GitHub Desktop.
Dice Rules like Rod Reel & Fist
#!/usr/bin/python3
# Evaluating potential alternatives to the "Combining Dice" procedure of Rod, Reel, & Fist
# Ian McDougall, July 2023
# We roll a number of d6 from 1-7, determine a value by some rule, and compare it with a target number (TN) from 3-7. Equal or greater is a success
# A rule is good if:
# - The first and second derivatives are monotonic
# - Fewer results are saturated
# - Success is more likely
# Structure:
# For each pool size (1-7),
# generate a list of tuples representing all possible outcomes (ordered)
# for each mechanic, take a list of tuples and return a list of "results"
# for each target number, convert that list of results into a probability
# the final output is a probability for each pool size and TN, given a mechanic
import numpy as np
from collections import Counter
import itertools
import math
import matplotlib as mpl
import matplotlib.pyplot as plt
### Mechanic Functions
# RRF base rules: sum value and number of dice in the set (if multiple). "4, 4" counts as "6"
def combine(*args):
counts = Counter(args)
candidates = []
for x in counts.items():
candidates.append(x[0] + (0 if x[1]==1 else x[1]))
return max(candidates)
# Iterative combination, as in the RRF counter-example. "2, 2, 4" counts as "4, 4", counts as "8"
# starts at the smallest number and works up
def pyramid(*args):
counts = Counter(args)
candidates = []
while counts:
counts = Counter(dict(sorted(counts.items(), reverse=True)))
x = counts.popitem()
if (x[1]==1):
candidates.append(x[0])
else:
counts[sum(x)] += 1
return max(candidates)
# maximum value in pool is the built-in 'max()'
# sum duplicate dice. e.g. "4, 4" counts as "8"
def dupsum(*args):
counts = Counter(args)
candidates = []
for x in counts.items():
candidates.append(x[0] * x[1])
return max(candidates)
# duplicate dice add one to the value of that number. e.g. "4, 4" counts as "5"
def dupadd(*args):
counts = Counter(args)
candidates = []
for x in counts.items():
candidates.append(x[0] + x[1] -1)
return max(candidates)
### Exploratory Functions
# inspired by https://stackoverflow.com/questions/46374185/does-python-have-a-function-which-computes-multinomial-coefficients
def multinomial(*args):
c = dict(Counter(*args))
res, i = 1, 1
for a in c:
for j in range(1, c[a]+1):
res *= i
res //= j
i += 1
return res
# returns the odds of success in [0,1] given a die type, a rule, a pool size, and a target
# for computational reasons, this looks more complicated than it is
def odds(die, rule, pool, target):
successes = 0
for roll in itertools.combinations_with_replacement(die, pool):
if rule(*roll) >= target:
successes += multinomial(roll)
return successes / (len(die) ** pool)
# weighted saturation
def wsat(arr, w):
return np.average(np.matmul(np.logical_or(arr==1, arr==0), w))
# weighted mean
def wavg(arr, w):
return np.average(np.matmul(arr, w))
def delta(A):
return np.subtract(A[:,1:], A[:,:-1])
def weights(length, r):
a = 1 - r # this ensures the sum converges to 1
w = list(map(lambda k : a * r ** k, np.arange(length)))
w[-1] = 1 - np.sum(w[:-1]) # a hack to put all the remaining weight on the smallest term
return w
### Plotting Functions
# from https://matplotlib.org/stable/gallery/images_contours_and_fields/image_annotated_heatmap.html
def heatmap(data, row_labels, col_labels, ax=None, cbar_kw={}, cbarlabel="", **kwargs):
if not ax:
ax = plt.gca()
im = ax.imshow(data, **kwargs)
ax.set_xticks(np.arange(data.shape[1]))
ax.set_yticks(np.arange(data.shape[0]))
ax.set_xticklabels(col_labels)
ax.set_yticklabels(row_labels)
ax.tick_params(length=0)
for edge, spine in ax.spines.items():
spine.set_visible(False)
return im
# ibid.
def annotate_heatmap(im, data=None, valfmt="{x:.2f}", textcolors=("black", "white"), threshold=None, **textkw):
if not isinstance(data, (list, np.ndarray)):
data = im.get_array()
if threshold is not None:
threshold = im.norm(threshold)
else:
threshold = im.norm(data.max())/2
kw = dict(horizontalalignment="center", verticalalignment="center")
kw.update(textkw)
if isinstance(valfmt, str):
valfmt = mpl.ticker.StrMethodFormatter(valfmt)
texts = []
for i in range(data.shape[0]):
for j in range(data.shape[1]):
kw.update(color=textcolors[int(im.norm(data[i, j]) >= threshold)])
text = im.axes.text(j, i, valfmt(data[i, j], None), **kw)
texts.append(text)
return texts
### Constants
d6 = np.arange(1, 7, dtype=int)
pools = np.arange(1,18, dtype=int)
targets = np.arange(3, 10, dtype=int)
ratio = 1/2
window = 9
#hwin = 7
#vwin = 5
nfig = 0
### Combining Dice
out_combine = np.array(([[odds(d6, combine, i, j) for i in pools] for j in targets]))
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_combine, targets, pools, ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle("Combining Dice Odds of Success")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
# A truncated view
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_combine[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice"),
ax.set_ylabel("Target Number")
fig.suptitle("Combining Dice Odds of Success (Truncated)")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Playing with Derivatives
out_combine_p = delta(out_combine)
out_combine_pp = delta(out_combine_p)
nfig += 1
fig, ax = plt.subplots(nrows=3)
im0 = ax[0].imshow(out_combine, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
im1 = ax[1].imshow(out_combine_p, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
im2 = ax[2].imshow(out_combine_pp, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
for axis in ax:
axis.tick_params(
axis='both',
which='both',
labelbottom=False,
labelleft=False,
length=0)
for edge, spine in axis.spines.items():
spine.set_visible(False)
fig.suptitle("Odds of Success with First and Second Derivative")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
out_badrule = np.hstack((out_combine[:,:2], np.ones_like(targets).reshape(-1,1), out_combine[:,3:]))
out_badrule_p = delta(out_badrule)
out_badrule_pp = delta(out_badrule_p)
nfig += 1
fig, ax = plt.subplots(nrows=3)
im0 = ax[0].imshow(out_badrule, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
im1 = ax[1].imshow(out_badrule_p, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
im2 = ax[2].imshow(out_badrule_pp, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
for axis in ax:
axis.tick_params(
axis='both',
which='both',
labelbottom=False,
labelleft=False,
length=0)
for edge, spine in axis.spines.items():
spine.set_visible(False)
fig.suptitle('"Threes Succeed" with First and Second Derivative')
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Saturation
out_saturate = np.logical_or(out_combine==1, out_combine==0)
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_saturate[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice"),
ax.set_ylabel("Target Number")
fig.suptitle("Combining Dice Saturation")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Weights
w = weights(len(pools), ratio)
x = 1 + np.arange(len(pools))
nfig += 1
fig, ax = plt.subplots()
ax.bar(x[:window], w[:window], width=1, edgecolor="white", linewidth=0.7)
ax.set(xlim=(0.5, window + 0.5),
ylim=(0, 1))
ax.tick_params(length=0)
ax.set_xlabel("Dice")
ax.set_ylabel("Weight")
for edge, spine in ax.spines.items():
spine.set_visible(False)
fig.suptitle("Geometric Weights, r={:.2} (Truncated)".format(ratio))
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Pyramid Dice
out_pyramid = np.array(([[odds(d6, pyramid, i, j) for i in pools] for j in targets]))
diff_pyramid = out_pyramid - out_combine
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_pyramid[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle("Pyramid Dice Odds of Success")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
nfig += 1
fig, ax = plt.subplots()
im = heatmap(diff_pyramid[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle("Pyramid Dice Change in Odds")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Maximum Dice
out_max = np.array(([[odds(d6, lambda *args: max([*args]), i, j) for i in pools] for j in targets]))
diff_max = out_max - out_combine
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_max[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle("Maximum Dice Odds of Success")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
nfig += 1
fig, ax = plt.subplots()
im = heatmap(diff_max[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=-0.5, textcolors=("white", "black"))
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle("Maximum Dice Change in Odds")
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Duplicates Sum
out_sum = np.array(([[odds(d6, dupsum, i, j) for i in pools] for j in targets]))
diff_sum = out_sum - out_combine
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_sum[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle('"Duplicates Sum" Odds of Success')
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
nfig += 1
fig, ax = plt.subplots()
im = heatmap(diff_sum[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle('"Duplicates Sum" Change in Odds')
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Duplicates Add One
out_add = np.array(([[odds(d6, dupadd, i, j) for i in pools] for j in targets]))
diff_add = out_add - out_combine
nfig += 1
fig, ax = plt.subplots()
im = heatmap(out_add[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle('"Duplicates Add One" Odds of Success')
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
nfig += 1
fig, ax = plt.subplots()
im = heatmap(diff_add[:,:window], targets, pools[:window], ax=ax, cmap="RdBu", norm=mpl.colors.Normalize(-1,1))
texts = annotate_heatmap(im, threshold=0.5)
ax.set_xlabel("Dice")
ax.set_ylabel("Target Number")
fig.suptitle('"Duplicates Add One" Change in Odds')
fig.tight_layout()
fig.savefig('figure{:02d}.png'.format(nfig))
### Metrics
# minimum first derivative (higher is better)
# maximum 2nd derivative (lower is better)
# weighted saturation (lower is better)
# weighted odds of success (higher is better)
rules = {
"Combining Dice": out_combine,
"Pyramid Dice": out_pyramid,
"Maximum Dice": out_max,
"Duplicates Sum": out_sum,
"Duplicates Add": out_add,
"Threes Win": out_badrule
}
print("sat\tavg\t1stmin\t2ndmax\tmechanic")
for rule in rules:
print("{:.2}\t{:.2}\t{:.2}\t{:.2}\t{}".format(wsat(rules[rule],w), wavg(rules[rule],w), np.min(delta(rules[rule])), np.max(delta(delta(rules[rule]))), rule))
# TN in [3,7], pools in [1,17]
#sat avg 1stmin 2ndmax mechanic
#0.16 0.49 0.0 0.042 Combining Dice
#0.18 0.5 0.0 0.074 Pyramid Dice
#0.2 0.45 0.0 0.0 Maximum Dice
#0.13 0.5 0.0 0.06 Duplicates Sum
#0.13 0.48 0.0 0.023 Duplicates Add
#0.26 0.53 -0.72 0.89 Threes Win
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment