Last active
August 26, 2023 17:01
-
-
Save iemcd/9660929c0c128d71a813bfb6de1a5698 to your computer and use it in GitHub Desktop.
Dice Rules like Rod Reel & Fist
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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