1140 lines
49 KiB
Python
1140 lines
49 KiB
Python
import os
|
||
import io
|
||
import subprocess
|
||
import shlex
|
||
import requests
|
||
import winsound
|
||
import webbrowser
|
||
import tkinter as tk
|
||
from tkinter import ttk, simpledialog, messagebox
|
||
import tkinter.font as tkfont
|
||
from PIL import Image, ImageTk
|
||
|
||
from mtg_api import init_cache_db, get_card_by_name, search_cards
|
||
from deck_manager import save_deck as dm_save_deck, load_deck, list_saved_decks
|
||
from collection_manager import load_collection, save_collection
|
||
from battle_simulator import simulate_match, record_manual_result, load_match_history
|
||
from models import Deck, Card
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Play a custom WAV if available; otherwise default beep on error only.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
def play_sound(sound_name: str):
|
||
path = os.path.join("assets", "sounds", f"{sound_name}.wav")
|
||
if os.path.isfile(path):
|
||
winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
|
||
else:
|
||
# Only make a sound on error; button clicks are now silent.
|
||
pass
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# VERSIONING (build number from Git commits, fallback __version__)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
MAJOR = 1
|
||
MINOR = 2
|
||
__version__ = f"{MAJOR}.{MINOR}.0" # fallback if not in a Git repo
|
||
GITHUB_REPO = "YourUsername/YourRepo" # ← replace with your GitHub "owner/repo"
|
||
|
||
|
||
def get_local_version() -> str:
|
||
"""
|
||
Return "<MAJOR>.<MINOR>.<commit_count>" by running:
|
||
git rev-list --count HEAD
|
||
Fallback to __version__ if Git fails.
|
||
"""
|
||
try:
|
||
p = subprocess.run(
|
||
shlex.split("git rev-list --count HEAD"),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.DEVNULL,
|
||
check=True,
|
||
text=True
|
||
)
|
||
build = p.stdout.strip()
|
||
return f"{MAJOR}.{MINOR}.{build}"
|
||
except Exception:
|
||
return __version__
|
||
|
||
|
||
def check_for_updates(local_version: str, repo: str) -> None:
|
||
"""
|
||
Fetch GitHub’s latest release (tag_name), compare to local_version.
|
||
If GitHub’s is newer, prompt to open the Releases page.
|
||
"""
|
||
api_url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||
try:
|
||
resp = requests.get(api_url, timeout=5)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
tag = data.get("tag_name", "").lstrip("v")
|
||
except Exception:
|
||
return
|
||
|
||
def to_tuple(v: str):
|
||
nums = [int(x) for x in v.split(".") if x.isdigit()]
|
||
return tuple(nums)
|
||
|
||
try:
|
||
if to_tuple(tag) > to_tuple(local_version):
|
||
ans = messagebox.askyesno(
|
||
"Update Available",
|
||
f"A newer version ({tag}) is available on GitHub.\n"
|
||
f"You’re running {local_version}.\n\n"
|
||
"Would you like to view the Releases page?"
|
||
)
|
||
if ans:
|
||
webbrowser.open(
|
||
data.get("html_url", f"https://github.com/{repo}/releases/latest")
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# MAIN APPLICATION
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
class MTGDeckBuilder(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("MTG Deck Builder")
|
||
self.geometry("1200x750")
|
||
|
||
# Ensure data folders/files exist
|
||
init_cache_db()
|
||
_ = load_collection()
|
||
_ = load_match_history()
|
||
|
||
# Track theme: "dark" or "light"
|
||
self.theme = tk.StringVar(value="dark")
|
||
|
||
# Currently loaded Deck
|
||
self.current_deck: Deck | None = None
|
||
|
||
# Caches
|
||
self.card_cache: dict[str, Card] = {}
|
||
self.search_images: dict[str, ImageTk.PhotoImage] = {}
|
||
self.coll_images: dict[str, dict[str, ImageTk.PhotoImage]] = {
|
||
tab: {} for tab in ["All", "Black", "White", "Red", "Green", "Blue", "Unmarked", "Tokens"]
|
||
}
|
||
self.deck_images: dict[str, dict[str, ImageTk.PhotoImage]] = {
|
||
tab: {} for tab in ["All", "Black", "White", "Red", "Green", "Blue", "Unmarked", "Tokens"]
|
||
}
|
||
self.preview_photo: ImageTk.PhotoImage | None = None
|
||
self.color_icon_images: dict[str, ImageTk.PhotoImage] = {}
|
||
|
||
# Build UI
|
||
self._load_color_icons()
|
||
self._load_sounds()
|
||
self._build_widgets()
|
||
self._layout_widgets()
|
||
self.apply_theme()
|
||
|
||
# After 1 second, check for updates
|
||
local_ver = get_local_version()
|
||
self.after(1000, lambda: check_for_updates(local_ver, GITHUB_REPO))
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Preload W/U/B/R/G color icons
|
||
# -----------------------------------------------------------------------------
|
||
def _load_color_icons(self):
|
||
icon_folder = os.path.join("assets", "icons")
|
||
for symbol in ["W", "U", "B", "R", "G"]:
|
||
path = os.path.join(icon_folder, f"{symbol}.png")
|
||
if os.path.isfile(path):
|
||
img = Image.open(path).resize((20, 20), Image.LANCZOS)
|
||
self.color_icon_images[symbol] = ImageTk.PhotoImage(img)
|
||
else:
|
||
self.color_icon_images[symbol] = None
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Ensure sound folder
|
||
# -----------------------------------------------------------------------------
|
||
def _load_sounds(self):
|
||
sound_folder = os.path.join("assets", "sounds")
|
||
os.makedirs(sound_folder, exist_ok=True)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Create all widgets (search is now a plain Entry again)
|
||
# -----------------------------------------------------------------------------
|
||
def _build_widgets(self):
|
||
# --- Top row: Deck controls + theme toggle ---
|
||
self.deck_frame = ttk.LabelFrame(self, text="Deck Controls", padding=8)
|
||
self.new_deck_btn = ttk.Button(self.deck_frame, text="New Deck", command=self._on_new_deck)
|
||
self.load_deck_btn = ttk.Button(self.deck_frame, text="Load Deck", command=self._on_load_deck)
|
||
self.save_deck_btn = ttk.Button(self.deck_frame, text="Save Deck", command=self._on_save_deck)
|
||
self.smart_build_btn = ttk.Button(self.deck_frame, text="Smart Build Deck", command=self._on_smart_build)
|
||
self.simulate_btn = ttk.Button(self.deck_frame, text="Simulate Battle", command=self._on_simulate_battle)
|
||
self.record_btn = ttk.Button(self.deck_frame, text="Record Result", command=self._on_record_result)
|
||
self.deck_name_label = ttk.Label(self.deck_frame, text="(no deck loaded)")
|
||
self.theme_toggle = ttk.Checkbutton(
|
||
self.deck_frame,
|
||
text="Light Mode",
|
||
variable=self.theme,
|
||
onvalue="light",
|
||
offvalue="dark",
|
||
command=self.apply_theme
|
||
)
|
||
|
||
# --- Collection panel with tabs (left) ---
|
||
self.coll_frame = ttk.LabelFrame(self, text="Your Collection", padding=8)
|
||
self.coll_notebook = ttk.Notebook(self.coll_frame)
|
||
self.coll_tabs = {}
|
||
self.coll_trees = {}
|
||
self.coll_scrolls = {}
|
||
for tab_name in ["All", "Black", "White", "Red", "Green", "Blue", "Unmarked", "Tokens"]:
|
||
frame = ttk.Frame(self.coll_notebook)
|
||
tree = ttk.Treeview(frame, height=20, columns=("info",), show="tree")
|
||
scroll = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
|
||
tree.configure(yscrollcommand=scroll.set)
|
||
tree.bind("<<TreeviewSelect>>", self._on_coll_select)
|
||
tree.pack(fill="both", expand=True, side="left", padx=(4,0), pady=4)
|
||
scroll.pack(fill="y", side="left", padx=(0,4), pady=4)
|
||
self.coll_notebook.add(frame, text=tab_name)
|
||
self.coll_tabs[tab_name] = frame
|
||
self.coll_trees[tab_name] = tree
|
||
self.coll_scrolls[tab_name] = scroll
|
||
self.remove_from_coll_btn = ttk.Button(self.coll_frame, text="Remove from Collection", command=self._on_remove_from_collection)
|
||
self.coll_qty_label = ttk.Label(self.coll_frame, text="Qty:")
|
||
self.coll_qty_spin = ttk.Spinbox(self.coll_frame, from_=1, to=1000, width=6)
|
||
self.coll_set_qty_btn = ttk.Button(self.coll_frame, text="Set Quantity", command=self._on_set_coll_qty)
|
||
|
||
# --- Right side: Search panel + Deck panel + Preview ---
|
||
self.right_frame = ttk.Frame(self)
|
||
|
||
# Search / Add Cards (plain Entry)
|
||
self.search_frame = ttk.LabelFrame(self.right_frame, text="Search / Add Cards", padding=8)
|
||
self.preview_container = ttk.Frame(self.search_frame)
|
||
self.preview_frame = ttk.Frame(self.preview_container, borderwidth=1, relief="solid")
|
||
self.preview_inner = ttk.Frame(self.preview_frame, padding=1)
|
||
self.preview_frame.configure(width=200, height=200)
|
||
self.preview_frame.grid_propagate(False)
|
||
self.card_image_label = ttk.Label(self.preview_inner)
|
||
self.color_icons_frame = ttk.Frame(self.preview_frame)
|
||
self.search_entry = ttk.Entry(self.search_frame, width=30)
|
||
self.search_entry.bind("<Return>", lambda e: self._on_perform_search())
|
||
self.search_btn = ttk.Button(self.search_frame, text="Search", command=self._on_perform_search)
|
||
self.results_tree = ttk.Treeview(self.search_frame, height=12, columns=("info",), show="tree")
|
||
self.results_scroll = ttk.Scrollbar(self.search_frame, orient="vertical", command=self.results_tree.yview)
|
||
self.results_tree.configure(yscrollcommand=self.results_scroll.set)
|
||
self.results_tree.bind("<<TreeviewSelect>>", self._on_result_select)
|
||
self.qty_add_frame = ttk.Frame(self.preview_container)
|
||
self.add_qty_label = ttk.Label(self.qty_add_frame, text="Qty:")
|
||
self.add_qty_spin = ttk.Spinbox(self.qty_add_frame, from_=1, to=20, width=5)
|
||
self.add_qty_spin.set("1")
|
||
self.add_to_coll_btn = ttk.Button(
|
||
self.qty_add_frame, text="Add to Collection", command=self._on_add_to_collection
|
||
)
|
||
self.add_to_deck_btn = ttk.Button(
|
||
self.qty_add_frame, text="Add to Deck", command=self._on_add_to_deck
|
||
)
|
||
|
||
# Deck panel with tabs
|
||
self.deck_view_frame = ttk.LabelFrame(self.right_frame, text="Deck Contents", padding=8)
|
||
self.deck_notebook = ttk.Notebook(self.deck_view_frame)
|
||
self.deck_tabs = {}
|
||
self.deck_trees = {}
|
||
self.deck_scrolls = {}
|
||
for tab_name in ["All", "Black", "White", "Red", "Green", "Blue", "Unmarked", "Tokens"]:
|
||
frame = ttk.Frame(self.deck_notebook)
|
||
tree = ttk.Treeview(frame, height=20, columns=("info",), show="tree")
|
||
scroll = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
|
||
tree.configure(yscrollcommand=scroll.set)
|
||
tree.bind("<<TreeviewSelect>>", self._on_deck_select)
|
||
tree.pack(fill="both", expand=True, side="left", padx=(4,0), pady=4)
|
||
scroll.pack(fill="y", side="left", padx=(0,4), pady=4)
|
||
self.deck_notebook.add(frame, text=tab_name)
|
||
self.deck_tabs[tab_name] = frame
|
||
self.deck_trees[tab_name] = tree
|
||
self.deck_scrolls[tab_name] = scroll
|
||
self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self._on_remove_selected)
|
||
self.deck_qty_label = ttk.Label(self.deck_view_frame, text="Qty:")
|
||
self.deck_qty_spin = ttk.Spinbox(self.deck_view_frame, from_=1, to=1000, width=6)
|
||
self.deck_set_qty_btn = ttk.Button(self.deck_view_frame, text="Set Quantity", command=self._on_set_deck_qty)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Arrange everything with pack() and grid()
|
||
# -----------------------------------------------------------------------------
|
||
def _layout_widgets(self):
|
||
# ─── Deck controls (top) ───────────────────────────────────────────
|
||
self.deck_frame.pack(fill="x", padx=10, pady=(10, 5))
|
||
self.new_deck_btn.grid(row=0, column=0, padx=4, pady=4)
|
||
self.load_deck_btn.grid(row=0, column=1, padx=4, pady=4)
|
||
self.save_deck_btn.grid(row=0, column=2, padx=4, pady=4)
|
||
self.smart_build_btn.grid(row=0, column=3, padx=4, pady=4)
|
||
self.simulate_btn.grid(row=0, column=4, padx=4, pady=4)
|
||
self.record_btn.grid(row=0, column=5, padx=4, pady=4)
|
||
self.theme_toggle.grid(row=0, column=6, padx=20, pady=4)
|
||
self.deck_name_label.grid(row=0, column=7, padx=10, pady=4, sticky="w")
|
||
|
||
# ─── Collection panel (left) ────────────────────────────────────────
|
||
self.coll_frame.pack(fill="y", side="left", padx=(10,5), pady=5)
|
||
self.coll_frame.configure(width=280)
|
||
|
||
# 1) “Remove from Collection” at top
|
||
self.remove_from_coll_btn.pack(fill="x", padx=4, pady=(4,4))
|
||
|
||
# 2) Tabs for All/Black/White/etc.
|
||
self.coll_notebook.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
# 3) Quantity + Set Quantity under the Collection tree
|
||
qty_frame_c = ttk.Frame(self.coll_frame)
|
||
qty_frame_c.pack(fill="x", padx=4, pady=(0,10))
|
||
self.coll_qty_label.pack(in_=qty_frame_c, side="left")
|
||
self.coll_qty_spin.pack(in_=qty_frame_c, side="left", padx=(4,10))
|
||
self.coll_set_qty_btn.pack(in_=qty_frame_c, side="left")
|
||
|
||
# ─── Right side: Search panel + Deck panel ──────────────────────────
|
||
self.right_frame.pack(fill="both", expand=True, side="left", padx=(5,10), pady=5)
|
||
self.right_frame.columnconfigure(0, weight=1)
|
||
self.right_frame.columnconfigure(1, weight=1)
|
||
self.right_frame.rowconfigure(0, weight=1)
|
||
|
||
# --- Search panel (right_frame at row=0, col=0) ---
|
||
self.search_frame.grid(row=0, column=0, sticky="nsew", padx=(0,5), pady=(0,0))
|
||
# Let the search results expand vertically and horizontally
|
||
self.search_frame.columnconfigure(0, weight=1)
|
||
self.search_frame.rowconfigure(1, weight=1)
|
||
|
||
# Row 0: Search entry + Search button
|
||
self.search_entry.grid(row=0, column=0, padx=4, pady=4, sticky="w")
|
||
self.search_btn .grid(row=0, column=1, padx=4, pady=4, sticky="w")
|
||
|
||
# Row 1: search results (col=0), scrollbar (col=1), preview_container (col=2)
|
||
self.results_tree .grid(row=1, column=0, padx=(4,0), pady=4, sticky="nsew")
|
||
self.results_scroll.grid(row=1, column=1, sticky="ns", pady=4)
|
||
self.preview_container.grid(row=1, column=2, padx=(10,4), pady=4, sticky="n")
|
||
|
||
# Keep results_tree filling space; preview_container stays its natural size
|
||
self.search_frame.rowconfigure(1, weight=1)
|
||
self.search_frame.columnconfigure(2, weight=0)
|
||
|
||
# ─── Inside preview_container ────────────────────────────────────
|
||
# Row 0: preview_frame (auto‐sizes to card image + 1px border)
|
||
# Row 1: qty_add_frame (holds the spinbox and two “Add” buttons side by side)
|
||
|
||
self.preview_container.columnconfigure(0, weight=0)
|
||
self.preview_container.rowconfigure(0, weight=0)
|
||
self.preview_container.rowconfigure(1, weight=0)
|
||
|
||
# 1) Place the framed preview (borderwidth=1, relief="solid")
|
||
self.preview_frame.grid(row=0, column=0, padx=0, pady=0, sticky="n")
|
||
# Inside the frame, pack preview_inner (which holds a 1px padding)
|
||
self.preview_inner.pack(fill="both", expand=True)
|
||
# And pack the image label inside that
|
||
self.card_image_label.pack(expand=True)
|
||
|
||
# 2) Immediately under that, place qty_add_frame
|
||
self.qty_add_frame.grid(row=1, column=0, pady=(4,4))
|
||
|
||
# Inside qty_add_frame, arrange: “Qty:” label, spinbox, Add to Collection, Add to Deck
|
||
self.add_qty_label.grid(row=0, column=0, padx=(0,4))
|
||
self.add_qty_spin .grid(row=0, column=1, padx=(0,10))
|
||
self.add_to_coll_btn.grid(row=0, column=2, padx=(0,4))
|
||
self.add_to_deck_btn .grid(row=0, column=3, padx=(0,4))
|
||
|
||
# Row 2 of search_frame: we no longer need a quantity row, because we moved
|
||
# the spinbox into qty_add_frame. If you want an extra blank row, you can comment this out.
|
||
|
||
# --- Deck panel (right_frame at row=0, col=1) ---
|
||
self.deck_view_frame.grid(row=0, column=1, sticky="nsew", padx=(5,0), pady=(0,0))
|
||
self.deck_view_frame.columnconfigure(0, weight=1)
|
||
self.deck_view_frame.rowconfigure(0, weight=1)
|
||
|
||
# “Remove Selected” at top of deck_view_frame
|
||
self.remove_card_btn.pack(fill="x", padx=4, pady=(4,4))
|
||
|
||
# Then the deck’s Notebook (tabs) below, which expands
|
||
self.deck_notebook.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
# Qty + Set Quantity under the Deck notebook
|
||
qty_frame_d = ttk.Frame(self.deck_view_frame)
|
||
qty_frame_d.pack(fill="x", padx=4, pady=(0,10))
|
||
self.deck_qty_label.pack( in_=qty_frame_d, side="left")
|
||
self.deck_qty_spin.pack( in_=qty_frame_d, side="left", padx=(4,10))
|
||
self.deck_set_qty_btn.pack(in_=qty_frame_d, side="left")
|
||
|
||
# ─── Finally, refresh both lists ───────────────────────────────────
|
||
self._refresh_collection()
|
||
self._refresh_deck()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Apply VSCode Dark+ or Light theme
|
||
# -----------------------------------------------------------------------------
|
||
def apply_theme(self):
|
||
mode = self.theme.get()
|
||
style = ttk.Style()
|
||
style.theme_use("clam")
|
||
|
||
if mode == "dark":
|
||
bg = "#1e1e1e"; fg = "#d4d4d4"; panel = "#252526"
|
||
entry_bg = "#3c3c3c"; entry_fg = "#d4d4d4"; select_bg = "#264f78"
|
||
btn_bg = "#0e639c"; btn_fg = "#ffffff"
|
||
else:
|
||
bg = "#ffffff"; fg = "#000000"; panel = "#f0f0f0"
|
||
entry_bg = "#ffffff"; entry_fg = "#000000"; select_bg = "#cce5ff"
|
||
btn_bg = "#007acc"; btn_fg = "#ffffff"
|
||
|
||
style.configure("TLabelframe", background=panel, foreground=fg)
|
||
style.configure("TLabelframe.Label", background=panel, foreground=fg)
|
||
style.configure("TLabel", background=bg, foreground=fg)
|
||
style.configure("TButton", background=btn_bg, foreground=btn_fg)
|
||
style.map("TButton",
|
||
background=[("active", "#005a9e")] if mode=="dark" else [("active", "#0057e7")])
|
||
style.configure("TCheckbutton", background=panel, foreground=fg)
|
||
|
||
style.configure("Treeview",
|
||
background=entry_bg, foreground=entry_fg,
|
||
fieldbackground=entry_bg, selectbackground=select_bg, rowheight=48)
|
||
style.map("Treeview", background=[("selected", select_bg)])
|
||
style.configure("TEntry", fieldbackground=entry_bg, foreground=entry_fg)
|
||
style.configure("TSpinbox", fieldbackground=entry_bg, foreground=entry_fg)
|
||
|
||
self.configure(background=bg)
|
||
for frame in [self.deck_frame, self.coll_frame, self.search_frame,
|
||
self.deck_view_frame, self.preview_frame, self.right_frame]:
|
||
frame.configure(style="TLabelframe")
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Perform “Search” (when user clicks Search or hits Enter)
|
||
# -----------------------------------------------------------------------------
|
||
def _on_perform_search(self):
|
||
query = self.search_entry.get().strip()
|
||
if not query:
|
||
return
|
||
|
||
self.results_tree.delete(*self.results_tree.get_children())
|
||
self.search_images.clear()
|
||
|
||
try:
|
||
results = search_cards(query)
|
||
except Exception:
|
||
results = []
|
||
if not results:
|
||
return
|
||
|
||
for idx, card in enumerate(results):
|
||
self.card_cache[card.name] = card
|
||
img = None
|
||
if card.image_url:
|
||
try:
|
||
resp = requests.get(card.image_url, timeout=5)
|
||
resp.raise_for_status()
|
||
pil = Image.open(io.BytesIO(resp.content))
|
||
pil.thumbnail((80,120), Image.LANCZOS)
|
||
img = ImageTk.PhotoImage(pil)
|
||
self.search_images[card.name] = img
|
||
except Exception:
|
||
img = None
|
||
|
||
display = f"{card.name} ● {card.mana_cost or ''} ● {card.type_line} [{card.rarity}]"
|
||
if img:
|
||
self.results_tree.insert("", "end", iid=str(idx), text=display, image=img)
|
||
else:
|
||
self.results_tree.insert("", "end", iid=str(idx), text=display)
|
||
|
||
self._clear_preview()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# When a search result is clicked → preview it
|
||
# -----------------------------------------------------------------------------
|
||
def _on_result_select(self, event):
|
||
sel = self.results_tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = self.results_tree.item(iid, "text")
|
||
card_name = display.split(" ● ")[0].strip()
|
||
|
||
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||
if not card:
|
||
return
|
||
self.card_cache[card.name] = card
|
||
self._show_preview(card)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Show full image + color pips in preview
|
||
# -----------------------------------------------------------------------------
|
||
def _show_preview(self, card: Card):
|
||
# Clear out any old contents in color icons and image:
|
||
for w in self.color_icons_frame.winfo_children():
|
||
w.destroy()
|
||
self.card_image_label.config(image="", text="")
|
||
|
||
# Display color pips at the top inside preview_inner:
|
||
x = 0
|
||
for symbol in card.colors:
|
||
icon = self.color_icon_images.get(symbol)
|
||
if icon:
|
||
lbl = ttk.Label(self.color_icons_frame, image=icon)
|
||
lbl.image = icon
|
||
lbl.grid(row=0, column=x, padx=2)
|
||
x += 1
|
||
|
||
if card.image_url:
|
||
try:
|
||
resp = requests.get(card.image_url, timeout=10)
|
||
resp.raise_for_status()
|
||
img_data = resp.content
|
||
image = Image.open(io.BytesIO(img_data))
|
||
# Optional: resize the image so it’s not gigantic.
|
||
# For example, if you want max width=180, max height=260, do:
|
||
max_w, max_h = 180, 260
|
||
image.thumbnail((max_w, max_h), Image.LANCZOS)
|
||
|
||
photo = ImageTk.PhotoImage(image)
|
||
self.preview_photo = photo # keep a reference
|
||
|
||
# Put the image in the Label:
|
||
self.card_image_label.config(image=photo, text="")
|
||
except Exception:
|
||
self.card_image_label.config(text="Could not load image", image="")
|
||
self.preview_photo = None
|
||
else:
|
||
self.card_image_label.config(text="No image available", image="")
|
||
self.preview_photo = None
|
||
|
||
# Now re‐pack / grid so that preview_frame wraps to its contents:
|
||
# (If it was hidden or empty before, we need to ensure layout is updated.)
|
||
self.preview_inner.update_idletasks()
|
||
self.preview_frame.update_idletasks()
|
||
self.preview_container.update_idletasks()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Add to Collection (silent)—auto-caches thumbnails on refresh
|
||
# -----------------------------------------------------------------------------
|
||
def _on_add_to_collection(self):
|
||
coll = load_collection()
|
||
sel = self.results_tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = self.results_tree.item(iid, "text")
|
||
card_name = display.split(" ● ")[0].strip()
|
||
|
||
try:
|
||
qty = int(self.add_qty_spin.get())
|
||
if qty < 1:
|
||
raise ValueError
|
||
except Exception:
|
||
qty = 1
|
||
|
||
coll[card_name] = coll.get(card_name, 0) + qty
|
||
save_collection(coll)
|
||
self._refresh_collection()
|
||
|
||
# Clear the search box so user can type another name
|
||
self.search_entry.delete(0, tk.END)
|
||
self.search_entry.focus_set()
|
||
self.results_tree.delete(*self.results_tree.get_children())
|
||
self._clear_preview()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Add to Deck (silent)
|
||
# -----------------------------------------------------------------------------
|
||
def _on_add_to_deck(self):
|
||
if not self.current_deck:
|
||
return
|
||
sel = self.results_tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = self.results_tree.item(iid, "text")
|
||
card_name = display.split(" ● ")[0].strip()
|
||
|
||
try:
|
||
qty = int(self.add_qty_spin.get())
|
||
if qty < 1:
|
||
raise ValueError
|
||
except Exception:
|
||
qty = 1
|
||
|
||
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||
if not card:
|
||
return
|
||
self.card_cache[card.name] = card
|
||
|
||
self.current_deck.add_card(card.name, qty)
|
||
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)")
|
||
self._refresh_deck()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Remove selected from collection
|
||
# -----------------------------------------------------------------------------
|
||
def _on_remove_from_collection(self):
|
||
current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text")
|
||
tree = self.coll_trees[current_tab]
|
||
sel = tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = tree.item(iid, "text")
|
||
_, name_part = display.split("×", 1)
|
||
card_name = name_part.strip()
|
||
|
||
coll = load_collection()
|
||
if card_name in coll:
|
||
del coll[card_name]
|
||
save_collection(coll)
|
||
self._refresh_collection()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# When a collection card is selected → populate spinbox
|
||
# -----------------------------------------------------------------------------
|
||
def _on_coll_select(self, event):
|
||
current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text")
|
||
tree = self.coll_trees[current_tab]
|
||
sel = tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = tree.item(iid, "text")
|
||
qty_str, _ = display.split("×", 1)
|
||
try:
|
||
self.coll_qty_spin.set(qty_str.strip())
|
||
except Exception:
|
||
self.coll_qty_spin.set("1")
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Set quantity in collection (inline)
|
||
# -----------------------------------------------------------------------------
|
||
def _on_set_coll_qty(self):
|
||
current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text")
|
||
tree = self.coll_trees[current_tab]
|
||
sel = tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = tree.item(iid, "text")
|
||
_, name_part = display.split("×", 1)
|
||
card_name = name_part.strip()
|
||
|
||
try:
|
||
new_qty = int(self.coll_qty_spin.get())
|
||
if new_qty < 1:
|
||
raise ValueError
|
||
except Exception:
|
||
new_qty = 1
|
||
|
||
coll = load_collection()
|
||
coll[card_name] = new_qty
|
||
save_collection(coll)
|
||
self._refresh_collection()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Refresh the entire collection (all tabs) + autofit columns
|
||
# -----------------------------------------------------------------------------
|
||
def _refresh_collection(self):
|
||
coll = load_collection()
|
||
buckets = {tn: [] for tn in self.coll_trees}
|
||
for name, qty in coll.items():
|
||
card = self.card_cache.get(name) or get_card_by_name(name)
|
||
if card:
|
||
self.card_cache[card.name] = card
|
||
colors = card.colors
|
||
is_token = "Token" in card.type_line
|
||
else:
|
||
colors = []
|
||
is_token = False
|
||
|
||
buckets["All"].append((name, qty))
|
||
for col, tab in [("B", "Black"), ("W", "White"),
|
||
("R", "Red"), ("G", "Green"), ("U", "Blue")]:
|
||
if col in colors:
|
||
buckets[tab].append((name, qty))
|
||
if not colors and not is_token:
|
||
buckets["Unmarked"].append((name, qty))
|
||
if is_token:
|
||
buckets["Tokens"].append((name, qty))
|
||
|
||
for tab_name, tree in self.coll_trees.items():
|
||
tree.delete(*tree.get_children())
|
||
# Keep self.coll_images[tab_name] intact—reuse cached thumbnails
|
||
fnt_spec = ttk.Style().lookup("Treeview", "font")
|
||
if fnt_spec:
|
||
fnt = tkfont.Font(font=fnt_spec)
|
||
else:
|
||
fnt = tkfont.nametofont("TkDefaultFont")
|
||
|
||
max_width = 0
|
||
for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())):
|
||
card = self.card_cache.get(card_name)
|
||
img = None
|
||
if card and card.thumbnail_url:
|
||
if card_name not in self.coll_images[tab_name]:
|
||
try:
|
||
resp = requests.get(card.thumbnail_url, timeout=5)
|
||
resp.raise_for_status()
|
||
pil = Image.open(io.BytesIO(resp.content))
|
||
pil.thumbnail((24,36), Image.LANCZOS)
|
||
img_obj = ImageTk.PhotoImage(pil)
|
||
self.coll_images[tab_name][card_name] = img_obj
|
||
except Exception:
|
||
pass
|
||
img = self.coll_images[tab_name].get(card_name)
|
||
|
||
display = f"{qty}× {card_name}"
|
||
if img:
|
||
tree.insert("", "end", iid=str(idx), text=display, image=img)
|
||
text_w = fnt.measure(display)
|
||
total_w = text_w + 24 + 10
|
||
else:
|
||
tree.insert("", "end", iid=str(idx), text=display)
|
||
total_w = fnt.measure(display) + 10
|
||
|
||
if total_w > max_width:
|
||
max_width = total_w
|
||
|
||
tree.column("#0", width=max_width)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “New Deck” callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_new_deck(self):
|
||
name = simpledialog.askstring("New Deck", "Enter deck name:", parent=self)
|
||
if not name:
|
||
return
|
||
self.current_deck = Deck(name=name)
|
||
self.deck_name_label.config(text=f"Deck: {name} (0 cards)")
|
||
self._refresh_deck()
|
||
self._clear_preview()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Load Deck” callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_load_deck(self):
|
||
choices = list_saved_decks()
|
||
if not choices:
|
||
return
|
||
name = simpledialog.askstring(
|
||
"Load Deck",
|
||
f"Available: {', '.join(choices)}\nEnter deck name:",
|
||
parent=self
|
||
)
|
||
if not name:
|
||
return
|
||
deck = load_deck(name)
|
||
if deck:
|
||
self.current_deck = deck
|
||
self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)")
|
||
self._refresh_deck()
|
||
self._clear_preview()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Save Deck” callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_save_deck(self):
|
||
if not self.current_deck:
|
||
return
|
||
dm_save_deck(self.current_deck)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# When a deck card is selected → preview + set spinbox
|
||
# -----------------------------------------------------------------------------
|
||
def _on_deck_select(self, event):
|
||
current_tab = self.deck_notebook.tab(self.deck_notebook.select(), "text")
|
||
tree = self.deck_trees[current_tab]
|
||
sel = tree.selection()
|
||
if not sel or not self.current_deck:
|
||
return
|
||
iid = sel[0]
|
||
display = tree.item(iid, "text")
|
||
parts = display.split("×", 1)
|
||
if len(parts) != 2:
|
||
return
|
||
qty_str, name_part = parts
|
||
card_name = name_part.strip()
|
||
if card_name.endswith("⚠"):
|
||
card_name = card_name[:-1].strip()
|
||
|
||
try:
|
||
self.deck_qty_spin.set(qty_str.strip())
|
||
except Exception:
|
||
self.deck_qty_spin.set("1")
|
||
|
||
card = self.card_cache.get(card_name) or get_card_by_name(card_name)
|
||
if not card:
|
||
return
|
||
self.card_cache[card.name] = card
|
||
self._show_preview(card)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Set Quantity” in Deck callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_set_deck_qty(self):
|
||
if not self.current_deck:
|
||
return
|
||
current_tab = self.deck_notebook.tab(self.deck_notebook.select(), "text")
|
||
tree = self.deck_trees[current_tab]
|
||
sel = tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = tree.item(iid, "text")
|
||
parts = display.split("×", 1)
|
||
if len(parts) != 2:
|
||
return
|
||
_, name_part = parts
|
||
card_name = name_part.strip()
|
||
if card_name.endswith("⚠"):
|
||
card_name = card_name[:-1].strip()
|
||
|
||
try:
|
||
new_qty = int(self.deck_qty_spin.get())
|
||
if new_qty < 1:
|
||
raise ValueError
|
||
except Exception:
|
||
new_qty = 1
|
||
|
||
self.current_deck.cards[card_name] = new_qty
|
||
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)")
|
||
self._refresh_deck()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Remove Selected” from deck callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_remove_selected(self):
|
||
if not self.current_deck:
|
||
return
|
||
current_tab = self.deck_notebook.tab(self.deck_notebook.select(), "text")
|
||
tree = self.deck_trees[current_tab]
|
||
sel = tree.selection()
|
||
if not sel:
|
||
return
|
||
iid = sel[0]
|
||
display = tree.item(iid, "text")
|
||
parts = display.split("×", 1)
|
||
if len(parts) != 2:
|
||
return
|
||
_, name_part = parts
|
||
card_name = name_part.strip()
|
||
if card_name.endswith("⚠"):
|
||
card_name = card_name[:-1].strip()
|
||
|
||
if card_name in self.current_deck.cards:
|
||
del self.current_deck.cards[card_name]
|
||
self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)")
|
||
self._refresh_deck()
|
||
self._clear_preview()
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Refresh deck tabs + autofit columns
|
||
# -----------------------------------------------------------------------------
|
||
def _refresh_deck(self):
|
||
if not self.current_deck:
|
||
for tree in self.deck_trees.values():
|
||
tree.delete(*tree.get_children())
|
||
return
|
||
|
||
buckets = {tn: [] for tn in self.deck_trees}
|
||
for name, qty in self.current_deck.cards.items():
|
||
card = self.card_cache.get(name) or get_card_by_name(name)
|
||
if card:
|
||
self.card_cache[card.name] = card
|
||
colors = card.colors
|
||
is_token = "Token" in card.type_line
|
||
else:
|
||
colors = []
|
||
is_token = False
|
||
|
||
buckets["All"].append((name, qty))
|
||
for col, tab in [("B", "Black"), ("W", "White"),
|
||
("R", "Red"), ("G", "Green"), ("U", "Blue")]:
|
||
if col in colors:
|
||
buckets[tab].append((name, qty))
|
||
if not colors and not is_token:
|
||
buckets["Unmarked"].append((name, qty))
|
||
if is_token:
|
||
buckets["Tokens"].append((name, qty))
|
||
|
||
for tab_name, tree in self.deck_trees.items():
|
||
tree.delete(*tree.get_children())
|
||
self.deck_images[tab_name].clear()
|
||
fnt_spec = ttk.Style().lookup("Treeview", "font")
|
||
if fnt_spec:
|
||
fnt = tkfont.Font(font=fnt_spec)
|
||
else:
|
||
fnt = tkfont.nametofont("TkDefaultFont")
|
||
|
||
max_width = 0
|
||
for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())):
|
||
card = self.card_cache.get(card_name)
|
||
img = None
|
||
if card and card.thumbnail_url:
|
||
if card_name not in self.deck_images[tab_name]:
|
||
try:
|
||
resp = requests.get(card.thumbnail_url, timeout=5)
|
||
resp.raise_for_status()
|
||
pil = Image.open(io.BytesIO(resp.content))
|
||
pil.thumbnail((24, 36), Image.LANCZOS)
|
||
img_obj = ImageTk.PhotoImage(pil)
|
||
self.deck_images[tab_name][card_name] = img_obj
|
||
except Exception:
|
||
pass
|
||
img = self.deck_images[tab_name].get(card_name)
|
||
|
||
flag = ""
|
||
if card and qty > 1 and not is_land(card):
|
||
flag = " ⚠"
|
||
display = f"{qty}× {card_name}{flag}"
|
||
if img:
|
||
tree.insert("", "end", iid=str(idx), text=display, image=img)
|
||
text_w = fnt.measure(display)
|
||
total_w = text_w + 24 + 10
|
||
else:
|
||
tree.insert("", "end", iid=str(idx), text=display)
|
||
total_w = fnt.measure(display) + 10
|
||
|
||
if total_w > max_width:
|
||
max_width = total_w
|
||
|
||
tree.column("#0", width=max_width)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Clear card preview
|
||
# -----------------------------------------------------------------------------
|
||
def _clear_preview(self):
|
||
self.card_image_label.config(image="", text="")
|
||
for w in self.color_icons_frame.winfo_children():
|
||
w.destroy()
|
||
self.preview_photo = None
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Smart Build Deck” callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_smart_build(self):
|
||
color_input = simpledialog.askstring(
|
||
"Smart Build: Colors",
|
||
"Enter 1–3 colors (e.g. R G) separated by spaces:",
|
||
parent=self
|
||
)
|
||
if not color_input:
|
||
return
|
||
|
||
colors = [c.strip().upper() for c in color_input.split() if c.strip().upper() in {"W","U","B","R","G"}]
|
||
if not 1 <= len(colors) <= 3:
|
||
play_sound("error")
|
||
messagebox.showerror("Invalid Colors", "You must pick 1–3 of W, U, B, R, G.")
|
||
return
|
||
|
||
history = load_match_history()
|
||
archetypes = ["Aggro", "Midrange", "Control"]
|
||
best_arch = None
|
||
best_rate = -1.0
|
||
combo = "/".join(colors)
|
||
|
||
for arch in archetypes:
|
||
total = wins = 0
|
||
for record in history:
|
||
dn = record.get("deck", "")
|
||
if dn.startswith(arch) and combo in dn:
|
||
res = record.get("result", "")
|
||
if res in ("W","L"):
|
||
total += 1
|
||
if res == "W":
|
||
wins += 1
|
||
if total > 0:
|
||
rate = wins / total
|
||
if rate > best_rate:
|
||
best_rate = rate
|
||
best_arch = arch
|
||
|
||
if best_arch:
|
||
confirm = messagebox.askokcancel(
|
||
"Choose Archetype",
|
||
f"Based on history, {best_arch} {combo} has win rate {best_rate:.0%}.\nUse it?"
|
||
)
|
||
if confirm:
|
||
archetype = best_arch.lower()
|
||
else:
|
||
archetype = None
|
||
else:
|
||
archetype = None
|
||
|
||
if not archetype:
|
||
arch_input = simpledialog.askstring(
|
||
"Smart Build: Archetype",
|
||
"Enter archetype (Aggro, Control, Midrange):",
|
||
parent=self
|
||
)
|
||
if not arch_input:
|
||
return
|
||
arch_input = arch_input.strip().lower()
|
||
if arch_input not in {"aggro", "control", "midrange"}:
|
||
play_sound("error")
|
||
messagebox.showerror("Invalid Archetype", "Must be 'Aggro', 'Control', or 'Midrange'.")
|
||
return
|
||
archetype = arch_input
|
||
|
||
deck_name = f"{archetype.capitalize()} {combo} Auto"
|
||
deck = Deck(name=deck_name)
|
||
|
||
land_count = 24
|
||
if archetype == "aggro":
|
||
creature_target = 24; noncreature_target = 12
|
||
elif archetype == "midrange":
|
||
creature_target = 18; noncreature_target = 18
|
||
else:
|
||
creature_target = 12; noncreature_target = 24
|
||
|
||
per_color = land_count // len(colors)
|
||
extra = land_count % len(colors)
|
||
basic_map = {"W":"Plains","U":"Island","B":"Swamp","R":"Mountain","G":"Forest"}
|
||
for idx, col in enumerate(colors):
|
||
qty = per_color + (1 if idx < extra else 0)
|
||
deck.add_card(basic_map[col], qty)
|
||
|
||
creature_query = f"c:{''.join(colors)} type:creature"
|
||
if archetype == "aggro":
|
||
creature_query += " cmc<=3"
|
||
elif archetype == "midrange":
|
||
creature_query += " cmc<=4"
|
||
else:
|
||
creature_query += " cmc<=5"
|
||
creatures = search_cards(creature_query)
|
||
creatures = [c for c in creatures if set(c.colors).issubset(set(colors))]
|
||
used = set()
|
||
added = 0
|
||
for c in creatures:
|
||
if added >= creature_target:
|
||
break
|
||
if c.name not in used:
|
||
deck.add_card(c.name, 1)
|
||
used.add(c.name)
|
||
added += 1
|
||
|
||
noncre_query = f"c:{''.join(colors)} (type:instant or type:sorcery)"
|
||
if archetype == "aggro":
|
||
noncre_query += " cmc<=3"
|
||
elif archetype == "midrange":
|
||
noncre_query += " cmc<=4"
|
||
else:
|
||
noncre_query += " cmc>=3"
|
||
noncre = search_cards(noncre_query)
|
||
noncre = [c for c in noncre if set(c.colors).issubset(set(colors))]
|
||
added_non = 0
|
||
for c in noncre:
|
||
if added_non >= noncreature_target:
|
||
break
|
||
if c.name not in used:
|
||
deck.add_card(c.name, 1)
|
||
used.add(c.name)
|
||
added_non += 1
|
||
|
||
total_cards = sum(deck.cards.values())
|
||
if total_cards < 60:
|
||
fill_needed = 60 - total_cards
|
||
filler = search_cards("type:creature cmc<=3")
|
||
for c in filler:
|
||
if c.name not in used:
|
||
deck.add_card(c.name, 1)
|
||
used.add(c.name)
|
||
fill_needed -= 1
|
||
if fill_needed == 0:
|
||
break
|
||
|
||
self.current_deck = deck
|
||
self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)")
|
||
self._refresh_deck()
|
||
self._clear_preview()
|
||
messagebox.showinfo(
|
||
"Smart Build Complete",
|
||
f"Created deck '{deck.name}' with {deck.total_cards()} cards."
|
||
)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Simulate Battle” callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_simulate_battle(self):
|
||
choices = list_saved_decks()
|
||
if len(choices) < 2:
|
||
return
|
||
|
||
d1 = simpledialog.askstring(
|
||
"Simulate Battle: Deck 1",
|
||
f"Available: {', '.join(choices)}\nEnter deck 1 name:",
|
||
parent=self
|
||
)
|
||
if not d1 or d1 not in choices:
|
||
return
|
||
deck1 = load_deck(d1)
|
||
if not deck1:
|
||
return
|
||
|
||
d2 = simpledialog.askstring(
|
||
"Simulate Battle: Deck 2",
|
||
f"Available: {', '.join(choices)}\nEnter deck 2 name:",
|
||
parent=self
|
||
)
|
||
if not d2 or d2 not in choices:
|
||
return
|
||
deck2 = load_deck(d2)
|
||
if not deck2:
|
||
return
|
||
|
||
wins1, wins2, ties = simulate_match(deck1, deck2, iterations=1000)
|
||
messagebox.showinfo(
|
||
"Simulation Complete",
|
||
f"Results (1000 games):\n\n"
|
||
f"{d1} wins: {wins1}\n"
|
||
f"{d2} wins: {wins2}\n"
|
||
f"Ties: {ties}"
|
||
)
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# “Record Result” callback
|
||
# -----------------------------------------------------------------------------
|
||
def _on_record_result(self):
|
||
choices = list_saved_decks()
|
||
if not choices:
|
||
return
|
||
|
||
deck_name = simpledialog.askstring(
|
||
"Record Result: Deck",
|
||
f"Available: {', '.join(choices)}\nEnter deck name:",
|
||
parent=self
|
||
)
|
||
if not deck_name or deck_name not in choices:
|
||
return
|
||
|
||
opponent = simpledialog.askstring(
|
||
"Record Result: Opponent Deck (optional)",
|
||
"Enter opponent deck name (or leave blank):",
|
||
parent=self
|
||
)
|
||
if opponent is None:
|
||
return
|
||
|
||
result = simpledialog.askstring(
|
||
"Record Result: Outcome",
|
||
"Enter result (W for win, L for loss, T for tie):",
|
||
parent=self
|
||
)
|
||
if not result or result.upper() not in {"W","L","T"}:
|
||
play_sound("error")
|
||
messagebox.showerror("Invalid Result", "Result must be W, L, or T.")
|
||
return
|
||
|
||
record_manual_result(deck_name, opponent, result.upper())
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Launch the app
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
if __name__ == "__main__":
|
||
missing_icons = [s for s in ["W","U","B","R","G"]
|
||
if not os.path.isfile(os.path.join("assets","icons", f"{s}.png"))]
|
||
if missing_icons:
|
||
print(f"Warning: Missing color icon(s) for {missing_icons} in assets/icons/. Cards will still load.")
|
||
|
||
missing_sounds = []
|
||
for nm in ["click","error"]:
|
||
if not os.path.isfile(os.path.join("assets","sounds", f"{nm}.wav")):
|
||
missing_sounds.append(nm)
|
||
if missing_sounds:
|
||
print(f"Warning: Missing sound(s) for {missing_sounds} in assets/sounds/. Default OS beep may appear.")
|
||
|
||
app = MTGDeckBuilder()
|
||
app.mainloop()
|