Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Python implementation and Gradio app for "An Optimal Strategy for [Solitaire] Yahtzee" (James Glenn, 2006)
|
| 3 |
+
Source: https://gunpowder.cs.loyola.edu/~jglenn/research/optimal_yahtzee.pdf
|
| 4 |
+
|
| 5 |
+
At each turn, the optimal strategy depends only on:
|
| 6 |
+
- The available scoring categories (2**13 possibilities)
|
| 7 |
+
- The total score in the upper section, capped at 63 (2**6 possibilities)
|
| 8 |
+
|
| 9 |
+
For each of the 2**19 possible states, we compute the expected optimal score for the rest of the game.
|
| 10 |
+
This implementation does not include Yahtzee bonuses or jokers.
|
| 11 |
+
|
| 12 |
+
The Gradio app then allows you to input your current scorecard and dice roll to get the optimal action.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import gradio as gr
|
| 17 |
+
from tqdm import tqdm
|
| 18 |
+
|
| 19 |
+
FOUR_OF_A_KIND_IS_40_POINTS = False # common variant used in France
|
| 20 |
+
|
| 21 |
+
# Compute Rk and R5 (unique dice combinations for up to 5 dice / exactly 5 dice)
|
| 22 |
+
Rk = np.indices((6,) * 6).reshape(6, -1).T
|
| 23 |
+
Rk = Rk[Rk.sum(axis=1) <= 5] # size 462
|
| 24 |
+
R5 = Rk[Rk.sum(axis=1) == 5] # size 252
|
| 25 |
+
|
| 26 |
+
# Compute R (all combinations of up to 5 dice)
|
| 27 |
+
R = np.indices((7,) * 5).reshape(5, -1).T
|
| 28 |
+
R = np.array([np.bincount(r, minlength=7)[1:] for r in R])
|
| 29 |
+
R = [R[R.sum(axis=1) == i] for i in range(6)]
|
| 30 |
+
|
| 31 |
+
# Calculate probabilities (P) of each R5 outcome from each Rk roll
|
| 32 |
+
P = np.zeros((len(Rk), len(R5)))
|
| 33 |
+
R5_indices = {tuple(r): i for i, r in enumerate(R5)}
|
| 34 |
+
for i, rk in enumerate(Rk):
|
| 35 |
+
for r in R[5 - rk.sum()]:
|
| 36 |
+
P[i, R5_indices[tuple(rk + r)]] += 1
|
| 37 |
+
P /= P.sum(axis=1, keepdims=True)
|
| 38 |
+
M = (P > 0).T # Mask for valid transitions
|
| 39 |
+
|
| 40 |
+
# Calculate category scores for all R5 outcomes
|
| 41 |
+
R5_scores = np.zeros((len(R5), 13), dtype=int)
|
| 42 |
+
R5_scores[:, :6] = R5 * np.arange(1, 7)
|
| 43 |
+
upper_score = R5_scores[:, :6].sum(axis=1)
|
| 44 |
+
R5_scores[:, 6] = upper_score
|
| 45 |
+
m = R5.max(axis=1)
|
| 46 |
+
R5_scores[:, 7] = upper_score * (m >= 3)
|
| 47 |
+
R5_scores[:, 8] = (40 if FOUR_OF_A_KIND_IS_40_POINTS else upper_score) * (m >= 4)
|
| 48 |
+
R5_scores[:, 9] = 25 * np.array([set(r) == {0, 2, 3} for r in R5])
|
| 49 |
+
R5_scores[:, 10] = 30 * ((R5[:, 0:4] > 0).all(axis=1)| (R5[:, 1:5] > 0).all(axis=1)| (R5[:, 2:6] > 0).all(axis=1))
|
| 50 |
+
R5_scores[:, 11] = 40 * ((R5[:, 0:5] > 0).all(axis=1) | (R5[:, 1:6] > 0).all(axis=1))
|
| 51 |
+
R5_scores[:, 12] = 50 * (m == 5)
|
| 52 |
+
|
| 53 |
+
# Initialize state scores:
|
| 54 |
+
# - Bits 0-5: upper section score (capped at 63 for bonus)
|
| 55 |
+
# - Bits 6-11: upper categories (Aces, Twos, Threes, Fours, Fives, Sixes)
|
| 56 |
+
# - Bits 12-18: lower categories (Chance, Three-of-a-kind, Four-of-a-kind, Full House, Small Straight, Large Straight, Yahtzee)
|
| 57 |
+
UPPER_TOTAL_BITS = 2**6 - 1
|
| 58 |
+
UPPER_TOTAL_AND_CATEGORIES_BITS = 2**12 - 1
|
| 59 |
+
CATEGORIES_BITS = 2**19 - 2**6
|
| 60 |
+
|
| 61 |
+
states = np.arange(2**19, dtype=int)
|
| 62 |
+
scores = np.zeros(2**19, dtype=float)
|
| 63 |
+
|
| 64 |
+
def get_score(state):
|
| 65 |
+
# Get children states
|
| 66 |
+
available = np.array([1 - (state >> (6 + i)) & 1 for i in range(13)])
|
| 67 |
+
upper_total_states = np.clip((state & UPPER_TOTAL_BITS) + R5_scores * (np.arange(13) < 6), 0, 63)
|
| 68 |
+
categories_states = (state & CATEGORIES_BITS) + 2 ** np.arange(6, 19)
|
| 69 |
+
children = available * (upper_total_states + categories_states)
|
| 70 |
+
|
| 71 |
+
# Backpropagate scores from children to root of the widget (as per the paper description)
|
| 72 |
+
s3 = available * (R5_scores + scores[children]) # Roll 3
|
| 73 |
+
s2 = P @ s3.max(axis=1) # Roll 2
|
| 74 |
+
s1 = P @ np.where(M, s2, 0).max(axis=1) # Roll 1
|
| 75 |
+
s0 = P[0] @ np.where(M, s1, 0).max(axis=1) # Root state
|
| 76 |
+
|
| 77 |
+
return s0, s1, s2, s3
|
| 78 |
+
|
| 79 |
+
# Identify reachable states (e.g., an upper total of 1 is impossible if only "Twos" was played)
|
| 80 |
+
reachable_states = np.zeros(2**12, dtype=bool)
|
| 81 |
+
U = np.indices((7,) * 6).reshape(6, -1).T # 6 means unused category
|
| 82 |
+
reachable_states[np.clip((U % 6) @ np.arange(1, 7), 0, 63) + (U < 6) @ (2 ** np.arange(6, 12))] = True
|
| 83 |
+
|
| 84 |
+
# Compute scores sorted by the number of categories already played
|
| 85 |
+
# The score for the 64 states with all categories played is 0 except if the upper total is 63
|
| 86 |
+
order = np.argsort([bin(state)[:-6].count("1") for state in states])[::-1]
|
| 87 |
+
scores[-1] = 35
|
| 88 |
+
for idx in tqdm(order[63:]):
|
| 89 |
+
if reachable_states[states[idx] & UPPER_TOTAL_AND_CATEGORIES_BITS]:
|
| 90 |
+
scores[idx] = get_score(states[idx])[0]
|
| 91 |
+
print(f"Optimal score at the beginning of the game: {scores[0]:.2f}") # 245.87 as per the paper (section 6)
|
| 92 |
+
|
| 93 |
+
# Gradio app
|
| 94 |
+
EMPTY = ""
|
| 95 |
+
score_options = {
|
| 96 |
+
"Ones": [EMPTY, 0, 1, 2, 3, 4, 5],
|
| 97 |
+
"Twos": [EMPTY, 0, 2, 4, 6, 8, 10],
|
| 98 |
+
"Threes": [EMPTY, 0, 3, 6, 9, 12, 15],
|
| 99 |
+
"Fours": [EMPTY, 0, 4, 8, 12, 16, 20],
|
| 100 |
+
"Fives": [EMPTY, 0, 5, 10, 15, 20, 25],
|
| 101 |
+
"Sixes": [EMPTY, 0, 6, 12, 18, 24, 30],
|
| 102 |
+
"Chance": [EMPTY] + list(range(0, 31)),
|
| 103 |
+
"Three of a kind": [EMPTY] + list(range(0, 31)),
|
| 104 |
+
"Four of a kind": ([EMPTY, 0, 40] if FOUR_OF_A_KIND_IS_40_POINTS else ([EMPTY] + list(range(0, 31)))),
|
| 105 |
+
"Full House": [EMPTY, 0, 25],
|
| 106 |
+
"Small Straight": [EMPTY, 0, 30],
|
| 107 |
+
"Large Straight": [EMPTY, 0, 40],
|
| 108 |
+
"Yahtzee": [EMPTY, 0, 50],
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
categories = list(score_options.keys())
|
| 112 |
+
upper_categories, lower_categories = categories[:6], categories[6:]
|
| 113 |
+
|
| 114 |
+
def update_components(*inputs):
|
| 115 |
+
# Parse inputs
|
| 116 |
+
scorecard = {c: inputs[i] for i, c in enumerate(categories)}
|
| 117 |
+
roll = inputs[len(categories)]
|
| 118 |
+
dice = np.array(inputs[len(categories) + 1 :])
|
| 119 |
+
|
| 120 |
+
# Compute totals
|
| 121 |
+
scorecard_values = [0 if v == EMPTY else v for v in scorecard.values()]
|
| 122 |
+
upper, lower = sum(scorecard_values[:6]), sum(scorecard_values[6:])
|
| 123 |
+
bonus = 35 if upper > 62 else 0
|
| 124 |
+
|
| 125 |
+
# Compute optimal action
|
| 126 |
+
if (EMPTY not in dice) and (EMPTY in scorecard.values()):
|
| 127 |
+
state = np.clip(upper, 0, 63) + sum(2 ** (6 + i) for i, c in enumerate(categories) if scorecard[c] != EMPTY)
|
| 128 |
+
s = get_score(state)[roll]
|
| 129 |
+
r5 = R5_indices[tuple(np.bincount(dice, minlength=7)[1:])]
|
| 130 |
+
|
| 131 |
+
if roll == 3:
|
| 132 |
+
idx = np.argmax(s[r5])
|
| 133 |
+
new_score = R5_scores[r5, idx]
|
| 134 |
+
action = f"Play {categories[idx]} ({new_score} pts)\nExpected score: {upper + lower + new_score + s[r5, idx]:.2f}"
|
| 135 |
+
else:
|
| 136 |
+
rk = Rk[np.argmax(np.where(M, s, 0)[r5])]
|
| 137 |
+
keep = sum([[i + 1] * rk[i] for i in range(6)], [])
|
| 138 |
+
action = f"Keep {keep}"
|
| 139 |
+
else:
|
| 140 |
+
action = ""
|
| 141 |
+
|
| 142 |
+
return upper, bonus, lower, lower + bonus + upper, action
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
with gr.Blocks() as app:
|
| 146 |
+
|
| 147 |
+
gr.Markdown("""# 🎲 Optimal Yahtzee
|
| 148 |
+
This app recommends the optimal strategy for playing solitaire Yahtzee, based on [James Glenn's 2006 research](http://gunpowder.cs.loyola.edu/~jglenn/research/optimal_yahtzee.pdf).
|
| 149 |
+
### How to use:
|
| 150 |
+
- Enter your current scores in the upper and lower sections.
|
| 151 |
+
- Select the roll number (1, 2, or 3) and input your dice values (1–6).
|
| 152 |
+
- The app will display the optimal dice to keep (rolls 1 or 2) or the best scoring category to select (roll 3).
|
| 153 |
+
""" + FOUR_OF_A_KIND_IS_40_POINTS * "\n**Note:** In this variant, Four of a Kind is worth 40 points.")
|
| 154 |
+
|
| 155 |
+
score_inputs = {}
|
| 156 |
+
|
| 157 |
+
with gr.Row():
|
| 158 |
+
with gr.Column():
|
| 159 |
+
gr.Markdown("### Upper Section")
|
| 160 |
+
for c in upper_categories:
|
| 161 |
+
score_inputs[c] = gr.Dropdown(score_options[c], label=c, value=EMPTY)
|
| 162 |
+
upper_score = gr.Number(label="Upper Total", interactive=False)
|
| 163 |
+
bonus_score = gr.Number(label="Bonus (if >62)", interactive=False)
|
| 164 |
+
|
| 165 |
+
with gr.Column():
|
| 166 |
+
gr.Markdown("### Lower Section")
|
| 167 |
+
for c in lower_categories:
|
| 168 |
+
score_inputs[c] = gr.Dropdown(score_options[c], label=c, value=EMPTY)
|
| 169 |
+
lower_score = gr.Number(label="Lower Total", interactive=False)
|
| 170 |
+
total_score = gr.Number(label="Total", interactive=False)
|
| 171 |
+
|
| 172 |
+
with gr.Column():
|
| 173 |
+
gr.Markdown("### Current Roll")
|
| 174 |
+
roll_input = gr.Dropdown([1, 2, 3], label="Roll number", value=1)
|
| 175 |
+
dice_inputs = [
|
| 176 |
+
gr.Dropdown([EMPTY, 1, 2, 3, 4, 5, 6], label=f"Die {i+1}")
|
| 177 |
+
for i in range(5)
|
| 178 |
+
]
|
| 179 |
+
action_box = gr.Textbox(label="Optimal action", lines=2)
|
| 180 |
+
|
| 181 |
+
inputs = list(score_inputs.values()) + [roll_input] + dice_inputs
|
| 182 |
+
outputs = [upper_score, bonus_score, lower_score, total_score, action_box]
|
| 183 |
+
|
| 184 |
+
for inp in inputs:
|
| 185 |
+
inp.change(update_components, inputs=inputs, outputs=outputs)
|
| 186 |
+
|
| 187 |
+
app.launch()
|