From 8eba39cca7fa0e80814a4f4339df62bf45f7f9e0 Mon Sep 17 00:00:00 2001 From: rgriffith Date: Thu, 19 Mar 2026 10:01:51 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20precon=20picker=20empty=20=E2=80=94=20mu?= =?UTF-8?q?tate=20PRECON=5FINDEX=20in-place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PRECON_INDEX list was imported by value in routes/shared.py. When download_precon_database() or load_precon_index() reassigned it with 'PRECON_INDEX = ...', the new list object was only visible in precon_service module scope — shared.PRECON_INDEX (and thus the /api/lab/precons endpoint) still pointed to the original empty []. Fix: use PRECON_INDEX.clear() + .extend() instead of reassignment so all modules sharing the same list reference see the updated data. --- services/precon_service.py | 298 +++++++++++++++++++------------------ 1 file changed, 156 insertions(+), 142 deletions(-) diff --git a/services/precon_service.py b/services/precon_service.py index 38ede0d..909ef64 100644 --- a/services/precon_service.py +++ b/services/precon_service.py @@ -1,142 +1,156 @@ -"""Precon deck download, caching, and conversion logic.""" -from __future__ import annotations - -import json -import logging -import os -import re -import time -from collections import Counter -from pathlib import Path -from urllib.request import urlopen, Request - -log = logging.getLogger("commander_ai_lab.api") - -# Fallback default — used only when CFG.precon_dir has not been resolved yet. -_DEFAULT_PRECON_DIR = Path(__file__).parent.parent / "precon-decks" - -PRECON_INDEX: list[dict] = [] -GITHUB_PRECON_URL = ( - "https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/" - "master/decks_v2.json" -) -PRECON_CACHE_HOURS = 168 - - -def _get_precon_dir() -> Path: - """Return the active precon-decks directory from CFG, with fallback.""" - from models.state import CFG - if CFG.precon_dir: - return Path(CFG.precon_dir) - return _DEFAULT_PRECON_DIR - - -# Re-export for backwards compat — callers that import PRECON_DIR at module -# level will get the default. Runtime code should call _get_precon_dir(). -PRECON_DIR = _DEFAULT_PRECON_DIR - - -def load_precon_index(): - global PRECON_INDEX - precon_dir = _get_precon_dir() - idx_path = precon_dir / "precon-index.json" - if idx_path.exists(): - with open(idx_path, "r", encoding="utf-8") as f: - PRECON_INDEX = json.load(f) - log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded") - else: - PRECON_INDEX = [] - log.info(f" Precons: index not found at {idx_path}") - - -def _sanitize_filename(name: str) -> str: - safe = re.sub(r'[<>:"/\\|?*]', '', name) - safe = safe.replace(' ', '_').replace("'", '').replace('!', '') - return re.sub(r'_+', '_', safe).strip('_') - - -def _deck_to_dck(deck_data: dict) -> str: - lines = ["[metadata]", f"Name={deck_data['name']}"] - commanders = deck_data.get('commander', []) - if commanders: - lines.append("[Commander]") - for card in commanders: - lines.append(f"{card.get('count', 1)} {card['name']}") - cards = deck_data.get('cards', []) - if cards: - lines.append("[Main]") - for card in cards: - lines.append(f"{card.get('count', 1)} {card['name']}") - sideboard = deck_data.get('sideboard', []) - if sideboard: - lines.append("[Sideboard]") - for card in sideboard: - lines.append(f"{card.get('count', 1)} {card['name']}") - return "\n".join(lines) + "\n" - - -def download_precon_database(force: bool = False) -> dict: - global PRECON_INDEX - precon_dir = _get_precon_dir() - idx_path = precon_dir / "precon-index.json" - if not force and idx_path.exists(): - try: - with open(idx_path, "r", encoding="utf-8") as f: - existing = json.load(f) - if len(existing) > 50: - age_hours = (time.time() - idx_path.stat().st_mtime) / 3600 - if age_hours < PRECON_CACHE_HOURS: - log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)") - PRECON_INDEX = existing - return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None} - except Exception: - pass - log.info(" Precons: Downloading full precon database from GitHub...") - try: - req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"}) - with urlopen(req, timeout=120) as resp: - all_decks = json.loads(resp.read()) - except Exception as e: - msg = f"Failed to download precon database: {e}" - log.error(f" Precons: ERROR - {msg}") - if idx_path.exists(): - load_precon_index() - return {"downloaded": 0, "skipped": False, "error": msg} - commander_decks = [ - d for d in all_decks - if d.get('type') == 'Commander Deck' - and (d.get('format') or '').lower() == 'commander' - ] - log.info(f" Precons: Found {len(commander_decks)} Commander precon decks") - precon_dir.mkdir(parents=True, exist_ok=True) - name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks) - dup_names = {n for n, c in name_counts.items() if c > 1} - index = [] - written = 0 - for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))): - safe_name = _sanitize_filename(deck['name']) - if safe_name in dup_names: - sc = (deck.get('set_code') or 'unk').upper() - safe_name = f"{safe_name}_{sc}" - file_name = f"{safe_name}.dck" - dck_path = precon_dir / file_name - commanders = deck.get('commander', []) - cmdr_names = [c['name'] for c in commanders] if commanders else [] - total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders) - with open(dck_path, "w", encoding="utf-8") as f: - f.write(_deck_to_dck(deck)) - written += 1 - release = deck.get('release_date', '') - year = int(release[:4]) if release and len(release) >= 4 else 0 - index.append({ - "name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown", - "commanders": cmdr_names, "colors": [], - "set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''), - "year": year, "releaseDate": release, "theme": "", - "fileName": file_name, "cardCount": total_cards, - }) - with open(idx_path, "w", encoding="utf-8") as f: - json.dump(index, f, indent=2, ensure_ascii=False) - PRECON_INDEX = index - log.info(f" Precons: {written} .dck files written, index saved") - return {"downloaded": written, "skipped": False, "total": written, "error": None} +"""Precon deck download, caching, and conversion logic.""" +from __future__ import annotations + +import json +import logging +import os +import re +import time +from collections import Counter +from pathlib import Path +from urllib.request import urlopen, Request + +log = logging.getLogger("commander_ai_lab.api") + +# Fallback default — used only when CFG.precon_dir has not been resolved yet. +_DEFAULT_PRECON_DIR = Path(__file__).parent.parent / "precon-decks" + +PRECON_INDEX: list[dict] = [] +GITHUB_PRECON_URL = ( + "https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/" + "master/decks_v2.json" +) +PRECON_CACHE_HOURS = 168 + + +def _get_precon_dir() -> Path: + """Return the active precon-decks directory from CFG, with fallback.""" + from models.state import CFG + if CFG.precon_dir: + return Path(CFG.precon_dir) + return _DEFAULT_PRECON_DIR + + +# Re-export for backwards compat — callers that import PRECON_DIR at module +# level will get the default. Runtime code should call _get_precon_dir(). +PRECON_DIR = _DEFAULT_PRECON_DIR + + +def load_precon_index(): + """Load precon index from disk into the shared PRECON_INDEX list. + + We mutate the list in-place (clear + extend) so that any module that + captured a reference via ``from precon_service import PRECON_INDEX`` + will see the updated contents. + """ + precon_dir = _get_precon_dir() + idx_path = precon_dir / "precon-index.json" + if idx_path.exists(): + with open(idx_path, "r", encoding="utf-8") as f: + data = json.load(f) + PRECON_INDEX.clear() + PRECON_INDEX.extend(data) + log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded") + else: + PRECON_INDEX.clear() + log.info(f" Precons: index not found at {idx_path}") + + +def _sanitize_filename(name: str) -> str: + safe = re.sub(r'[<>:"/\\|?*]', '', name) + safe = safe.replace(' ', '_').replace("'", '').replace('!', '') + return re.sub(r'_+', '_', safe).strip('_') + + +def _deck_to_dck(deck_data: dict) -> str: + lines = ["[metadata]", f"Name={deck_data['name']}"] + commanders = deck_data.get('commander', []) + if commanders: + lines.append("[Commander]") + for card in commanders: + lines.append(f"{card.get('count', 1)} {card['name']}") + cards = deck_data.get('cards', []) + if cards: + lines.append("[Main]") + for card in cards: + lines.append(f"{card.get('count', 1)} {card['name']}") + sideboard = deck_data.get('sideboard', []) + if sideboard: + lines.append("[Sideboard]") + for card in sideboard: + lines.append(f"{card.get('count', 1)} {card['name']}") + return "\n".join(lines) + "\n" + + +def download_precon_database(force: bool = False) -> dict: + """Download precon database from GitHub and populate PRECON_INDEX. + + We mutate the list in-place (clear + extend) so that any module that + captured a reference via ``from precon_service import PRECON_INDEX`` + will see the updated contents. + """ + precon_dir = _get_precon_dir() + idx_path = precon_dir / "precon-index.json" + if not force and idx_path.exists(): + try: + with open(idx_path, "r", encoding="utf-8") as f: + existing = json.load(f) + if len(existing) > 50: + age_hours = (time.time() - idx_path.stat().st_mtime) / 3600 + if age_hours < PRECON_CACHE_HOURS: + log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)") + PRECON_INDEX.clear() + PRECON_INDEX.extend(existing) + return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None} + except Exception: + pass + log.info(" Precons: Downloading full precon database from GitHub...") + try: + req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"}) + with urlopen(req, timeout=120) as resp: + all_decks = json.loads(resp.read()) + except Exception as e: + msg = f"Failed to download precon database: {e}" + log.error(f" Precons: ERROR - {msg}") + if idx_path.exists(): + load_precon_index() + return {"downloaded": 0, "skipped": False, "error": msg} + commander_decks = [ + d for d in all_decks + if d.get('type') == 'Commander Deck' + and (d.get('format') or '').lower() == 'commander' + ] + log.info(f" Precons: Found {len(commander_decks)} Commander precon decks") + precon_dir.mkdir(parents=True, exist_ok=True) + name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks) + dup_names = {n for n, c in name_counts.items() if c > 1} + index = [] + written = 0 + for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))): + safe_name = _sanitize_filename(deck['name']) + if safe_name in dup_names: + sc = (deck.get('set_code') or 'unk').upper() + safe_name = f"{safe_name}_{sc}" + file_name = f"{safe_name}.dck" + dck_path = precon_dir / file_name + commanders = deck.get('commander', []) + cmdr_names = [c['name'] for c in commanders] if commanders else [] + total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders) + with open(dck_path, "w", encoding="utf-8") as f: + f.write(_deck_to_dck(deck)) + written += 1 + release = deck.get('release_date', '') + year = int(release[:4]) if release and len(release) >= 4 else 0 + index.append({ + "name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown", + "commanders": cmdr_names, "colors": [], + "set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''), + "year": year, "releaseDate": release, "theme": "", + "fileName": file_name, "cardCount": total_cards, + }) + with open(idx_path, "w", encoding="utf-8") as f: + json.dump(index, f, indent=2, ensure_ascii=False) + PRECON_INDEX.clear() + PRECON_INDEX.extend(index) + log.info(f" Precons: {written} .dck files written, index saved") + return {"downloaded": written, "skipped": False, "total": written, "error": None}