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.
- backshot_solver-0.1.0/PKG-INFO +107 -0
- backshot_solver-0.1.0/README.md +98 -0
- backshot_solver-0.1.0/pyproject.toml +18 -0
- backshot_solver-0.1.0/pyproject.toml.orig +17 -0
- backshot_solver-0.1.0/src/backshot_solver/__init__.py +2 -0
- backshot_solver-0.1.0/src/backshot_solver/app.py +664 -0
- backshot_solver-0.1.0/src/backshot_solver/predictor.py +78 -0
|
@@ -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,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
|