backshot-solver 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.3
2
+ Name: backshot-solver
3
+ Version: 0.1.0
4
+ Summary: A buckshot roulette magazine predictor
5
+ Author: takingurstuff
6
+ Author-email: takingurstuff <130810510+takingurstuff@users.noreply.github.com>
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Buckshot Roulette Solver Overlay
11
+
12
+ A minimal, low-latency, mathematical state-space tracker and HUD overlay for _Buckshot Roulette_ (and its Roblox port _Buckshot_). Tracks exact shell inventory, computes real-time Bayesian chamber probabilities, and incorporates oracle item observations (Magnifying Glass and Burner Phone) using vim-style keybinds. Partially inspired by [BackshotRoulette](https://github.com/redstone59/BackshotRoulette) (redstone59)
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ ### Via `pipx` or `uv`
19
+
20
+ ```bash
21
+ # uv:
22
+ uvx backshot-solver
23
+
24
+ # or pipx:
25
+ pipx install backshot-solver
26
+ ```
27
+
28
+ ### Editable
29
+
30
+ ```bash
31
+ git clone https://github.com/takingurstuff/backshot-solver
32
+ pip install -e .
33
+ ```
34
+
35
+ ### Development Install (`uv` recommended)
36
+
37
+ ```bash
38
+ git clone https://github.com/takingurstuff/backshot-solver
39
+ uv sync
40
+ ```
41
+
42
+ ---
43
+
44
+ ## How to Use
45
+
46
+ 1. Launch **Buckshot Roulette** in **Borderless Windowed** or **Windowed** mode.
47
+ 2. Launch the solver:
48
+
49
+ ```bash
50
+ backshot-solver
51
+ ```
52
+
53
+ _Do note that the overlay pins itself to top for easy accessing_.
54
+
55
+ 3. Enter the initial **Live** and **Blank** shell counts announced by the dealer at the start of the loadout.
56
+ 4. Click **Initialize**.
57
+
58
+ ---
59
+
60
+ ## Keybinds & Controls
61
+
62
+ All inputs use modal, vim-style chords. You do not need to click or hold modifier keys:
63
+
64
+ | Command | Action | Example |
65
+ | ---------------- | ------------------------------------- | ---------------------------------- |
66
+ | `L` | Register Live shell fired/ejected | Dealer shoots Live |
67
+ | `B` | Register Blank shell fired/ejected | Dealer shoots Blank or drinks Beer |
68
+ | `ML` / `MB` | **Magnifying Glass** (Top shell type) | `ML` = Top shell is Live |
69
+ | `P<Slot><Type>` | **Burner Phone** (Forward indexing) | `P3L` = 3rd shell from top is Live |
70
+ | `P-<Slot><Type>` | **Burner Phone** (Backward indexing) | `P-1B` = Last shell is Blank |
71
+
72
+ _Note: If an invalid key or partial chord is abandoned, the input buffer clears automatically after 1.2 seconds._
73
+
74
+ ---
75
+
76
+ ## Compositor Setup (Hyprland)
77
+
78
+ For Hyprland users, add these rules to `~/.config/hypr/hyprland.conf` to enable opacity and floating:
79
+
80
+ ```ini
81
+ windowrulev2 = float, title:^(Buckshot Roulette Solver Overlay)$
82
+ windowrulev2 = opacity 0.7 0.7, title:^(Buckshot Roulette Solver Overlay)$
83
+ windowrulev2 = noblur, title:^(Buckshot Roulette Solver Overlay)$
84
+
85
+ ```
86
+
87
+ OR post luafication hyprland:
88
+
89
+ ```lua
90
+ hl.window_rule({
91
+ match = {
92
+ title = "^(Buckshot Roulette Solver Overlay)$",
93
+ },
94
+ float = true,
95
+ pin = true,
96
+ opacity = "0.7 0.7",
97
+ no_blur = true,
98
+ })
99
+ ```
100
+
101
+ ---
102
+
103
+ ## Disclaimers
104
+
105
+ > **Use at Your Own Risk:** This is an external mathematical calculator. While it does not hook into, inject code, or read game process memory, using third-party trackers in online multiplayer mode may be considered unsportsmanlike or breach community guidelines.
106
+
107
+ > **RNG & Variance:** This tool provides strictly optimal mathematical probabilities based on known information. An $80\%$ chance of a blank is not a $100\%$ guarantee—variance and bad rolls can and will still happen.
@@ -0,0 +1,98 @@
1
+ # Buckshot Roulette Solver Overlay
2
+
3
+ A minimal, low-latency, mathematical state-space tracker and HUD overlay for _Buckshot Roulette_ (and its Roblox port _Buckshot_). Tracks exact shell inventory, computes real-time Bayesian chamber probabilities, and incorporates oracle item observations (Magnifying Glass and Burner Phone) using vim-style keybinds. Partially inspired by [BackshotRoulette](https://github.com/redstone59/BackshotRoulette) (redstone59)
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ### Via `pipx` or `uv`
10
+
11
+ ```bash
12
+ # uv:
13
+ uvx backshot-solver
14
+
15
+ # or pipx:
16
+ pipx install backshot-solver
17
+ ```
18
+
19
+ ### Editable
20
+
21
+ ```bash
22
+ git clone https://github.com/takingurstuff/backshot-solver
23
+ pip install -e .
24
+ ```
25
+
26
+ ### Development Install (`uv` recommended)
27
+
28
+ ```bash
29
+ git clone https://github.com/takingurstuff/backshot-solver
30
+ uv sync
31
+ ```
32
+
33
+ ---
34
+
35
+ ## How to Use
36
+
37
+ 1. Launch **Buckshot Roulette** in **Borderless Windowed** or **Windowed** mode.
38
+ 2. Launch the solver:
39
+
40
+ ```bash
41
+ backshot-solver
42
+ ```
43
+
44
+ _Do note that the overlay pins itself to top for easy accessing_.
45
+
46
+ 3. Enter the initial **Live** and **Blank** shell counts announced by the dealer at the start of the loadout.
47
+ 4. Click **Initialize**.
48
+
49
+ ---
50
+
51
+ ## Keybinds & Controls
52
+
53
+ All inputs use modal, vim-style chords. You do not need to click or hold modifier keys:
54
+
55
+ | Command | Action | Example |
56
+ | ---------------- | ------------------------------------- | ---------------------------------- |
57
+ | `L` | Register Live shell fired/ejected | Dealer shoots Live |
58
+ | `B` | Register Blank shell fired/ejected | Dealer shoots Blank or drinks Beer |
59
+ | `ML` / `MB` | **Magnifying Glass** (Top shell type) | `ML` = Top shell is Live |
60
+ | `P<Slot><Type>` | **Burner Phone** (Forward indexing) | `P3L` = 3rd shell from top is Live |
61
+ | `P-<Slot><Type>` | **Burner Phone** (Backward indexing) | `P-1B` = Last shell is Blank |
62
+
63
+ _Note: If an invalid key or partial chord is abandoned, the input buffer clears automatically after 1.2 seconds._
64
+
65
+ ---
66
+
67
+ ## Compositor Setup (Hyprland)
68
+
69
+ For Hyprland users, add these rules to `~/.config/hypr/hyprland.conf` to enable opacity and floating:
70
+
71
+ ```ini
72
+ windowrulev2 = float, title:^(Buckshot Roulette Solver Overlay)$
73
+ windowrulev2 = opacity 0.7 0.7, title:^(Buckshot Roulette Solver Overlay)$
74
+ windowrulev2 = noblur, title:^(Buckshot Roulette Solver Overlay)$
75
+
76
+ ```
77
+
78
+ OR post luafication hyprland:
79
+
80
+ ```lua
81
+ hl.window_rule({
82
+ match = {
83
+ title = "^(Buckshot Roulette Solver Overlay)$",
84
+ },
85
+ float = true,
86
+ pin = true,
87
+ opacity = "0.7 0.7",
88
+ no_blur = true,
89
+ })
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Disclaimers
95
+
96
+ > **Use at Your Own Risk:** This is an external mathematical calculator. While it does not hook into, inject code, or read game process memory, using third-party trackers in online multiplayer mode may be considered unsportsmanlike or breach community guidelines.
97
+
98
+ > **RNG & Variance:** This tool provides strictly optimal mathematical probabilities based on known information. An $80\%$ chance of a blank is not a $100\%$ guarantee—variance and bad rolls can and will still happen.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "backshot-solver"
3
+ version = "0.1.0"
4
+ description = "A buckshot roulette magazine predictor"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = []
8
+
9
+ [[project.authors]]
10
+ name = "takingurstuff"
11
+ email = "130810510+takingurstuff@users.noreply.github.com"
12
+
13
+ [project.gui-scripts]
14
+ backshot-solver = "backshot_solver.app:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.1,<0.13.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "backshot-solver"
3
+ version = "0.1.0"
4
+ description = "A buckshot roulette magazine predictor"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "takingurstuff", email = "130810510+takingurstuff@users.noreply.github.com" },
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = []
11
+
12
+ [project.gui-scripts]
13
+ backshot-solver = "backshot_solver.app:main"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.12.1,<0.13.0"]
17
+ build-backend = "uv_build"
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from backshot-solver!")
@@ -0,0 +1,664 @@
1
+ import re
2
+ import sys
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+ from typing import List, Literal, Union, Dict
6
+ from .predictor import BuckshotMagazinePredictor
7
+
8
+ ShellType = Literal["L", "B", "U"]
9
+
10
+
11
+ class App(tk.Tk):
12
+
13
+ def __init__(self):
14
+ super().__init__()
15
+
16
+ # --- Window & Title Bar Configuration ---
17
+ self.title("Buckshot Roulette Solver Overlay")
18
+ self.geometry("520x720")
19
+ self.minsize(460, 680)
20
+
21
+ # --- Chroma Key Transparency Settings ---
22
+ # Any widget or background painted with CHROMA_KEY becomes completely transparent/pass-through
23
+ self.CHROMA_KEY = "#010101"
24
+ self.configure(bg=self.CHROMA_KEY)
25
+
26
+ # Apply OS-level chroma transparency (Windows / X11)
27
+ if sys.platform.startswith("win"):
28
+ self.wm_attributes("-transparentcolor", self.CHROMA_KEY)
29
+ self.attributes("-topmost", True)
30
+
31
+ # --- Solid Dark UI Theme Palette ---
32
+ self.COLOR_PANEL_BG = "#161616" # Opaque panel backing
33
+ self.COLOR_PANEL_BORDER = "#2B2B2B" # Subdued outer borders
34
+ self.COLOR_CANVAS_BG = "#0D0D0D" # High-contrast widget display
35
+ self.COLOR_TEXT_PRIMARY = "#EEEEEE" # Crisp bright labels
36
+ self.COLOR_TEXT_MUTED = "#888888" # Subdued instructional text
37
+
38
+ self.PALETTE = {
39
+ "L": {"bg": "#9E1C1C", "border": "#FF4D4D", "text": "LIVE"},
40
+ "B": {"bg": "#14487A", "border": "#4DA3FF", "text": "BLANK"},
41
+ "U": {"bg": "#2A2A2A", "border": "#555555", "text": "?"},
42
+ }
43
+
44
+ # State & Buffer Tracking
45
+ self.magazine = None
46
+ self.key_buffer = ""
47
+ self.reset_timer_id = None
48
+
49
+ self._configure_ttk_styles()
50
+ self._create_widgets()
51
+
52
+ # Listen for keypress events across the window
53
+ self.bind("<Key>", self._handle_keypress)
54
+
55
+ def _configure_ttk_styles(self):
56
+ """Configure dark, solid TTK styles to ensure no background bleed."""
57
+ style = ttk.Style(self)
58
+ style.theme_use("clam")
59
+
60
+ # Base Frame & LabelFrames
61
+ style.configure("Dark.TFrame", background=self.COLOR_PANEL_BG)
62
+ style.configure(
63
+ "Dark.TLabelframe",
64
+ background=self.COLOR_PANEL_BG,
65
+ bordercolor=self.COLOR_PANEL_BORDER,
66
+ darkcolor=self.COLOR_PANEL_BORDER,
67
+ lightcolor=self.COLOR_PANEL_BORDER,
68
+ )
69
+ style.configure(
70
+ "Dark.TLabelframe.Label",
71
+ background=self.COLOR_PANEL_BG,
72
+ foreground="#CCCCCC",
73
+ font=("Consolas", 9, "bold"),
74
+ )
75
+
76
+ # Labels
77
+ style.configure(
78
+ "Dark.TLabel",
79
+ background=self.COLOR_PANEL_BG,
80
+ foreground=self.COLOR_TEXT_PRIMARY,
81
+ )
82
+ style.configure(
83
+ "Muted.TLabel",
84
+ background=self.COLOR_PANEL_BG,
85
+ foreground=self.COLOR_TEXT_MUTED,
86
+ font=("Arial", 8),
87
+ )
88
+
89
+ # Buttons
90
+ style.configure(
91
+ "Dark.TButton",
92
+ background="#262626",
93
+ foreground="#FFFFFF",
94
+ bordercolor="#444444",
95
+ focuscolor="none",
96
+ font=("Consolas", 9, "bold"),
97
+ )
98
+ style.map(
99
+ "Dark.TButton",
100
+ background=[("active", "#383838"), ("pressed", "#181818")],
101
+ bordercolor=[("active", "#666666")],
102
+ )
103
+
104
+ # Spinbox
105
+ style.configure(
106
+ "Dark.TSpinbox",
107
+ fieldbackground="#0D0D0D",
108
+ background="#262626",
109
+ foreground="#FFFFFF",
110
+ arrowcolor="#CCCCCC",
111
+ bordercolor="#333333",
112
+ insertcolor="#FFFFFF",
113
+ )
114
+
115
+ def _create_widgets(self):
116
+ # Main solid HUD container
117
+ main_frame = ttk.Frame(self, padding="14", style="Dark.TFrame")
118
+ main_frame.pack(fill="both", expand=True, padx=4, pady=4)
119
+ main_frame.columnconfigure(0, weight=1)
120
+
121
+ # 1. Instructions Block
122
+ instructions_text = (
123
+ "• [ L ] / [ B ] = Register Live / Blank shot or ejection\n"
124
+ "• [ ML ] / [ MB ] = Magnifying Glass (e.g., ML = top is live)\n"
125
+ "• [ P + Slot + Type ] = Burner Phone (e.g., P3L = 3rd shell is live)"
126
+ )
127
+ self.label_instructions = ttk.Label(
128
+ main_frame,
129
+ text=instructions_text,
130
+ justify="left",
131
+ anchor="nw",
132
+ style="Muted.TLabel",
133
+ )
134
+ self.label_instructions.grid(row=0, column=0, sticky="ew", pady=(0, 6))
135
+
136
+ # 2. Input Fields Container
137
+ inputs_frame = ttk.Frame(main_frame, style="Dark.TFrame")
138
+ inputs_frame.grid(row=1, column=0, sticky="ew", pady=(0, 6))
139
+ inputs_frame.columnconfigure(0, weight=1)
140
+ inputs_frame.columnconfigure(1, weight=1)
141
+
142
+ vcmd = (self.register(self._validate_spinbox), "%P")
143
+
144
+ ttk.Label(inputs_frame, text="Live Shells:", style="Dark.TLabel").grid(
145
+ row=0, column=0, sticky="w", padx=(0, 6), pady=(0, 2)
146
+ )
147
+ self.live_shells = ttk.Spinbox(
148
+ inputs_frame,
149
+ from_=0,
150
+ to=9,
151
+ increment=1,
152
+ validate="key",
153
+ validatecommand=vcmd,
154
+ style="Dark.TSpinbox",
155
+ )
156
+ self.live_shells.set("0")
157
+ self.live_shells.grid(row=1, column=0, sticky="ew", padx=(0, 6))
158
+
159
+ ttk.Label(inputs_frame, text="Blank Shells:", style="Dark.TLabel").grid(
160
+ row=0, column=1, sticky="w", padx=(6, 0), pady=(0, 2)
161
+ )
162
+ self.blank_shells = ttk.Spinbox(
163
+ inputs_frame,
164
+ from_=0,
165
+ to=9,
166
+ increment=1,
167
+ validate="key",
168
+ validatecommand=vcmd,
169
+ style="Dark.TSpinbox",
170
+ )
171
+ self.blank_shells.set("0")
172
+ self.blank_shells.grid(row=1, column=1, sticky="ew", padx=(6, 0))
173
+
174
+ # 3. Action Buttons
175
+ button_frame = ttk.Frame(main_frame, style="Dark.TFrame")
176
+ button_frame.grid(row=2, column=0, sticky="ew", pady=(2, 6))
177
+ button_frame.columnconfigure(0, weight=1)
178
+ button_frame.columnconfigure(1, weight=1)
179
+
180
+ self.btn_initialize = ttk.Button(
181
+ button_frame,
182
+ text="Initialize",
183
+ command=self.on_initialize,
184
+ style="Dark.TButton",
185
+ )
186
+ self.btn_initialize.grid(row=0, column=0, sticky="ew", padx=(0, 6), ipady=2)
187
+
188
+ self.btn_reset = ttk.Button(
189
+ button_frame,
190
+ text="Reset",
191
+ command=self.on_reset,
192
+ style="Dark.TButton",
193
+ )
194
+ self.btn_reset.grid(row=0, column=1, sticky="ew", padx=(6, 0), ipady=2)
195
+
196
+ # 4. Status Bar
197
+ status_frame = ttk.LabelFrame(
198
+ main_frame, text="Status", padding="6", style="Dark.TLabelframe"
199
+ )
200
+ status_frame.grid(row=3, column=0, sticky="ew", pady=(2, 6))
201
+ status_frame.columnconfigure(0, weight=1)
202
+
203
+ self.label_status = ttk.Label(
204
+ status_frame,
205
+ text="Ready. Enter counts and click 'Initialize'.",
206
+ font=("Consolas", 9),
207
+ justify="left",
208
+ anchor="w",
209
+ style="Dark.TLabel",
210
+ )
211
+ self.label_status.grid(row=0, column=0, sticky="ew")
212
+
213
+ # 5. Shell Pool (Tallies & Counts)
214
+ tally_frame = ttk.LabelFrame(
215
+ main_frame,
216
+ text="Remaining Shell Pool",
217
+ padding="6",
218
+ style="Dark.TLabelframe",
219
+ )
220
+ tally_frame.grid(row=4, column=0, sticky="ew", pady=(2, 6))
221
+ tally_frame.columnconfigure(0, weight=1)
222
+
223
+ self.tally_canvas = tk.Canvas(
224
+ tally_frame,
225
+ height=48,
226
+ bg=self.COLOR_CANVAS_BG,
227
+ highlightthickness=1,
228
+ highlightbackground="#222222",
229
+ )
230
+ self.tally_canvas.grid(row=0, column=0, sticky="ew")
231
+ self.tally_canvas.bind("<Configure>", lambda e: self.update_tally_display())
232
+
233
+ # 6. Top Shell Confidence Box
234
+ confidence_frame = ttk.LabelFrame(
235
+ main_frame,
236
+ text="Next Shell Confidence",
237
+ padding="6",
238
+ style="Dark.TLabelframe",
239
+ )
240
+ confidence_frame.grid(row=5, column=0, sticky="ew", pady=(2, 6))
241
+ confidence_frame.columnconfigure(0, weight=1)
242
+
243
+ self.confidence_canvas = tk.Canvas(
244
+ confidence_frame,
245
+ height=22,
246
+ bg=self.COLOR_CANVAS_BG,
247
+ highlightthickness=1,
248
+ highlightbackground="#222222",
249
+ )
250
+ self.confidence_canvas.grid(row=0, column=0, sticky="ew", pady=(0, 4))
251
+ self.confidence_canvas.bind(
252
+ "<Configure>", lambda e: self.update_confidence_bar()
253
+ )
254
+
255
+ self.label_confidence = ttk.Label(
256
+ confidence_frame,
257
+ text="No data",
258
+ font=("Consolas", 9, "bold"),
259
+ anchor="center",
260
+ style="Dark.TLabel",
261
+ )
262
+ self.label_confidence.grid(row=1, column=0, sticky="ew")
263
+
264
+ # 7. Magazine Visualizer Box (Chamber Sequence)
265
+ magazine_frame = ttk.LabelFrame(
266
+ main_frame,
267
+ text="Chamber Sequence Prediction",
268
+ padding="6",
269
+ style="Dark.TLabelframe",
270
+ )
271
+ magazine_frame.grid(row=6, column=0, sticky="nsew", pady=(2, 0))
272
+ magazine_frame.columnconfigure(0, weight=1)
273
+ main_frame.rowconfigure(6, weight=1)
274
+
275
+ self.mag_canvas = tk.Canvas(
276
+ magazine_frame,
277
+ height=86,
278
+ bg=self.COLOR_CANVAS_BG,
279
+ highlightthickness=1,
280
+ highlightbackground="#222222",
281
+ )
282
+ self.mag_canvas.grid(row=0, column=0, sticky="nsew")
283
+ self.mag_canvas.bind("<Configure>", lambda e: self.update_magazine_display())
284
+
285
+ # --- Drawing Helpers ---
286
+ def _draw_tally_group(
287
+ self,
288
+ canvas: tk.Canvas,
289
+ start_x: int,
290
+ start_y: int,
291
+ count: int,
292
+ color: str,
293
+ ) -> int:
294
+ stroke_h = 22
295
+ stroke_spacing = 6
296
+ group_gap = 14
297
+ line_w = 3
298
+
299
+ cur_x = start_x
300
+ full_groups = count // 5
301
+ remainder = count % 5
302
+
303
+ for _ in range(full_groups):
304
+ for i in range(4):
305
+ canvas.create_line(
306
+ cur_x + (i * stroke_spacing),
307
+ start_y,
308
+ cur_x + (i * stroke_spacing),
309
+ start_y + stroke_h,
310
+ fill=color,
311
+ width=line_w,
312
+ capstyle="round",
313
+ )
314
+ canvas.create_line(
315
+ cur_x - 3,
316
+ start_y + stroke_h - 2,
317
+ cur_x + (3 * stroke_spacing) + 3,
318
+ start_y + 2,
319
+ fill=color,
320
+ width=line_w,
321
+ capstyle="round",
322
+ )
323
+ cur_x += (3 * stroke_spacing) + group_gap
324
+
325
+ for i in range(remainder):
326
+ canvas.create_line(
327
+ cur_x + (i * stroke_spacing),
328
+ start_y,
329
+ cur_x + (i * stroke_spacing),
330
+ start_y + stroke_h,
331
+ fill=color,
332
+ width=line_w,
333
+ capstyle="round",
334
+ )
335
+ if remainder > 0:
336
+ cur_x += (remainder - 1) * stroke_spacing
337
+
338
+ return cur_x
339
+
340
+ # --- UI Synchronization ---
341
+ def refresh_all_displays(self):
342
+ self.update_tally_display()
343
+ self.update_confidence_bar()
344
+ self.update_magazine_display()
345
+
346
+ def update_tally_display(self):
347
+ self.tally_canvas.delete("all")
348
+ w = self.tally_canvas.winfo_width()
349
+ h = self.tally_canvas.winfo_height()
350
+
351
+ if w <= 1:
352
+ return
353
+
354
+ if not self.magazine or self.magazine.depth == 0:
355
+ self.tally_canvas.create_text(
356
+ w // 2,
357
+ h // 2,
358
+ text="CHAMBER EMPTY",
359
+ fill="#555555",
360
+ font=("Consolas", 9, "bold"),
361
+ )
362
+ return
363
+
364
+ lives = self.magazine.n_lives
365
+ blanks = self.magazine.n_blanks
366
+ half_w = w // 2
367
+ y_pos = (h - 22) // 2
368
+
369
+ # Left Half: LIVE
370
+ self.tally_canvas.create_text(
371
+ 16,
372
+ h // 2,
373
+ text=f"LIVE [{lives}]",
374
+ fill=self.PALETTE["L"]["border"],
375
+ font=("Consolas", 10, "bold"),
376
+ anchor="w",
377
+ )
378
+ if lives > 0:
379
+ self._draw_tally_group(
380
+ self.tally_canvas,
381
+ start_x=95,
382
+ start_y=y_pos,
383
+ count=lives,
384
+ color=self.PALETTE["L"]["border"],
385
+ )
386
+
387
+ # Center Line
388
+ self.tally_canvas.create_line(half_w, 6, half_w, h - 6, fill="#222222", width=1)
389
+
390
+ # Right Half: BLANK
391
+ self.tally_canvas.create_text(
392
+ half_w + 16,
393
+ h // 2,
394
+ text=f"BLANK [{blanks}]",
395
+ fill=self.PALETTE["B"]["border"],
396
+ font=("Consolas", 10, "bold"),
397
+ anchor="w",
398
+ )
399
+ if blanks > 0:
400
+ self._draw_tally_group(
401
+ self.tally_canvas,
402
+ start_x=half_w + 105,
403
+ start_y=y_pos,
404
+ count=blanks,
405
+ color=self.PALETTE["B"]["border"],
406
+ )
407
+
408
+ def update_confidence_bar(self):
409
+ self.confidence_canvas.delete("all")
410
+ width = self.confidence_canvas.winfo_width()
411
+ height = self.confidence_canvas.winfo_height()
412
+
413
+ if width <= 1:
414
+ return
415
+
416
+ if not self.magazine or self.magazine.depth == 0:
417
+ self.label_confidence.configure(
418
+ text="No Shells Remaining", foreground=self.COLOR_TEXT_MUTED
419
+ )
420
+ return
421
+
422
+ top_pred = self.magazine.predict_slot(0)
423
+
424
+ if top_pred == "L":
425
+ p_val = 1.0
426
+ shell_type = "L"
427
+ text_desc = "100.0% LIVE"
428
+ elif top_pred == "B":
429
+ p_val = 1.0
430
+ shell_type = "B"
431
+ text_desc = "100.0% BLANK"
432
+ elif isinstance(top_pred, dict):
433
+ p_live = top_pred.get("L", 0.0)
434
+ p_blank = top_pred.get("B", 0.0)
435
+ if p_live >= p_blank:
436
+ p_val = p_live
437
+ shell_type = "L"
438
+ else:
439
+ p_val = p_blank
440
+ shell_type = "B"
441
+ text_desc = f"Live: {p_live*100:.1f}% | Blank: {p_blank*100:.1f}%"
442
+ else:
443
+ self.label_confidence.configure(
444
+ text="Unknown", foreground=self.COLOR_TEXT_MUTED
445
+ )
446
+ return
447
+
448
+ self.label_confidence.configure(
449
+ text=text_desc, foreground=self.COLOR_TEXT_PRIMARY
450
+ )
451
+
452
+ fill_width = int(width * p_val)
453
+ bar_color = self.PALETTE[shell_type]["bg"]
454
+
455
+ self.confidence_canvas.create_rectangle(
456
+ 0, 0, fill_width, height, fill=bar_color, width=0
457
+ )
458
+
459
+ def update_magazine_display(self):
460
+ self.mag_canvas.delete("all")
461
+ canvas_width = self.mag_canvas.winfo_width()
462
+ canvas_height = self.mag_canvas.winfo_height()
463
+
464
+ if canvas_width <= 1 or not self.magazine or self.magazine.depth == 0:
465
+ return
466
+
467
+ predictions: List[ShellType] = self.magazine.predict_all()
468
+ n = len(predictions)
469
+ if n == 0:
470
+ return
471
+
472
+ card_w, card_h = 36, 48
473
+ spacing = 8
474
+ total_w = (n * card_w) + ((n - 1) * spacing)
475
+ start_x = max(10, (canvas_width - total_w) // 2)
476
+ start_y = max(10, (canvas_height - card_h) // 2)
477
+
478
+ for i, shell_type in enumerate(predictions):
479
+ x = start_x + i * (card_w + spacing)
480
+ y = start_y
481
+ cfg = self.PALETTE[shell_type]
482
+ is_top = i == 0
483
+
484
+ # Card Rectangle
485
+ self.mag_canvas.create_rectangle(
486
+ x,
487
+ y,
488
+ x + card_w,
489
+ y + card_h,
490
+ fill=cfg["bg"],
491
+ outline="#FFD700" if is_top else cfg["border"],
492
+ width=2 if is_top else 1,
493
+ )
494
+
495
+ # Card Text Label
496
+ self.mag_canvas.create_text(
497
+ x + card_w // 2,
498
+ y + card_h // 2,
499
+ text=cfg["text"],
500
+ fill="#FFFFFF",
501
+ font=("Consolas", 8, "bold"),
502
+ )
503
+
504
+ # Slot Number
505
+ self.mag_canvas.create_text(
506
+ x + card_w // 2,
507
+ y - 7,
508
+ text=f"#{i+1}",
509
+ fill="#FFD700" if is_top else "#777777",
510
+ font=("Consolas", 7, "bold" if is_top else "normal"),
511
+ )
512
+
513
+ # --- Status Management Helpers ---
514
+ def set_status(self, message: str, is_warning: bool = False):
515
+ color = "#FF5555" if is_warning else self.COLOR_TEXT_PRIMARY
516
+ self.label_status.configure(text=message, foreground=color)
517
+
518
+ # --- Validation & Value Fetching ---
519
+ def _validate_spinbox(self, proposed_val: str) -> bool:
520
+ if proposed_val == "":
521
+ return True
522
+ return proposed_val.isdigit() and len(proposed_val) == 1
523
+
524
+ def _get_spinbox_value(self, spinbox: ttk.Spinbox) -> int:
525
+ raw_val = spinbox.get().strip()
526
+ if not raw_val or not raw_val.isdigit():
527
+ spinbox.set("0")
528
+ return 0
529
+ return int(raw_val)
530
+
531
+ # --- Key Event Handling & Buffer Management ---
532
+ def _handle_keypress(self, event):
533
+ if event.widget in (self.live_shells, self.blank_shells):
534
+ return
535
+
536
+ char = event.char.lower()
537
+ if not char:
538
+ return
539
+
540
+ self.key_buffer += char
541
+
542
+ if self.reset_timer_id:
543
+ self.after_cancel(self.reset_timer_id)
544
+
545
+ if self._evaluate_buffer():
546
+ self.key_buffer = ""
547
+ else:
548
+ self.reset_timer_id = self.after(1200, self._clear_buffer_on_timeout)
549
+
550
+ def _evaluate_buffer(self) -> bool:
551
+ buf = self.key_buffer
552
+
553
+ # 1. Single shot: [B] or [L]
554
+ if buf in ("b", "l"):
555
+ if not self._check_magazine_initialized():
556
+ return True
557
+ self.on_shot_registered(is_live=(buf == "l"))
558
+ return True
559
+
560
+ # 2. Magnifier: [MB] or [ML]
561
+ match_mag = re.match(r"^m([bl])$", buf)
562
+ if match_mag:
563
+ if not self._check_magazine_initialized():
564
+ return True
565
+ self.on_magnifier_used(is_live=(match_mag.group(1) == "l"))
566
+ return True
567
+
568
+ # 3. Burner Phone (Forward & Backward): [P + optional minus + digit + B/L]
569
+ # Examples: p3l (3rd from top), p-1b (last shell from bottom)
570
+ match_phone = re.match(r"^p(-?[1-9])([bl])$", buf)
571
+ if match_phone:
572
+ if not self._check_magazine_initialized():
573
+ return True
574
+ slot_offset = int(match_phone.group(1))
575
+ self.on_phone_used(slot=slot_offset, is_live=(match_phone.group(2) == "l"))
576
+ return True
577
+
578
+ if not (buf.startswith("m") or buf.startswith("p")):
579
+ return True
580
+
581
+ return False
582
+
583
+ def _check_magazine_initialized(self) -> bool:
584
+ if self.magazine is None or self.magazine.depth == 0:
585
+ self.set_status("⚠️ Magazine is empty or not initialized!", is_warning=True)
586
+ return False
587
+ return True
588
+
589
+ def _clear_buffer_on_timeout(self):
590
+ self.key_buffer = ""
591
+
592
+ # --- Action Callbacks ---
593
+ def on_shot_registered(self, is_live: bool):
594
+ if self._check_magazine_initialized():
595
+ self.magazine.observe_extraction("L" if is_live else "B")
596
+ self.set_status(f"{'Live' if is_live else 'Blank'} shell ejected.")
597
+ self.refresh_all_displays()
598
+
599
+ def on_magnifier_used(self, is_live: bool):
600
+ if self._check_magazine_initialized():
601
+ self.magazine.observe_peek(0, "L" if is_live else "B")
602
+ self.set_status(
603
+ f"Magnifier: Top shell is {'Live' if is_live else 'Blank'}."
604
+ )
605
+ self.refresh_all_displays()
606
+
607
+ def on_phone_used(self, slot: int, is_live: bool):
608
+ if self._check_magazine_initialized():
609
+ depth = self.magazine.depth
610
+
611
+ if slot > 0:
612
+ # 1-based forward index -> 0-based index
613
+ zero_indexed = slot - 1
614
+ else:
615
+ # Negative backward index (e.g., -1 is last shell, depth - 1)
616
+ zero_indexed = slot
617
+
618
+ if not (0 <= abs(zero_indexed) < depth):
619
+ self.set_status(
620
+ f"⚠️ Slot #{slot} is out of bounds (depth={depth}).",
621
+ is_warning=True,
622
+ )
623
+ return
624
+
625
+ self.magazine.observe_peek(zero_indexed, "L" if is_live else "B")
626
+ self.set_status(
627
+ f"Burner Phone: Shell #{slot if slot > 0 else zero_indexed + 1} is {'Live' if is_live else 'Blank'}."
628
+ )
629
+ self.refresh_all_displays()
630
+
631
+ # --- Lifecycle Callbacks ---
632
+ def on_initialize(self):
633
+ lives = self._get_spinbox_value(self.live_shells)
634
+ blanks = self._get_spinbox_value(self.blank_shells)
635
+ total = lives + blanks
636
+
637
+ if total == 0:
638
+ self.set_status(
639
+ "⚠️ Cannot initialize with 0 total shells.", is_warning=True
640
+ )
641
+ return
642
+
643
+ self.magazine = BuckshotMagazinePredictor(lives, blanks)
644
+ self.set_status(
645
+ f"Initialized: {lives} Live, {blanks} Blank ({total} total chambered)."
646
+ )
647
+ self.refresh_all_displays()
648
+
649
+ def on_reset(self):
650
+ self.live_shells.set("0")
651
+ self.blank_shells.set("0")
652
+ self.magazine = None
653
+ self.key_buffer = ""
654
+ self.set_status("App reset. Enter shell counts and click 'Initialize'.")
655
+ self.refresh_all_displays()
656
+
657
+
658
+ def main():
659
+ app = App()
660
+ app.mainloop()
661
+
662
+
663
+ if __name__ == "__main__":
664
+ main()
@@ -0,0 +1,78 @@
1
+ import itertools
2
+ from typing import List, Dict, Optional, Literal, Union
3
+
4
+ ShellType = Literal["L", "B", "U"]
5
+
6
+
7
+ class BuckshotMagazinePredictor:
8
+ def __init__(self, n_lives: int, n_blanks: int):
9
+ """Initializes with total known counts of Live ('L') and Blank ('B') shells."""
10
+ self.candidates: List[tuple[Literal["L", "B"], ...]] = list(
11
+ set(itertools.permutations(["L"] * n_lives + ["B"] * n_blanks))
12
+ )
13
+ self.depth = n_lives + n_blanks
14
+ self.n_lives = n_lives
15
+ self.n_blanks = n_blanks
16
+
17
+ def observe_peek(self, depth_from_top: int, shell_type: Literal["L", "B"]) -> int:
18
+ """
19
+ Filters candidate sequences by an oracle query (e.g. Magnifying Glass / Phone) at a given depth.
20
+ depth_from_top: 0-indexed (0 is current top shell). supports negative indexes.
21
+ """
22
+ self.candidates = [
23
+ cand for cand in self.candidates if cand[depth_from_top] == shell_type
24
+ ]
25
+ return len(self.candidates)
26
+
27
+ def observe_extraction(self, shell_type: Literal["L", "B"]) -> int:
28
+ """
29
+ Filters candidates matching fired/ejected top shell, then shifts the magazine up.
30
+ """
31
+ self.candidates = [
32
+ cand[1:] for cand in self.candidates if cand[0] == shell_type
33
+ ]
34
+ self.depth -= 1
35
+ if shell_type == "B":
36
+ self.n_blanks -= 1
37
+ elif shell_type == "L":
38
+ self.n_lives -= 1
39
+ return len(self.candidates)
40
+
41
+ def predict_slot(
42
+ self, depth_from_top: int = 0
43
+ ) -> Union[Literal["L", "B"], Dict[Literal["L", "B"], float], None]:
44
+ """
45
+ Returns:
46
+ - "L" or "B" if deterministic (100% certain).
47
+ - A dict with probabilities `{"L": P(Live), "B": P(Blank)}` if unknown.
48
+ - None if magazine is empty or index is out of bounds.
49
+ """
50
+ if not self.candidates or depth_from_top >= self.depth:
51
+ return None
52
+
53
+ total = len(self.candidates)
54
+ count_live = sum(1 for cand in self.candidates if cand[depth_from_top] == "L")
55
+
56
+ p_live = count_live / total
57
+ p_blank = 1.0 - p_live
58
+
59
+ if p_live == 1.0:
60
+ return "L"
61
+ if p_blank == 1.0:
62
+ return "B"
63
+
64
+ return {"L": round(p_live, 4), "B": round(p_blank, 4)}
65
+
66
+ def predict_all(self) -> List[ShellType]:
67
+ """
68
+ Returns a list of ShellType for all remaining slots from top to bottom.
69
+ Each element is 'L' (Live), 'B' (Blank), or 'U' (Unknown/Probabilistic).
70
+ """
71
+ result: List[ShellType] = []
72
+ for i in range(self.depth):
73
+ slot_prediction = self.predict_slot(i)
74
+ if slot_prediction in ("L", "B"):
75
+ result.append(slot_prediction)
76
+ else:
77
+ result.append("U")
78
+ return result