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 f4e6e63..0aa1536 100644 Binary files a/data/cards_cache.sqlite and b/data/cards_cache.sqlite differ 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", []), )