simonjegou commited on
Commit
d7aca66
·
verified ·
1 Parent(s): 306617a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +187 -0
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()