From de26c2e4c63b802a06384c34a1117e837f1184cd Mon Sep 17 00:00:00 2001 From: Dani Date: Tue, 3 Jun 2025 17:35:59 -0400 Subject: [PATCH] Started testing and adding features --- .gitignore | 1 + battle_simulator.py | 83 ++++ collection_manager.py | 633 ++---------------------------- data/cards_cache.sqlite | Bin 16384 -> 65536 bytes main.py | 841 +++++++++++++++++++++++++++++++--------- models.py | 11 +- 6 files changed, 790 insertions(+), 779 deletions(-) create mode 100644 battle_simulator.py diff --git a/.gitignore b/.gitignore index 2cd3f63..49c37dd 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,4 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore +/data/collection.json \ No newline at end of file diff --git a/battle_simulator.py b/battle_simulator.py new file mode 100644 index 0000000..7e5bea6 --- /dev/null +++ b/battle_simulator.py @@ -0,0 +1,83 @@ +# battle_simulator.py +import random +import json +import os + +# Basic land names for simplicity: +BASIC_LANDS = {"Plains", "Island", "Swamp", "Mountain", "Forest"} + +# Path for manual match history +MATCH_HISTORY_FILE = os.path.join("data", "match_history.json") + +def _deck_to_list(deck): + """ + Convert Deck.cards (dict of name: qty) into a flat list of card names. + """ + card_list = [] + for name, qty in deck.cards.items(): + card_list.extend([name] * qty) + return card_list + +def simulate_hand(deck, hand_size=7): + """ + Simulate drawing an opening hand from the deck. + Returns True if the hand has between 2 and 5 lands (inclusive), else False. + """ + deck_list = _deck_to_list(deck) + if len(deck_list) < hand_size: + return False + hand = random.sample(deck_list, hand_size) + land_count = sum(1 for card in hand if card in BASIC_LANDS) + return 2 <= land_count <= 5 + +def simulate_match(deck1, deck2, iterations=1000): + """ + Simulate a match between deck1 and deck2 over 'iterations' games. + For each game, both decks draw an opening hand; if one hits the land range + and the other doesn't, that deck wins; if both hit or both miss, it's a tie. + Returns (wins1, wins2, ties). + """ + wins1 = wins2 = ties = 0 + for _ in range(iterations): + result1 = simulate_hand(deck1) + result2 = simulate_hand(deck2) + if result1 and not result2: + wins1 += 1 + elif result2 and not result1: + wins2 += 1 + else: + ties += 1 + return wins1, wins2, ties + +def load_match_history(): + """ + Load manual match history from JSON. Returns a list of records: + [{"deck": "DeckName", "opponent": "OppName", "result": "W"|"L"|"T"}, ...] + If file doesn't exist, returns []. + """ + if not os.path.isdir(os.path.dirname(MATCH_HISTORY_FILE)): + os.makedirs(os.path.dirname(MATCH_HISTORY_FILE), exist_ok=True) + if not os.path.isfile(MATCH_HISTORY_FILE): + return [] + try: + with open(MATCH_HISTORY_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + return [] + +def save_match_history(history): + """ + Save the list of match records to disk. + """ + if not os.path.isdir(os.path.dirname(MATCH_HISTORY_FILE)): + os.makedirs(os.path.dirname(MATCH_HISTORY_FILE), exist_ok=True) + with open(MATCH_HISTORY_FILE, "w", encoding="utf-8") as f: + json.dump(history, f, indent=2) + +def record_manual_result(deck_name, opponent_name, result): + """ + Append a manual result to match history. 'result' should be "W", "L", or "T". + """ + history = load_match_history() + history.append({"deck": deck_name, "opponent": opponent_name, "result": result}) + save_match_history(history) diff --git a/collection_manager.py b/collection_manager.py index 0dc382c..b8c8989 100644 --- a/collection_manager.py +++ b/collection_manager.py @@ -1,597 +1,38 @@ -# main.py +# collection_manager.py + import os -import io -import requests -import tkinter as tk -from tkinter import ttk, messagebox, simpledialog -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, list_collection -from models import Deck, Card - -# ----------------------------------------------------------------------------- -# Helper to detect lands -# ----------------------------------------------------------------------------- -def is_land(card: Card) -> bool: - return "Land" in card.type_line - -# ----------------------------------------------------------------------------- -# Main Application Window -# ----------------------------------------------------------------------------- -class MTGDeckBuilder(tk.Tk): - def __init__(self): - super().__init__() - self.title("MTG Deck Builder") - self.geometry("1000x650") - - # Ensure necessary folders/files exist - init_cache_db() - _ = load_collection() # ensure collection file/folder - - # Currently loaded Deck (or None) - self.current_deck: Deck | None = None - - # In-memory cache: card_name → Card object (so we don't re-fetch repeatedly) - self.card_cache: dict[str, Card] = {} - - # Hold references to PhotoImage objects so they don’t get garbage-collected - self.preview_photo: ImageTk.PhotoImage | None = None - self.color_icon_images: dict[str, ImageTk.PhotoImage] = {} - - # Build the UI - self._load_color_icons() - self._build_widgets() - self._layout_widgets() - - # ----------------------------------------------------------------------------- - # Pre-load color icon images from local `assets/icons` - # ----------------------------------------------------------------------------- - def _load_color_icons(self): - """ - Expects five PNG files in assets/icons: W.png, U.png, B.png, R.png, G.png - """ - 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((32, 32), Image.LANCZOS) - self.color_icon_images[symbol] = ImageTk.PhotoImage(img) - else: - self.color_icon_images[symbol] = None - - # ----------------------------------------------------------------------------- - # Create all widgets - # ----------------------------------------------------------------------------- - def _build_widgets(self): - # -- Deck Controls ------------------------------------------------------- - self.deck_frame = ttk.LabelFrame(self, text="Deck Controls", padding=10) - self.new_deck_btn = ttk.Button(self.deck_frame, text="New Deck", command=self.new_deck) - self.load_deck_btn = ttk.Button(self.deck_frame, text="Load Deck", command=self.load_deck) - self.save_deck_btn = ttk.Button(self.deck_frame, text="Save Deck", command=self.save_deck) - self.smart_build_btn = ttk.Button(self.deck_frame, text="Smart Build Deck", command=self.smart_build_deck) - self.deck_name_label = ttk.Label(self.deck_frame, text="(no deck loaded)") - - # -- A container for the two main panels (Search vs. Deck) ------------- - self.content_frame = ttk.Frame(self) - - # -- Search / Add Cards ----------------------------------------------- - self.search_frame = ttk.LabelFrame(self.content_frame, text="Search / Add Cards", padding=10) - self.search_entry = ttk.Entry(self.search_frame, width=40) - self.search_btn = ttk.Button(self.search_frame, text="Search", command=self.perform_search) - - self.results_list = tk.Listbox(self.search_frame, height=12, width=50) - self.result_scroll = ttk.Scrollbar(self.search_frame, orient="vertical", command=self.results_list.yview) - self.results_list.configure(yscrollcommand=self.result_scroll.set) - self.results_list.bind("<>", self.on_result_select) - - self.add_qty_label = ttk.Label(self.search_frame, text="Quantity:") - self.add_qty_spin = ttk.Spinbox(self.search_frame, from_=1, to=20, width=5) - self.add_to_deck_btn = ttk.Button(self.search_frame, text="Add to Deck", command=self.add_card_to_deck) - self.add_to_coll_btn = ttk.Button(self.search_frame, text="Add to Collection", command=self.add_card_to_collection) - - # -- Card Preview (image + color icons) -------------------------------- - self.preview_frame = ttk.LabelFrame(self.search_frame, text="Card Preview", padding=10) - self.card_image_label = ttk.Label(self.preview_frame) - self.color_icons_frame = ttk.Frame(self.preview_frame) - - # -- Deck Contents ----------------------------------------------------- - self.deck_view_frame = ttk.LabelFrame(self.content_frame, text="Deck Contents", padding=10) - self.deck_list = tk.Listbox(self.deck_view_frame, height=20, width=40) - self.deck_scroll = ttk.Scrollbar(self.deck_view_frame, orient="vertical", command=self.deck_list.yview) - self.deck_list.configure(yscrollcommand=self.deck_scroll.set) - self.deck_list.bind("<>", self.on_deck_select) - - self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self.remove_selected) - - # -- Collection Controls (view your collection) ------------------------ - self.coll_frame = ttk.LabelFrame(self, text="Collection Controls", padding=10) - self.view_coll_btn = ttk.Button(self.coll_frame, text="View Collection", command=self.view_collection) - - # ----------------------------------------------------------------------------- - # Arrange everything in the proper geometry (pack/grid) - # ----------------------------------------------------------------------------- - def _layout_widgets(self): - # 1) Top: Deck controls (packed) - self.deck_frame.pack(fill="x", padx=10, pady=5) - self.new_deck_btn.grid(row=0, column=0, padx=5, pady=5) - self.load_deck_btn.grid(row=0, column=1, padx=5, pady=5) - self.save_deck_btn.grid(row=0, column=2, padx=5, pady=5) - self.smart_build_btn.grid(row=0, column=3, padx=5, pady=5) - self.deck_name_label.grid(row=0, column=4, padx=10, pady=5, sticky="w") - - # 2) Just below: Collection controls (packed) - self.coll_frame.pack(fill="x", padx=10, pady=(0,5)) - self.view_coll_btn.pack(padx=5, pady=5, anchor="w") - - # 3) Middle: content_frame (packed) - self.content_frame.pack(fill="both", expand=True, padx=10, pady=5) - self.content_frame.columnconfigure(0, weight=1) - self.content_frame.columnconfigure(1, weight=0) - self.content_frame.rowconfigure(0, weight=1) - - # --- Left panel: Search / Add / Collection (gridded inside content_frame) --- - self.search_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) - self.search_frame.columnconfigure(0, weight=1) - self.search_frame.rowconfigure(1, weight=1) - - self.search_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") - self.search_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w") - - self.results_list.grid(row=1, column=0, columnspan=3, padx=(5, 0), pady=5, sticky="nsew") - self.result_scroll.grid(row=1, column=3, sticky="ns", pady=5) - - self.add_qty_label.grid(row=2, column=0, padx=5, pady=5, sticky="w") - self.add_qty_spin.grid(row=2, column=1, padx=5, pady=5, sticky="w") - self.add_to_deck_btn.grid(row=2, column=2, padx=5, pady=5, sticky="w") - self.add_to_coll_btn.grid(row=2, column=3, padx=5, pady=5, sticky="w") - - # Preview frame sits below everything in the search_frame - self.preview_frame.grid(row=3, column=0, columnspan=4, padx=5, pady=10, sticky="nsew") - self.preview_frame.columnconfigure(0, weight=1) - self.preview_frame.rowconfigure(0, weight=1) - - self.card_image_label.grid(row=0, column=0, padx=5, pady=5) - self.color_icons_frame.grid(row=1, column=0, padx=5, pady=5) - - # --- Right panel: Deck Contents (gridded inside content_frame) --- - self.deck_view_frame.grid(row=0, column=1, sticky="nsew") - self.deck_view_frame.columnconfigure(0, weight=1) - self.deck_view_frame.rowconfigure(0, weight=1) - - self.deck_list.grid(row=0, column=0, padx=(5, 0), pady=5, sticky="nsew") - self.deck_scroll.grid(row=0, column=1, sticky="ns", pady=5) - self.remove_card_btn.grid(row=1, column=0, padx=5, pady=5, sticky="w") - - # ----------------------------------------------------------------------------- - # Create a brand-new Deck - # ----------------------------------------------------------------------------- - def 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_list() - self._clear_preview() - - # ----------------------------------------------------------------------------- - # Load a saved Deck from disk - # ----------------------------------------------------------------------------- - def load_deck(self): - choices = list_saved_decks() - if not choices: - messagebox.showinfo("Load Deck", "No saved decks found.") - 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_list() - self._clear_preview() - else: - messagebox.showerror("Error", f"Deck '{name}' not found.") - - # ----------------------------------------------------------------------------- - # Save current Deck to disk - # ----------------------------------------------------------------------------- - def save_deck(self): - if not self.current_deck: - messagebox.showwarning("Save Deck", "No deck loaded.") - return - dm_save_deck(self.current_deck) - messagebox.showinfo("Save Deck", f"Deck '{self.current_deck.name}' saved.") - - # ----------------------------------------------------------------------------- - # Smart-build a 60-card deck based on user’s color choice & archetype - # ----------------------------------------------------------------------------- - def smart_build_deck(self): - # 1) Ask for colors - 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: - messagebox.showerror("Invalid Colors", "You must pick 1–3 of W, U, B, R, G.") - return - - # 2) Ask for archetype - archetype = simpledialog.askstring( - "Smart Build: Archetype", - "Enter archetype (Aggro, Control, Midrange):", - parent=self - ) - if not archetype: - return - archetype = archetype.strip().lower() - if archetype not in {"aggro", "control", "midrange"}: - messagebox.showerror("Invalid Archetype", "Must be 'Aggro', 'Control', or 'Midrange'.") - return - - # 3) Build a name for the new deck - deck_name = f"{archetype.capitalize()} {'/'.join(colors)} Auto" - deck = Deck(name=deck_name) - - # 4) Determine land count & creature/spell counts - # We’ll do a 24-land / 24-spells / 12-utility split. - land_count = 24 - spells_count = 36 # creatures+noncreatures - # For simplicity, creatures vs. noncreature split: - if archetype == "aggro": - creature_target = 24 - noncreature_target = 12 - elif archetype == "midrange": - creature_target = 18 - noncreature_target = 18 - else: # control - creature_target = 12 - noncreature_target = 24 - - # 5) Fetch lands: basic lands for each color, split evenly. - per_color = land_count // len(colors) - extra = land_count % len(colors) - for idx, col in enumerate(colors): - qty = per_color + (1 if idx < extra else 0) - # Basic Land names in MTG: “Plains”, “Island”, “Swamp”, “Mountain”, “Forest” - map_basic = {"W": "Plains", "U": "Island", "B": "Swamp", "R": "Mountain", "G": "Forest"} - land_name = map_basic[col] - deck.add_card(land_name, qty) - - # 6) Fetch creatures using Scryfall search - # Example query: “c:R type:creature cmc<=3” for aggro; adjust for other archetypes. - creature_query = f"c:{''.join(colors)} type:creature" - if archetype == "aggro": - creature_query += " cmc<=3" - elif archetype == "midrange": - creature_query += " cmc<=4" - else: # control - creature_query += " cmc<=5" - - creatures = search_cards(creature_query) - creatures = [c for c in creatures if set(c.colors).issubset(set(colors))] - # Take up to creature_target distinct names - 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 - - # 7) Fetch noncreature spells: instants & sorceries - 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: # control - 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 - - # 8) If we still need cards (e.g., not enough results), pad with any multi-color or colorless - total_cards = sum(deck.cards.values()) - if total_cards < 60: - fill_needed = 60 - total_cards - filler = search_cards("type:creature cmc<=3") # cheap catch-all - 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 - - # 9) Assign to current_deck and refresh UI - self.current_deck = deck - self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)") - self._refresh_deck_list() - self._clear_preview() - messagebox.showinfo("Smart Build Complete", f"Created deck '{deck.name}' with {deck.total_cards()} cards.") - - # ----------------------------------------------------------------------------- - # View your existing Collection in a pop-up window - # ----------------------------------------------------------------------------- - def view_collection(self): - coll = load_collection() - if not coll: - messagebox.showinfo("Collection", "Your collection is empty.") - return - - # Build a Toplevel window - top = tk.Toplevel(self) - top.title("Your Collection") - top.geometry("400x500") - - lbl = ttk.Label(top, text="Card Name – Quantity", font=("TkDefaultFont", 12, "bold")) - lbl.pack(pady=(10,5)) - - listbox = tk.Listbox(top, width=50, height=20) - listbox.pack(fill="both", expand=True, padx=10, pady=5) - - # Sort alphabetically - for name, qty in sorted(coll.items(), key=lambda x: x[0].lower()): - listbox.insert(tk.END, f"{qty}× {name}") - - def remove_selected(): - sel = listbox.curselection() - if not sel: - return - idx = sel[0] - entry = listbox.get(idx) - qty_str, name_part = entry.split("×", 1) - card_name = name_part.strip() - # Remove entirely (or prompt for qty) — here, we remove all copies - del coll[card_name] - save_collection(coll) - listbox.delete(idx) - - btn = ttk.Button(top, text="Remove Selected Card", command=remove_selected) - btn.pack(pady=(5,10)) - - # ----------------------------------------------------------------------------- - # Perform a Scryfall search and populate the listbox - # ----------------------------------------------------------------------------- - def perform_search(self): - query = self.search_entry.get().strip() - if not query: - return - self.results_list.delete(0, tk.END) - results = search_cards(query) - if not results: - self.results_list.insert(tk.END, "(no results)") - return - - # Cache Card objects - for card in results: - self.card_cache[card.name] = card - - for card in results: - display = f"{card.name} • {card.mana_cost or ''} • {card.type_line} [{card.rarity}]" - self.results_list.insert(tk.END, display) - - self._clear_preview() - - # ----------------------------------------------------------------------------- - # When a search result is selected, show color icons + preview image - # ----------------------------------------------------------------------------- - def on_result_select(self, event): - sel = self.results_list.curselection() - if not sel: - return - index = sel[0] - display = self.results_list.get(index) - 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 a given Card’s image + its color icons - # ----------------------------------------------------------------------------- - def _show_preview(self, card: Card): - # 1) Display color icons - for widget in self.color_icons_frame.winfo_children(): - widget.destroy() - - x = 0 - for symbol in card.colors: - icon_img = self.color_icon_images.get(symbol) - if icon_img: - lbl = ttk.Label(self.color_icons_frame, image=icon_img) - lbl.image = icon_img - lbl.grid(row=0, column=x, padx=2) - x += 1 - - # 2) Fetch & display card image - if card.image_url: - try: - response = requests.get(card.image_url, timeout=10) - response.raise_for_status() - img_data = response.content - image = Image.open(io.BytesIO(img_data)) - image.thumbnail((250, 350), Image.LANCZOS) - photo = ImageTk.PhotoImage(image) - self.preview_photo = photo - 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 - - # ----------------------------------------------------------------------------- - # Clear preview panel (both icons & image) - # ----------------------------------------------------------------------------- - def _clear_preview(self): - self.card_image_label.config(image="", text="") - for widget in self.color_icons_frame.winfo_children(): - widget.destroy() - self.preview_photo = None - - # ----------------------------------------------------------------------------- - # Add selected card (with qty) to the current Deck - # ----------------------------------------------------------------------------- - def add_card_to_deck(self): - if not self.current_deck: - messagebox.showwarning("Add Card", "Create or load a deck first.") - return - sel = self.results_list.curselection() - if not sel: - messagebox.showwarning("Add Card", "Select a card from search results.") - return - index = sel[0] - display = self.results_list.get(index) - card_name = display.split(" • ")[0].strip() - - try: - qty = int(self.add_qty_spin.get()) - except ValueError: - messagebox.showerror("Invalid Quantity", "Enter a valid number.") - return - - card = self.card_cache.get(card_name) or get_card_by_name(card_name) - if not card: - messagebox.showerror("Error", f"Card '{card_name}' not found.") - return - self.card_cache[card.name] = card - - self.current_deck.add_card(card.name, qty) - total = self.current_deck.total_cards() - self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)") - self._refresh_deck_list() - - # ----------------------------------------------------------------------------- - # Add selected card (with qty) to your Collection (no deck required) - # ----------------------------------------------------------------------------- - def add_card_to_collection(self): - coll = load_collection() - sel = self.results_list.curselection() - if not sel: - messagebox.showwarning("Add to Collection", "Select a card first.") - return - index = sel[0] - display = self.results_list.get(index) - card_name = display.split(" • ")[0].strip() - - try: - qty = int(self.add_qty_spin.get()) - except ValueError: - messagebox.showerror("Invalid Quantity", "Enter a valid number.") - return - - # Increment in collection - coll[card_name] = coll.get(card_name, 0) + qty - save_collection(coll) - messagebox.showinfo("Collection", f"Added {qty}× '{card_name}' to your collection.") - - # ----------------------------------------------------------------------------- - # When a deck entry is selected, preview its image + colors - # ----------------------------------------------------------------------------- - def on_deck_select(self, event): - sel = self.deck_list.curselection() - if not sel or not self.current_deck: - return - index = sel[0] - entry = self.deck_list.get(index) - # Format: "Qty× CardName ⚠?" or "Qty× CardName" - parts = entry.split("×", 1) - if len(parts) != 2: - return - _, rest = parts - card_name = rest.strip() - if card_name.endswith("⚠"): - card_name = card_name[:-1].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) - - # ----------------------------------------------------------------------------- - # Remove the selected card (all copies) from the deck - # ----------------------------------------------------------------------------- - def remove_selected(self): - if not self.current_deck: - return - sel = self.deck_list.curselection() - if not sel: - return - index = sel[0] - entry = self.deck_list.get(index) - parts = entry.split("×", 1) - if len(parts) != 2: - return - qty_str, rest = parts - try: - qty = int(qty_str.strip()) - except ValueError: - return - card_name = rest.strip() - if card_name.endswith("⚠"): - card_name = card_name[:-1].strip() - - self.current_deck.remove_card(card_name, qty) - total = self.current_deck.total_cards() - self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)") - self._refresh_deck_list() - self._clear_preview() - - # ----------------------------------------------------------------------------- - # Repopulate deck_list, marking non-land duplicates with “⚠” - # ----------------------------------------------------------------------------- - def _refresh_deck_list(self): - self.deck_list.delete(0, tk.END) - if not self.current_deck: - return - 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 - flag = "" - if qty > 1 and not is_land(card): - flag = " ⚠" - display = f"{qty}× {card.name}{flag}" - else: - display = f"{qty}× {name}" - self.deck_list.insert(tk.END, display) - -# ----------------------------------------------------------------------------- -# Launch the app -# ----------------------------------------------------------------------------- -if __name__ == "__main__": - # Warn if color icons are missing - missing = [s for s in ["W","U","B","R","G"] if not os.path.isfile(os.path.join("assets","icons",f"{s}.png"))] - if missing: - print(f"Warning: Missing color icon(s) for {missing} in assets/icons/. Cards will still load.") - app = MTGDeckBuilder() - app.mainloop() +import json + +COLLECTION_FILE = os.path.join("data", "collection.json") + +def load_collection() -> dict[str, int]: + """ + Returns a dict of card_name → quantity in your collection. + If the file doesn’t exist, returns an empty dict. + """ + if not os.path.isdir(os.path.dirname(COLLECTION_FILE)): + os.makedirs(os.path.dirname(COLLECTION_FILE), exist_ok=True) + if not os.path.isfile(COLLECTION_FILE): + return {} + try: + with open(COLLECTION_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return {str(k): int(v) for k, v in data.items()} + except json.JSONDecodeError: + return {} + +def save_collection(collection: dict[str, int]) -> None: + """ + Writes your collection (card_name → quantity) to disk. + """ + if not os.path.isdir(os.path.dirname(COLLECTION_FILE)): + os.makedirs(os.path.dirname(COLLECTION_FILE), exist_ok=True) + with open(COLLECTION_FILE, "w", encoding="utf-8") as f: + json.dump(collection, f, indent=2) + +def list_collection() -> list[tuple[str, int]]: + """ + Returns a sorted list of (card_name, qty) from your collection. + """ + coll = load_collection() + return sorted(coll.items(), key=lambda x: x[0].lower()) diff --git a/data/cards_cache.sqlite b/data/cards_cache.sqlite index f4e6e63f2e376951fa2cb08aa5d728c61bd9f51d..0aa153694da087c3f68d4f6f955cfeeb81f410db 100644 GIT binary patch literal 65536 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 diff --git a/main.py b/main.py index f04087a..476098b 100644 --- a/main.py +++ b/main.py @@ -2,13 +2,15 @@ import os import io import requests -import sqlite3 +import winsound import tkinter as tk from tkinter import ttk, messagebox, simpledialog 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 # ----------------------------------------------------------------------------- @@ -17,6 +19,14 @@ from models import Deck, Card def is_land(card: Card) -> bool: return "Land" in card.type_line +# ----------------------------------------------------------------------------- +# Play a custom WAV whenever you want (falls back if missing) +# ----------------------------------------------------------------------------- +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) + # ----------------------------------------------------------------------------- # Main Application Window # ----------------------------------------------------------------------------- @@ -24,151 +34,270 @@ class MTGDeckBuilder(tk.Tk): def __init__(self): super().__init__() self.title("MTG Deck Builder") - self.geometry("1000x600") + self.geometry("1200x750") - # Ensure cache DB exists + # Ensure necessary folders/files exist init_cache_db() + _ = load_collection() # ensure collection file + _ = load_match_history() # ensure match history file + + # Track theme: "dark" or "light" + self.theme = tk.StringVar(value="dark") # Currently loaded Deck self.current_deck: Deck | None = None - # Local in‐memory cache: card_name → Card object + # In-memory cache: card_name → Card object self.card_cache: dict[str, Card] = {} - # Keep references to PhotoImage so they do not get garbage‐collected + # Keep references to PhotoImage to avoid garbage-collection + self.thumbnail_images: dict[str, ImageTk.PhotoImage] = {} self.preview_photo: ImageTk.PhotoImage | None = None self.color_icon_images: dict[str, ImageTk.PhotoImage] = {} - # Build UI components + # Build & layout UI self._load_color_icons() + self._load_sounds() self._build_widgets() self._layout_widgets() + self.apply_theme() # start in VSCode-style dark # ----------------------------------------------------------------------------- - # Pre‐load color icon images from local `assets/icons` + # Pre-load color icons (W/U/B/R/G) # ----------------------------------------------------------------------------- def _load_color_icons(self): - """ - Expects five PNG files in assets/icons: W.png, U.png, B.png, R.png, G.png - """ 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((32, 32), Image.LANCZOS) + img = Image.open(path).resize((20, 20), Image.LANCZOS) self.color_icon_images[symbol] = ImageTk.PhotoImage(img) else: - self.color_icon_images[symbol] = None # missing icon silently + self.color_icon_images[symbol] = None + + # ----------------------------------------------------------------------------- + # Ensure sound folder exists + # ----------------------------------------------------------------------------- + def _load_sounds(self): + sound_folder = os.path.join("assets", "sounds") + os.makedirs(sound_folder, exist_ok=True) + # Place click.wav and error.wav there if you have them. # ----------------------------------------------------------------------------- # Create all widgets # ----------------------------------------------------------------------------- def _build_widgets(self): - # -- Deck Controls ------------------------------------------------------- - self.deck_frame = ttk.LabelFrame(self, text="Deck Controls", padding=10) - self.new_deck_btn = ttk.Button(self.deck_frame, text="New Deck", command=self.new_deck) - self.load_deck_btn = ttk.Button(self.deck_frame, text="Load Deck", command=self.load_deck) - self.save_deck_btn = ttk.Button(self.deck_frame, text="Save Deck", command=self.save_deck) + # --- 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 + ) - # -- A container for the two main panels (Search and Deck) ------------- - self.content_frame = ttk.Frame(self) + # --- 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.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) - # -- Search / Add Cards ----------------------------------------------- - self.search_frame = ttk.LabelFrame(self.content_frame, text="Search / Add Cards", padding=10) - self.search_entry = ttk.Entry(self.search_frame, width=40) - self.search_btn = ttk.Button(self.search_frame, text="Search", command=self.perform_search) - - self.results_list = tk.Listbox(self.search_frame, height=12, width=50) - self.result_scroll = ttk.Scrollbar(self.search_frame, orient="vertical", command=self.results_list.yview) - self.results_list.configure(yscrollcommand=self.result_scroll.set) - self.results_list.bind("<>", self.on_result_select) - - self.add_qty_label = ttk.Label(self.search_frame, text="Quantity:") + # --- Right side: Search panel + Deck panel + Preview --- + self.right_frame = ttk.Frame(self) + # Search / Add Cards + self.search_frame = ttk.LabelFrame(self.right_frame, text="Search / Add Cards", padding=8) + self.search_entry = ttk.Entry(self.search_frame, width=30) + self.search_btn = ttk.Button(self.search_frame, text="Search", command=self._on_perform_search) + self.search_entry.bind("", lambda e: 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("<>", self._on_result_select) + self.add_qty_label = ttk.Label(self.search_frame, text="Qty:") self.add_qty_spin = ttk.Spinbox(self.search_frame, from_=1, to=20, width=5) - self.add_card_btn = ttk.Button(self.search_frame, text="Add to Deck", command=self.add_card_to_deck) + self.add_qty_spin.set("1") + # Swap these two so that Add to Collection is on the left + self.add_to_coll_btn = ttk.Button(self.search_frame, text="Add to Collection", command=self._on_add_to_collection) + self.add_to_deck_btn = ttk.Button(self.search_frame, text="Add to Deck", command=self._on_add_to_deck) - # -- Card Preview (image + color icons) -------------------------------- - self.preview_frame = ttk.LabelFrame(self.search_frame, text="Card Preview", padding=10) - # Label to show the card image + # 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.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 + tree.bind("<>", self._on_deck_select) + self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self._on_remove_selected) + + # Card preview (bottom) + self.preview_frame = ttk.LabelFrame(self, text="Card Preview", padding=8) self.card_image_label = ttk.Label(self.preview_frame) - # Frame to hold 0–5 color icons horizontally self.color_icons_frame = ttk.Frame(self.preview_frame) - # -- Deck Contents ----------------------------------------------------- - self.deck_view_frame = ttk.LabelFrame(self.content_frame, text="Deck Contents", padding=10) - self.deck_list = tk.Listbox(self.deck_view_frame, height=20, width=40) - self.deck_scroll = ttk.Scrollbar(self.deck_view_frame, orient="vertical", command=self.deck_list.yview) - self.deck_list.configure(yscrollcommand=self.deck_scroll.set) - self.deck_list.bind("<>", self.on_deck_select) - - self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self.remove_selected) - # ----------------------------------------------------------------------------- - # Arrange everything in the proper geometry + # Arrange everything with pack() and grid() # ----------------------------------------------------------------------------- def _layout_widgets(self): - # 1) Top ─ Deck controls (packed) - self.deck_frame.pack(fill="x", padx=10, pady=5) - self.new_deck_btn.grid(row=0, column=0, padx=5, pady=5) - self.load_deck_btn.grid(row=0, column=1, padx=5, pady=5) - self.save_deck_btn.grid(row=0, column=2, padx=5, pady=5) - self.deck_name_label.grid(row=0, column=3, padx=10, pady=5, sticky="w") + # --- 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") - # 2) Middle ─ content_frame (packed) - self.content_frame.pack(fill="both", expand=True, padx=10, pady=5) - self.content_frame.columnconfigure(0, weight=1) - self.content_frame.columnconfigure(1, weight=0) - self.content_frame.rowconfigure(0, weight=1) + # --- Collection panel (left) --- + self.coll_frame.pack(fill="y", side="left", padx=(10,5), pady=5) + self.coll_frame.configure(width=250) + self.coll_notebook.pack(fill="both", expand=True, padx=4, pady=4) + self.remove_from_coll_btn.pack(fill="x", padx=4, pady=(4,10)) - # --- Left panel: Search / Add Cards (gridded inside content_frame) --- - self.search_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) + # --- Right side: search + deck --- + 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 + self.search_frame.grid(row=0, column=0, sticky="nsew", padx=(0,5)) self.search_frame.columnconfigure(0, weight=1) self.search_frame.rowconfigure(1, weight=1) - self.search_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") - self.search_btn.grid(row=0, column=1, padx=5, pady=5, sticky="w") + 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") - self.results_list.grid(row=1, column=0, columnspan=2, padx=(5, 0), pady=5, sticky="nsew") - self.result_scroll.grid(row=1, column=2, sticky="ns", pady=5) + self.results_tree.grid(row=1, column=0, columnspan=2, padx=(4,0), pady=4, sticky="nsew") + self.results_scroll.grid(row=1, column=2, sticky="ns", pady=4) - self.add_qty_label.grid(row=2, column=0, padx=5, pady=5, sticky="w") - self.add_qty_spin.grid(row=2, column=1, padx=5, pady=5, sticky="w") - self.add_card_btn.grid(row=2, column=2, padx=5, pady=5, sticky="w") + self.add_qty_label.grid(row=2, column=0, padx=4, pady=(4,10), sticky="w") + self.add_qty_spin.grid(row=2, column=1, padx=4, pady=(4,10), sticky="w") + self.add_to_coll_btn.grid(row=2, column=2, padx=4, pady=(4,10), sticky="w") + self.add_to_deck_btn.grid(row=2, column=3, padx=4, pady=(4,10), sticky="w") - # Preview frame sits below everything in the search_frame - self.preview_frame.grid(row=3, column=0, columnspan=3, padx=5, pady=10, sticky="nsew") - self.preview_frame.columnconfigure(0, weight=1) - self.preview_frame.rowconfigure(0, weight=1) - - self.card_image_label.grid(row=0, column=0, padx=5, pady=5) - self.color_icons_frame.grid(row=1, column=0, padx=5, pady=5) - - # --- Right panel: Deck Contents (gridded inside content_frame) --- - self.deck_view_frame.grid(row=0, column=1, sticky="nsew") + # Deck panel + self.deck_view_frame.grid(row=0, column=1, sticky="nsew", padx=(5,0)) self.deck_view_frame.columnconfigure(0, weight=1) self.deck_view_frame.rowconfigure(0, weight=1) - self.deck_list.grid(row=0, column=0, padx=(5, 0), pady=5, sticky="nsew") - self.deck_scroll.grid(row=0, column=1, sticky="ns", pady=5) - self.remove_card_btn.grid(row=1, column=0, padx=5, pady=5, sticky="w") + self.deck_notebook.pack(fill="both", expand=True, padx=4, pady=4) + self.remove_card_btn.pack(fill="x", padx=4, pady=(4,10)) + + # Preview panel (bottom) + self.preview_frame.pack(fill="x", padx=10, pady=(0,10)) + self.preview_frame.columnconfigure(0, weight=1) + self.preview_frame.rowconfigure(0, weight=1) + self.card_image_label.grid(row=0, column=0, padx=4, pady=4) + self.color_icons_frame.grid(row=1, column=0, padx=4, pady=(4,8)) + + # Populate both Collection and Deck initially + self._refresh_collection() + self._refresh_deck() # ----------------------------------------------------------------------------- - # Create a brand‐new Deck + # Apply either “VSCode Dark+” or Light theme # ----------------------------------------------------------------------------- - def new_deck(self): + def apply_theme(self): + mode = self.theme.get() # "dark" or "light" + style = ttk.Style() + style.theme_use("clam") + + if mode == "dark": + # VSCode Dark+ approximate palette + bg = "#1e1e1e" + fg = "#d4d4d4" + panel = "#252526" + entry_bg = "#3c3c3c" + entry_fg = "#d4d4d4" + select_bg = "#264f78" + btn_bg = "#0e639c" + btn_fg = "#ffffff" + else: + # Standard light + bg = "#ffffff" + fg = "#000000" + panel = "#f0f0f0" + entry_bg = "#ffffff" + entry_fg = "#000000" + select_bg = "#cce5ff" + btn_bg = "#007acc" + btn_fg = "#ffffff" + + # Configure styles + 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=36) + 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) + + # Re-color all frames + 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") + + # ----------------------------------------------------------------------------- + # “New Deck” callback + # ----------------------------------------------------------------------------- + def _on_new_deck(self): + play_sound("click") 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_list() + self._refresh_deck() self._clear_preview() # ----------------------------------------------------------------------------- - # Load a saved Deck from disk + # “Load Deck” callback # ----------------------------------------------------------------------------- - def load_deck(self): + def _on_load_deck(self): + play_sound("click") choices = list_saved_decks() if not choices: messagebox.showinfo("Load Deck", "No saved decks found.") @@ -185,15 +314,17 @@ class MTGDeckBuilder(tk.Tk): if deck: self.current_deck = deck self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)") - self._refresh_deck_list() + self._refresh_deck() self._clear_preview() else: + play_sound("error") messagebox.showerror("Error", f"Deck '{name}' not found.") # ----------------------------------------------------------------------------- - # Save current Deck to disk + # “Save Deck” callback # ----------------------------------------------------------------------------- - def save_deck(self): + def _on_save_deck(self): + play_sound("click") if not self.current_deck: messagebox.showwarning("Save Deck", "No deck loaded.") return @@ -201,40 +332,259 @@ class MTGDeckBuilder(tk.Tk): messagebox.showinfo("Save Deck", f"Deck '{self.current_deck.name}' saved.") # ----------------------------------------------------------------------------- - # Perform a Scryfall search and populate the listbox + # “Smart Build Deck” callback # ----------------------------------------------------------------------------- - def perform_search(self): + def _on_smart_build(self): + play_sound("click") + 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.") + + # ----------------------------------------------------------------------------- + # Refresh the entire collection (all tabs) + # ----------------------------------------------------------------------------- + def _refresh_collection(self): + coll = load_collection() + # Prepare a dict: tab_name → list of (name, qty) + buckets = {tn: [] for tn in self.coll_trees} + for name, qty in coll.items(): + # Fetch Card object (cache or API) + 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 + + # Every card goes into "All" + buckets["All"].append((name, qty)) + + # Color-specific tabs + for col, tab in [("B", "Black"), ("W", "White"), + ("R", "Red"), ("G", "Green"), ("U", "Blue")]: + if col in colors: + buckets[tab].append((name, qty)) + + # Unmarked = no colors + if not colors and not is_token: + buckets["Unmarked"].append((name, qty)) + + # Tokens tab + if is_token: + buckets["Tokens"].append((name, qty)) + + # Now update each Treeview + for tab_name, tree in self.coll_trees.items(): + tree.delete(*tree.get_children()) + for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())): + display = f"{qty}× {card_name}" + tree.insert("", "end", iid=str(idx), text=display) + + # ----------------------------------------------------------------------------- + # “Remove from Collection” callback + # ----------------------------------------------------------------------------- + def _on_remove_from_collection(self): + play_sound("click") + # Determine which tab is currently selected + 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, 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() + + # ----------------------------------------------------------------------------- + # “Search” callback + # ----------------------------------------------------------------------------- + def _on_perform_search(self): + play_sound("click") query = self.search_entry.get().strip() if not query: return - self.results_list.delete(0, tk.END) + + self.results_tree.delete(*self.results_tree.get_children()) + self.thumbnail_images.clear() + results = search_cards(query) if not results: - self.results_list.insert(tk.END, "(no results)") + messagebox.showinfo("Search", "No cards found.") return - # Cache Card objects in memory - for card in results: + for idx, card in enumerate(results): self.card_cache[card.name] = card + thumb = None + if card.thumbnail_url: + try: + resp = requests.get(card.thumbnail_url, timeout=5) + resp.raise_for_status() + img = Image.open(io.BytesIO(resp.content)) + img.thumbnail((40, 60), Image.LANCZOS) + thumb = ImageTk.PhotoImage(img) + except Exception: + thumb = None - for card in results: - display = f"{card.name} • {card.mana_cost or ''} • {card.type_line} [{card.rarity}]" - self.results_list.insert(tk.END, display) + display_text = f"{card.name} ● {card.mana_cost or ''} ● {card.type_line} [{card.rarity}]" + if thumb: + self.thumbnail_images[card.name] = thumb + self.results_tree.insert( + "", "end", iid=str(idx), + text=display_text, image=thumb + ) + else: + self.results_tree.insert( + "", "end", iid=str(idx), + text=display_text + ) - # Clear any old preview self._clear_preview() # ----------------------------------------------------------------------------- - # When a search result is selected, show color icons + preview image + # When a search result is selected → preview it # ----------------------------------------------------------------------------- - def on_result_select(self, event): - sel = self.results_list.curselection() + def _on_result_select(self, event): + sel = self.results_tree.selection() if not sel: return - index = sel[0] - display = self.results_list.get(index) - # Extract card name (everything before first " • ") - card_name = display.split(" • ")[0].strip() + 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: @@ -243,30 +593,27 @@ class MTGDeckBuilder(tk.Tk): self._show_preview(card) # ----------------------------------------------------------------------------- - # Show a given Card’s image + its color icons + # Show full image + color pips in preview # ----------------------------------------------------------------------------- def _show_preview(self, card: Card): - # 1) Display color icons - for widget in self.color_icons_frame.winfo_children(): - widget.destroy() + for w in self.color_icons_frame.winfo_children(): + w.destroy() x = 0 for symbol in card.colors: - icon_img = self.color_icon_images.get(symbol) - if icon_img: - lbl = ttk.Label(self.color_icons_frame, image=icon_img) - lbl.image = icon_img + 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 - # 2) Fetch & display card image if card.image_url: try: - response = requests.get(card.image_url, timeout=10) - response.raise_for_status() - img_data = response.content + resp = requests.get(card.image_url, timeout=10) + resp.raise_for_status() + img_data = resp.content image = Image.open(io.BytesIO(img_data)) - # Resize to fit in ~250×350 area, preserving aspect ratio image.thumbnail((250, 350), Image.LANCZOS) photo = ImageTk.PhotoImage(image) self.preview_photo = photo @@ -279,61 +626,81 @@ class MTGDeckBuilder(tk.Tk): self.preview_photo = None # ----------------------------------------------------------------------------- - # Clear preview panel (both icons & image) + # “Add to Deck” callback # ----------------------------------------------------------------------------- - def _clear_preview(self): - self.card_image_label.config(image="", text="") - for widget in self.color_icons_frame.winfo_children(): - widget.destroy() - self.preview_photo = None - - # ----------------------------------------------------------------------------- - # Add the currently highlighted search‐result card into the deck - # ----------------------------------------------------------------------------- - def add_card_to_deck(self): + def _on_add_to_deck(self): + play_sound("click") if not self.current_deck: messagebox.showwarning("Add Card", "Create or load a deck first.") return - sel = self.results_list.curselection() + sel = self.results_tree.selection() if not sel: messagebox.showwarning("Add Card", "Select a card from search results.") return - index = sel[0] - display = self.results_list.get(index) - card_name = display.split(" • ")[0].strip() + iid = sel[0] + display = self.results_tree.item(iid, "text") + card_name = display.split(" ● ")[0].strip() try: qty = int(self.add_qty_spin.get()) - except ValueError: - messagebox.showerror("Invalid Quantity", "Enter a valid number.") - return + 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: + play_sound("error") messagebox.showerror("Error", f"Card '{card_name}' not found.") return self.card_cache[card.name] = card self.current_deck.add_card(card.name, qty) - total = self.current_deck.total_cards() - self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)") - self._refresh_deck_list() + self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)") + self._refresh_deck() # ----------------------------------------------------------------------------- - # When a deck entry is selected, also preview its image + colors + # “Add to Collection” callback # ----------------------------------------------------------------------------- - def on_deck_select(self, event): - sel = self.deck_list.curselection() + def _on_add_to_collection(self): + play_sound("click") + coll = load_collection() + sel = self.results_tree.selection() + if not sel: + messagebox.showwarning("Add to Collection", "Select a card first.") + 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() + messagebox.showinfo("Collection", f"Added {qty}× '{card_name}' to your collection.") + + # ----------------------------------------------------------------------------- + # “Deck” selection callback → preview + # ----------------------------------------------------------------------------- + def _on_deck_select(self, event): + # Determine which tab is selected + 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 - index = sel[0] - entry = self.deck_list.get(index) - # Format is "Qty× CardName [⚠]" or "Qty× CardName" - parts = entry.split("×", 1) + iid = sel[0] + display = tree.item(iid, "text") + parts = display.split("×", 1) if len(parts) != 2: return - _, rest = parts - card_name = rest.strip() + card_name = parts[1].strip() if card_name.endswith("⚠"): card_name = card_name[:-1].strip() @@ -344,65 +711,181 @@ class MTGDeckBuilder(tk.Tk): self._show_preview(card) # ----------------------------------------------------------------------------- - # Remove the selected card (all copies) from the deck + # “Remove Selected” from deck callback # ----------------------------------------------------------------------------- - def remove_selected(self): - if not self.current_deck: + def _on_remove_selected(self): + play_sound("click") + 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 - sel = self.deck_list.curselection() - if not sel: - return - index = sel[0] - entry = self.deck_list.get(index) - parts = entry.split("×", 1) + iid = sel[0] + display = tree.item(iid, "text") + parts = display.split("×", 1) if len(parts) != 2: return - qty_str, rest = parts try: - qty = int(qty_str.strip()) + qty = int(parts[0].strip()) except ValueError: return - card_name = rest.strip() + card_name = parts[1].strip() if card_name.endswith("⚠"): card_name = card_name[:-1].strip() - # Remove that many copies (which usually removes it entirely) self.current_deck.remove_card(card_name, qty) - total = self.current_deck.total_cards() - self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({total} cards)") - self._refresh_deck_list() + self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)") + self._refresh_deck() self._clear_preview() # ----------------------------------------------------------------------------- - # Repopulate deck_list, showing "⚠" next to any non‐land with qty > 1 + # Refresh deck tabs # ----------------------------------------------------------------------------- - def _refresh_deck_list(self): - self.deck_list.delete(0, tk.END) + def _refresh_deck(self): if not self.current_deck: + for tree in self.deck_trees.values(): + tree.delete(*tree.get_children()) return + + # Prepare buckets similar to collection + 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 - flag = "" - if qty > 1 and not is_land(card): - flag = " ⚠" - display = f"{qty}× {card.name}{flag}" + colors = card.colors + is_token = "Token" in card.type_line else: - display = f"{qty}× {name}" - self.deck_list.insert(tk.END, display) + 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)) + + # Update each deck Treeview + for tab_name, tree in self.deck_trees.items(): + tree.delete(*tree.get_children()) + for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())): + card = self.card_cache.get(card_name) + flag = "" + if card and qty > 1 and not is_land(card): + flag = " ⚠" + display = f"{qty}× {card_name}{flag}" + tree.insert("", "end", iid=str(idx), text=display) + + # ----------------------------------------------------------------------------- + # 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 + + # ----------------------------------------------------------------------------- + # “Simulate Battle” callback + # ----------------------------------------------------------------------------- + def _on_simulate_battle(self): + play_sound("click") + choices = list_saved_decks() + if len(choices) < 2: + messagebox.showinfo("Simulate Battle", "Need at least two saved decks.") + 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: + play_sound("error") + messagebox.showerror("Error", f"Deck '{d1}' not found.") + 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: + play_sound("error") + messagebox.showerror("Error", f"Deck '{d2}' not found.") + return + + wins1, wins2, ties = simulate_match(deck1, deck2, iterations=1000) + msg = (f"Simulation results (1000 games):\n\n" + f"{d1} wins: {wins1}\n" + f"{d2} wins: {wins2}\n" + f"Ties: {ties}") + messagebox.showinfo("Simulation Complete", msg) + + # ----------------------------------------------------------------------------- + # “Record Result” callback + # ----------------------------------------------------------------------------- + def _on_record_result(self): + play_sound("click") + choices = list_saved_decks() + if not choices: + messagebox.showinfo("Record Result", "No saved decks to record.") + 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()) + messagebox.showinfo("Record Result", f"Recorded {result.upper()} for '{deck_name}' vs '{opponent}'.") # ----------------------------------------------------------------------------- # Launch the app # ----------------------------------------------------------------------------- if __name__ == "__main__": - # Warn if icons folder is missing any color PNG - missing = [] - for sym in ["W", "U", "B", "R", "G"]: - if not os.path.isfile(os.path.join("assets", "icons", f"{sym}.png")): - missing.append(sym) - if missing: - print(f"Warning: Missing color icon(s) for {missing} in assets/icons/.") - print("The rest of the GUI will still load, but those icons won't appear.") + 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() diff --git a/models.py b/models.py index a5b0b47..f710b75 100644 --- a/models.py +++ b/models.py @@ -4,7 +4,7 @@ from typing import Optional, List @dataclass class Card: - """Represents an MTG card (subset of Scryfall’s data), including color identity.""" + """Represents an MTG card (subset of Scryfall’s data), including both full image and thumbnail.""" id: str name: str mana_cost: Optional[str] @@ -12,11 +12,13 @@ class Card: oracle_text: Optional[str] set_name: str rarity: str - image_url: Optional[str] - colors: List[str] # e.g. ["R"], ["W","U"], or [] for colorless + image_url: Optional[str] # “normal” size or better + thumbnail_url: Optional[str] # small thumbnail + colors: List[str] # e.g. ["R"], ["W","U"], or [] for colorless @classmethod def from_scryfall_json(cls, data: dict) -> "Card": + image_uris = data.get("image_uris", {}) or {} return cls( id=data["id"], name=data["name"], @@ -25,7 +27,8 @@ class Card: oracle_text=data.get("oracle_text"), set_name=data["set_name"], rarity=data["rarity"], - image_url=data.get("image_uris", {}).get("normal"), + image_url=image_uris.get("normal") , + thumbnail_url=image_uris.get("small"), colors=data.get("colors", []), )