From 69d84cc4143f17d125324ac25cb205d84ddf5a5f Mon Sep 17 00:00:00 2001 From: mwisnowski <93788087+mwisnowski@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:59:03 -0700 Subject: [PATCH] feat: Card Kingdom prices, shopping cart export, and hover panel fixes (#73) * feat: add CK prices, shopping cart export, and hover panel fixes * fix: include commander in Buy This Deck cart export --- CHANGELOG.md | 23 ++- RELEASE_NOTES_TEMPLATE.md | 25 ++- code/deck_builder/phases/phase6_reporting.py | 12 +- code/file_setup/setup.py | 30 +++- code/web/app.py | 11 ++ code/web/routes/decks.py | 8 + code/web/routes/price.py | 2 + code/web/services/budget_evaluator.py | 44 +++-- code/web/services/card_index.py | 23 ++- code/web/services/price_service.py | 138 +++++++++++++++ code/web/static/styles.css | 138 +++++++++++++++ code/web/static/tailwind.css | 120 +++++++++++++ code/web/static/ts/cardHover.ts | 29 +++- code/web/templates/base.html | 49 ++++-- .../templates/browse/cards/_card_tile.html | 3 + .../browse/cards/_similar_cards.html | 3 + code/web/templates/browse/cards/detail.html | 22 +++ code/web/templates/build/_alternatives.html | 2 +- code/web/templates/build/_budget_review.html | 2 +- code/web/templates/decks/pickups.html | 128 +++++++++++++- code/web/templates/decks/view.html | 66 +++++++- code/web/templates/partials/deck_summary.html | 1 + .../web/templates/themes/detail_fragment.html | 6 +- config/themes/theme_list.json | 160 +++++++++--------- 24 files changed, 899 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f505fe0..104713e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,28 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability. ## [Unreleased] -_No unreleased changes yet._ +### Added +- **Card Kingdom prices**: All price displays now show both TCGPlayer (TCG) and Card Kingdom (CK) prices side by side + - Card tile overlays and inline pricing in deck summary, build wizard, and Pickups page + - Card hover panel + - Upgrade Suggestions table + - Alternatives and budget review panels + - Card browser grid tiles + - Theme detail example cards and commanders + - Similar cards panel on card detail pages + - Price stat block on individual card detail pages (fetched live via API) +- **Price source legend**: "TCG = TCGPlayer · CK = Card Kingdom" label added to the deck summary and Pickups pages for clarity +- **Shopping cart export**: One-click deck purchasing via TCGPlayer and Card Kingdom + - **Upgrade Suggestions page**: Per-card checkboxes with select-all toggle; "Open in TCGPlayer" and "Open in Card Kingdom" buttons copy the selected card list to the clipboard and open the vendor's mass-entry page in a new tab + - **Finished deck view**: "Buy This Deck" toolbar with the same TCGPlayer and Card Kingdom buttons for the complete deck list (commander + all 99 cards) + - Clipboard copy shows a confirmation toast; falls back to a copyable text area if clipboard API is unavailable + +### Changed +- **"Upgrade Suggestions" rename**: The Pickups page and its button in the deck view are now labelled "Upgrade Suggestions" for clarity + +### Fixed +- **Commander hover panel triggered by entire sidebar**: Hovering any element inside the left-hand card preview column (buttons, text, etc.) incorrectly triggered the commander card hover panel; panel now only activates when hovering the commander image or its direct container +- **Commander hover panel missing prices**: Price information was not shown in the commander card hover panel on the finished deck and run-result views; a price overlay is now attached to the commander image so TCG and CK prices load into the hover panel ## [4.5.3] - 2026-04-02 ### Added diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 73a6132..2b25f1e 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,9 +1,26 @@ # MTG Python Deckbuilder ## [Unreleased] -_No unreleased changes yet._ - -## [4.5.3] - 2026-04-02 ### Added -- **SBOM & supply chain provenance**: Every tagged release now attaches source SBOMs (SPDX + CycloneDX JSON) for Python dependencies and a CycloneDX container image SBOM to the GitHub Release assets. Build provenance attestations (SLSA-style) are published for the multi-arch Docker image via the GitHub Attestations API. `provenance: mode=max` is enabled on all arch builds. +- **Card Kingdom prices**: All price displays now show both TCGPlayer (TCG) and Card Kingdom (CK) prices side by side + - Card tile overlays and inline pricing in deck summary, build wizard, and Pickups page + - Card hover panel + - Upgrade Suggestions table + - Alternatives and budget review panels + - Card browser grid tiles + - Theme detail example cards and commanders + - Similar cards panel on card detail pages + - Price stat block on individual card detail pages (fetched live via API) +- **Price source legend**: "TCG = TCGPlayer · CK = Card Kingdom" label added to the deck summary and Pickups pages for clarity +- **Shopping cart export**: One-click deck purchasing via TCGPlayer and Card Kingdom + - **Upgrade Suggestions page**: Per-card checkboxes with select-all toggle; "Open in TCGPlayer" and "Open in Card Kingdom" buttons copy the selected card list to the clipboard and open the vendor's mass-entry page in a new tab + - **Finished deck view**: "Buy This Deck" toolbar with the same TCGPlayer and Card Kingdom buttons for the complete deck list (commander + all 99 cards) + - Clipboard copy shows a confirmation toast; falls back to a copyable text area if clipboard API is unavailable + +### Changed +- **"Upgrade Suggestions" rename**: The Pickups page and its button in the deck view are now labelled "Upgrade Suggestions" for clarity + +### Fixed +- **Commander hover panel triggered by entire sidebar**: Hovering any element inside the left-hand card preview column (buttons, text, etc.) incorrectly triggered the commander card hover panel; panel now only activates when hovering the commander image or its direct container +- **Commander hover panel missing prices**: Price information was not shown in the commander card hover panel on the finished deck and run-result views; a price overlay is now attached to the commander image so TCG and CK prices load into the hover panel diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index f7d3f30..0071ad1 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -897,9 +897,17 @@ class ReportingMixin: headers = [ "Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness", - "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned","Price" + "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned","Price (TCGPlayer)" ] + # Auto-inject price service when running in the web context + if price_lookup is None: + try: + from code.web.services.price_service import get_price_service + price_lookup = get_price_service().get_prices_batch + except Exception: + pass + # Batch price lookup (no-op when price_lookup not provided) card_names_list = list(self.card_library.keys()) prices_map: Dict[str, Any] = {} @@ -1068,7 +1076,7 @@ class ReportingMixin: total_price = sum( v for v in prices_map.values() if v is not None ) - price_col_index = headers.index('Price') + price_col_index = headers.index('Price (TCGPlayer)') summary_row = [''] * len(headers) summary_row[0] = 'Total' summary_row[price_col_index] = f'{total_price:.2f}' diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index 664e6de..a85c562 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -414,7 +414,7 @@ def regenerate_processed_parquet() -> None: def refresh_prices_parquet(output_func=None) -> None: """Rebuild the price cache from local Scryfall bulk data and write - ``price`` / ``price_updated`` columns into all_cards.parquet and + ``price`` / ``priceUpdated`` / ``scryfallID`` / ``ckPrice`` / ``ckPriceUpdated`` columns into all_cards.parquet and commander_cards.parquet. This is safe to call from both the web app and CLI contexts. @@ -455,17 +455,39 @@ def refresh_prices_parquet(output_func=None) -> None: name_col = "faceName" if "faceName" in df.columns else "name" card_names = df[name_col].fillna("").tolist() - _log(f"Fetching prices for {len(card_names):,} cards …") + # --- scryfallID column (from the map populated during _rebuild_cache) --- + scryfall_id_map = getattr(svc, "_scryfall_id_map", {}) + if scryfall_id_map: + df["scryfallID"] = df[name_col].map(lambda n: scryfall_id_map.get(n.lower()) if n else None) + mapped = df["scryfallID"].notna().sum() + _log(f"Added scryfallID column — {mapped:,} of {len(card_names):,} cards mapped.") + else: + _log("Warning: scryfallID map empty; skipping scryfallID column.") + + # --- TCGPlayer (Scryfall) prices --- + _log(f"Fetching TCGPlayer prices for {len(card_names):,} cards …") prices = svc.get_prices_batch(card_names) priced = sum(1 for p in prices.values() if p is not None) now_iso = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") df["price"] = df[name_col].map(lambda n: prices.get(n) if n else None) - df["price_updated"] = now_iso + df["priceUpdated"] = now_iso + + # --- Card Kingdom prices --- + _log("Fetching Card Kingdom price list …") + try: + svc._rebuild_ck_cache() + ck_prices = svc.get_ck_prices_batch(card_names) + ck_priced = sum(1 for p in ck_prices.values() if p is not None) + df["ckPrice"] = df[name_col].map(lambda n: ck_prices.get(n) if n else None) + df["ckPriceUpdated"] = now_iso + _log(f"Added ckPrice column — {ck_priced:,} of {len(card_names):,} cards priced.") + except Exception as exc: + _log(f"Warning: CK price fetch failed ({exc}). Skipping ckPrice column.") loader = DataLoader() loader.write_cards(df, processed_path) - _log(f"Updated all_cards.parquet — {priced:,} of {len(card_names):,} cards priced.") + _log(f"Updated all_cards.parquet — {priced:,} of {len(card_names):,} cards priced (TCGPlayer).") # Update commander_cards.parquet by applying the same price columns. processed_dir = os.path.dirname(processed_path) diff --git a/code/web/app.py b/code/web/app.py index 805b113..816a207 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -82,6 +82,17 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) except Exception: pass + # Warm up price cache in background so first deck view request is fast + try: + import threading as _threading + from .services.price_service import get_price_service as _get_ps + _ps = _get_ps() + _t = _threading.Thread(target=_ps._ensure_loaded, daemon=True, name="price-cache-warmup") + _t.start() + _t2 = _threading.Thread(target=_ps._ensure_ck_loaded, daemon=True, name="ck-price-cache-warmup") + _t2.start() + except Exception: + pass # Start price auto-refresh scheduler (optional, 1 AM UTC daily) if PRICE_AUTO_REFRESH: try: diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index cec9523..696985b 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -601,6 +601,13 @@ async def decks_pickups(request: Request, name: str) -> HTMLResponse: except Exception: pass + owned: set[str] = set() + try: + from ..services.build_utils import owned_set as _owned_set + owned = _owned_set() + except Exception: + pass + return templates.TemplateResponse( "decks/pickups.html", { @@ -612,6 +619,7 @@ async def decks_pickups(request: Request, name: str) -> HTMLResponse: "error": error_msg, "stale_prices": stale_prices, "stale_prices_global": stale_prices_global, + "owned_names": owned, }, ) diff --git a/code/web/routes/price.py b/code/web/routes/price.py index 3c041ad..2971fc4 100644 --- a/code/web/routes/price.py +++ b/code/web/routes/price.py @@ -68,9 +68,11 @@ async def get_card_price( name = unquote(card_name).strip() svc = get_price_service() price = svc.get_price(name, region=region, foil=foil) + ck_price = svc.get_ck_price(name) return JSONResponse({ "card_name": name, "price": price, + "ck_price": ck_price, "region": region, "foil": foil, "found": price is not None, diff --git a/code/web/services/budget_evaluator.py b/code/web/services/budget_evaluator.py index 10d6a99..3297c30 100644 --- a/code/web/services/budget_evaluator.py +++ b/code/web/services/budget_evaluator.py @@ -468,15 +468,18 @@ class BudgetEvaluatorService(BaseService): # Batch price lookup for all candidates candidate_names = list(candidates.keys()) prices = self._price_svc.get_prices_batch(candidate_names, region=region, foil=foil) + ck_prices = self._price_svc.get_ck_prices_batch(candidate_names) results = [] for name, info in candidates.items(): price = prices.get(name) if price is None or price > max_price: continue + ck_price = ck_prices.get(name) results.append({ "name": name, "price": round(price, 2), + "ck_price": round(ck_price, 2) if ck_price is not None else None, "tags": info["tags"], "shared_tags": sorted(info["shared_tags"]), }) @@ -540,13 +543,11 @@ class BudgetEvaluatorService(BaseService): def _get_card_tags(self, card_name: str) -> List[str]: """Look up theme tags for a single card from the card index.""" try: - from code.web.services.card_index import maybe_build_index, _CARD_INDEX + from code.web.services.card_index import maybe_build_index, lookup_card maybe_build_index() - needle = card_name.lower() - for cards in _CARD_INDEX.values(): - for c in cards: - if c.get("name", "").lower() == needle: - return list(c.get("tags", [])) + entry = lookup_card(card_name) + if entry: + return list(entry.get("tags", [])) except Exception: pass return [] @@ -554,17 +555,14 @@ class BudgetEvaluatorService(BaseService): def _get_card_broad_type(self, card_name: str) -> Optional[str]: """Return the first matching broad MTG type for a card (e.g. 'Land', 'Creature').""" try: - from code.web.services.card_index import maybe_build_index, _CARD_INDEX + from code.web.services.card_index import maybe_build_index, lookup_card maybe_build_index() - needle = card_name.lower() - for cards in _CARD_INDEX.values(): - for c in cards: - if c.get("name", "").lower() == needle: - type_line = c.get("type_line", "") - for broad in _BROAD_TYPES: - if broad in type_line: - return broad - return None + entry = lookup_card(card_name) + if entry: + type_line = entry.get("type_line", "") + for broad in _BROAD_TYPES: + if broad in type_line: + return broad except Exception: pass return None @@ -620,16 +618,13 @@ class BudgetEvaluatorService(BaseService): # Collect all unique tags from the current deck deck_tags: Set[str] = set() try: - from code.web.services.card_index import maybe_build_index, _CARD_INDEX + from code.web.services.card_index import maybe_build_index, _CARD_INDEX, lookup_card maybe_build_index() for name in decklist: - needle = name.lower() - for cards in _CARD_INDEX.values(): - for c in cards: - if c.get("name", "").lower() == needle: - deck_tags.update(c.get("tags", [])) - break + card_entry = lookup_card(name) + if card_entry: + deck_tags.update(card_entry.get("tags", [])) if not deck_tags: return [] @@ -664,6 +659,7 @@ class BudgetEvaluatorService(BaseService): top_candidates = sorted(candidates.values(), key=lambda x: x["score"], reverse=True)[:200] names = [c["name"] for c in top_candidates] prices = self._price_svc.get_prices_batch(names, region=region, foil=foil) + ck_prices = self._price_svc.get_ck_prices_batch(names) tier_ceilings = self.calculate_tier_ceilings(budget_remaining) pickups: List[Pickup] = [] @@ -679,9 +675,11 @@ class BudgetEvaluatorService(BaseService): tier = "M" if price <= tier_ceilings["S"]: tier = "S" + ck_price = ck_prices.get(c["name"]) pickups.append({ "card": c["name"], "price": round(price, 2), + "ck_price": round(ck_price, 2) if ck_price is not None else None, "tier": tier, "priority": c["score"], "tags": c["tags"], diff --git a/code/web/services/card_index.py b/code/web/services/card_index.py index 279f8e3..5d140d3 100644 --- a/code/web/services/card_index.py +++ b/code/web/services/card_index.py @@ -27,6 +27,8 @@ RARITY_COL = "rarity" _CARD_INDEX: Dict[str, List[Dict[str, Any]]] = {} _CARD_INDEX_MTIME: float | None = None +# Reverse lookup: lowercase card name → card dict (first occurrence per name) +_NAME_INDEX: Dict[str, Dict[str, Any]] = {} _RARITY_NORM = { "mythic rare": "mythic", @@ -50,7 +52,7 @@ def maybe_build_index() -> None: M4: Loads from all_cards.parquet instead of CSV files. """ - global _CARD_INDEX, _CARD_INDEX_MTIME + global _CARD_INDEX, _CARD_INDEX_MTIME, _NAME_INDEX try: from path_util import get_processed_cards_path @@ -99,6 +101,14 @@ def maybe_build_index() -> None: }) _CARD_INDEX = new_index + # Build name → card reverse index for O(1) lookups + new_name_index: Dict[str, Dict[str, Any]] = {} + for cards in new_index.values(): + for c in cards: + key = c.get("name", "").lower() + if key and key not in new_name_index: + new_name_index[key] = c + _NAME_INDEX = new_name_index _CARD_INDEX_MTIME = latest except Exception: # Defensive: if anything fails, leave index unchanged @@ -107,9 +117,20 @@ def maybe_build_index() -> None: def get_tag_pool(tag: str) -> List[Dict[str, Any]]: return _CARD_INDEX.get(tag, []) + +def lookup_card(name: str) -> Optional[Dict[str, Any]]: + """O(1) lookup of a card dict by name. Returns None if not found.""" + return _NAME_INDEX.get(name.lower().strip()) if name else None + + def lookup_commander(name: Optional[str]) -> Optional[Dict[str, Any]]: if not name: return None + # Fast path via name index + result = _NAME_INDEX.get(name.lower().strip()) + if result is not None: + return result + # Fallback: full scan (handles index not yet built) needle = name.lower().strip() for tag_cards in _CARD_INDEX.values(): for c in tag_cards: diff --git a/code/web/services/price_service.py b/code/web/services/price_service.py index 1cc1371..3bc3063 100644 --- a/code/web/services/price_service.py +++ b/code/web/services/price_service.py @@ -30,6 +30,8 @@ logger.addHandler(logging_util.stream_handler) _CACHE_TTL_SECONDS = 86400 # 24 hours _BULK_DATA_FILENAME = "scryfall_bulk_data.json" _PRICE_CACHE_FILENAME = "prices_cache.json" +_CK_CACHE_FILENAME = "ck_prices_cache.json" +_CK_API_URL = "https://api.cardkingdom.com/api/v2/pricelist" class PriceService(BaseService): @@ -68,6 +70,14 @@ class PriceService(BaseService): self._miss_count = 0 self._refresh_thread: Optional[threading.Thread] = None + # CK price cache: {normalized_card_name: float (cheapest non-foil retail)} + self._ck_cache_path: str = os.path.join(card_files_dir(), _CK_CACHE_FILENAME) + self._ck_cache: Dict[str, float] = {} + self._ck_loaded: bool = False + + # scryfall_id map built during _rebuild_cache: {name.lower(): scryfall_id} + self._scryfall_id_map: Dict[str, str] = {} + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -157,6 +167,35 @@ class PriceService(BaseService): "bulk_data_available": os.path.exists(self._bulk_path), } + # ------------------------------------------------------------------ + # CK Public API + # ------------------------------------------------------------------ + + def get_ck_price(self, card_name: str) -> Optional[float]: + """Return the Card Kingdom retail price for *card_name*, or None.""" + self._ensure_ck_loaded() + return self._ck_cache.get(card_name.lower().strip()) + + def get_ck_prices_batch(self, card_names: List[str]) -> Dict[str, Optional[float]]: + """Return a mapping of card name → CK retail price for all requested cards.""" + self._ensure_ck_loaded() + return { + name: self._ck_cache.get(name.lower().strip()) + for name in card_names + } + + def get_ck_built_at(self) -> Optional[str]: + """Return a human-readable CK cache build date, or None if unavailable.""" + try: + if os.path.exists(self._ck_cache_path): + import datetime + built = os.path.getmtime(self._ck_cache_path) + dt = datetime.datetime.fromtimestamp(built, tz=datetime.timezone.utc) + return dt.strftime("%B %d, %Y") + except Exception: + pass + return None + def refresh_cache_background(self) -> None: """Spawn a daemon thread to rebuild the price cache asynchronously. @@ -412,6 +451,7 @@ class PriceService(BaseService): logger.info("Building price cache from %s ...", self._bulk_path) new_cache: Dict[str, Dict[str, float]] = {} + new_scryfall_id_map: Dict[str, str] = {} built_at = time.time() try: @@ -426,6 +466,7 @@ class PriceService(BaseService): continue name: str = card.get("name", "") + scryfall_id: str = card.get("id", "") prices: Dict[str, Any] = card.get("prices") or {} if not name: continue @@ -446,6 +487,9 @@ class PriceService(BaseService): new_usd = entry.get("usd", 9999.0) if existing is None or new_usd < existing.get("usd", 9999.0): new_cache[key] = entry + # Track the scryfall_id of the cheapest-priced printing + if scryfall_id: + new_scryfall_id_map[key] = scryfall_id except Exception as exc: logger.error("Failed to parse bulk data: %s", exc) @@ -466,6 +510,7 @@ class PriceService(BaseService): with self._lock: self._cache = new_cache + self._scryfall_id_map = new_scryfall_id_map self._last_refresh = built_at # Stamp all keys as fresh so get_stale_cards() reflects the rebuild. # _lazy_ts may not exist if start_lazy_refresh() was never called @@ -476,9 +521,102 @@ class PriceService(BaseService): self._lazy_ts[key] = built_at self._save_lazy_ts() + # ------------------------------------------------------------------ + # CK internal helpers + # ------------------------------------------------------------------ + + def _ensure_ck_loaded(self) -> None: + """Lazy-load the CK price cache on first access (double-checked lock).""" + if self._ck_loaded: + return + with self._lock: + if self._ck_loaded: + return + if os.path.exists(self._ck_cache_path): + try: + age = time.time() - os.path.getmtime(self._ck_cache_path) + if age < self._ttl: + self._load_ck_from_cache() + logger.info("Loaded %d CK prices from cache (age %.1fh)", len(self._ck_cache), age / 3600) + self._ck_loaded = True + return + except Exception as exc: + logger.warning("CK cache unreadable: %s", exc) + # No fresh cache — set loaded flag anyway so we degrade gracefully + # rather than blocking every request. CK rebuild happens via Setup page. + self._ck_loaded = True + + def _rebuild_ck_cache(self) -> None: + """Fetch the Card Kingdom price list and cache retail prices by card name. + + Fetches https://api.cardkingdom.com/api/v2/pricelist, takes the cheapest + non-foil price_retail per card name (across all printings), and writes + ck_prices_cache.json atomically. + """ + import urllib.request as _urllib + logger.info("Fetching CK price list from %s ...", _CK_API_URL) + try: + req = _urllib.Request(_CK_API_URL, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"}) + with _urllib.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + except Exception as exc: + logger.warning("CK price fetch failed: %s", exc) + return + + items = data.get("data", []) + meta_created_at = data.get("meta", {}).get("created_at", "") + new_ck: Dict[str, float] = {} + + for item in items: + if item.get("is_foil") == "true": + continue + name = item.get("name", "") + price_str = item.get("price_retail", "") + if not name or not price_str: + continue + try: + price = float(price_str) + except (ValueError, TypeError): + continue + if price <= 0: + continue + + # Index by full name and each face for split/DFC cards + keys_to_index = [name.lower()] + if " // " in name: + keys_to_index += [part.strip().lower() for part in name.split(" // ")] + + for key in keys_to_index: + if key not in new_ck or price < new_ck[key]: + new_ck[key] = price + + # Write cache atomically + try: + cache_data = {"ck_prices": new_ck, "built_at": time.time(), "meta_created_at": meta_created_at} + tmp = self._ck_cache_path + ".tmp" + os.makedirs(os.path.dirname(self._ck_cache_path), exist_ok=True) + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(cache_data, fh, separators=(",", ":")) + os.replace(tmp, self._ck_cache_path) + logger.info("CK price cache written: %d cards → %s", len(new_ck), self._ck_cache_path) + except Exception as exc: + logger.error("Failed to write CK price cache: %s", exc) + return + + with self._lock: + self._ck_cache = new_ck + self._ck_loaded = True + + def _load_ck_from_cache(self) -> None: + """Deserialize the CK prices cache JSON into memory.""" + with open(self._ck_cache_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + self._ck_cache = data.get("ck_prices", {}) + @staticmethod def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]: """Convert raw Scryfall prices dict to {region_key: float} entries.""" + result: Dict[str, float] = {} for key in ("usd", "usd_foil", "eur", "eur_foil"): raw = prices.get(key) diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 1bb94a2..17c5c59 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -5993,6 +5993,45 @@ footer.site-footer { color: var(--muted, #b6b8bd); } +.owned-badge { + display: inline-block; + padding: .1rem .45rem; + border-radius: 4px; + font-size: .75rem; + font-weight: 600; + color: var(--ok, #16a34a); + border: 1px solid var(--ok, #16a34a); + background: transparent; +} + +/* Dual-price source labels (TCG / CK) */ + +.price-src-label { + font-size: .65rem; + font-weight: 600; + opacity: .65; + letter-spacing: .04em; + text-transform: uppercase; +} + +.price-sep { + opacity: .4; +} + +.alt-prices { + font-size: 11px; + opacity: .7; + font-weight: normal; +} + +/* Price source legend (TCG = TCGPlayer · CK = Card Kingdom) */ + +.price-legend { + font-size: .7rem; + opacity: .55; + letter-spacing: .02em; +} + /* Inline price tooltip on card names */ .card-name-price-hover { @@ -6382,6 +6421,104 @@ footer.site-footer { font-size: .92rem; } +/* Shopping cart toolbar — pickups page */ + +.cart-toolbar { + display: flex; + align-items: center; + gap: .6rem; + flex-wrap: wrap; + margin-bottom: .75rem; + padding: .45rem .6rem; + background: var(--panel, #1a1f2e); + border: 1px solid var(--border, #333); + border-radius: 6px; +} + +.cart-toolbar .cart-label { + font-size: .82rem; + color: var(--muted, #94a3b8); + white-space: nowrap; +} + +.cart-copy-feedback { + font-size: .8rem; + font-weight: 600; + color: #34d399; + margin-left: .25rem; +} + +.cart-copy-feedback.error { + color: #f87171; +} + +.cart-fallback-wrap { + margin-top: .6rem; + padding: .5rem .6rem; + background: var(--panel, #1a1f2e); + border: 1px solid var(--border, #333); + border-radius: 6px; +} + +.cart-fallback-wrap p { + font-size: .8rem; + color: var(--muted, #94a3b8); + margin: 0 0 .4rem 0; +} + +.cart-fallback-textarea { + width: 100%; + min-height: 110px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: .8rem; + padding: .4rem .5rem; + background: var(--bg, #0f172a); + color: var(--text, #e5e7eb); + border: 1px solid var(--border, #333); + border-radius: 4px; + resize: vertical; + box-sizing: border-box; +} + +.cart-cb-th { + width: 2.2rem; + text-align: center; + padding: .4rem .25rem; + border-bottom: 1px solid var(--border, #333); +} + +.cart-cb-td { + text-align: center; + padding: .35rem .25rem; + border-bottom: 1px solid var(--border-subtle, #222); +} + +/* Top-center toast for buy/cart copy confirmation */ + +.cart-toast-top { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + background: var(--panel, #1a1f2e); + color: var(--text, #e5e7eb); + border: 1px solid #34d399; + border-radius: 10px; + padding: .6rem 1.4rem; + font-size: 1rem; + font-weight: 600; + box-shadow: 0 8px 28px rgba(0,0,0,.45); + z-index: 10000; + white-space: nowrap; + pointer-events: none; + transition: opacity .25s ease, transform .25s ease; +} + +.cart-toast-top.hide { + opacity: 0; + transform: translateX(-50%) translateY(-6px); +} + .hover\:text-gray-700:hover { --tw-text-opacity: 1; color: rgb(55 65 81 / var(--tw-text-opacity, 1)); @@ -6422,3 +6559,4 @@ footer.site-footer { } } + diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css index 3c25c99..d8830e0 100644 --- a/code/web/static/tailwind.css +++ b/code/web/static/tailwind.css @@ -3728,6 +3728,43 @@ footer.site-footer { color: var(--muted, #b6b8bd); } +.owned-badge { + display: inline-block; + padding: .1rem .45rem; + border-radius: 4px; + font-size: .75rem; + font-weight: 600; + color: var(--ok, #16a34a); + border: 1px solid var(--ok, #16a34a); + background: transparent; +} + +/* Dual-price source labels (TCG / CK) */ +.price-src-label { + font-size: .65rem; + font-weight: 600; + opacity: .65; + letter-spacing: .04em; + text-transform: uppercase; +} + +.price-sep { + opacity: .4; +} + +.alt-prices { + font-size: 11px; + opacity: .7; + font-weight: normal; +} + +/* Price source legend (TCG = TCGPlayer · CK = Card Kingdom) */ +.price-legend { + font-size: .7rem; + opacity: .55; + letter-spacing: .02em; +} + /* Inline price tooltip on card names */ .card-name-price-hover { cursor: default; @@ -3957,3 +3994,86 @@ footer.site-footer { font-size: .92rem; } +/* Shopping cart toolbar — pickups page */ +.cart-toolbar { + display: flex; + align-items: center; + gap: .6rem; + flex-wrap: wrap; + margin-bottom: .75rem; + padding: .45rem .6rem; + background: var(--panel, #1a1f2e); + border: 1px solid var(--border, #333); + border-radius: 6px; +} +.cart-toolbar .cart-label { + font-size: .82rem; + color: var(--muted, #94a3b8); + white-space: nowrap; +} +.cart-copy-feedback { + font-size: .8rem; + font-weight: 600; + color: #34d399; + margin-left: .25rem; +} +.cart-copy-feedback.error { color: #f87171; } +.cart-fallback-wrap { + margin-top: .6rem; + padding: .5rem .6rem; + background: var(--panel, #1a1f2e); + border: 1px solid var(--border, #333); + border-radius: 6px; +} +.cart-fallback-wrap p { + font-size: .8rem; + color: var(--muted, #94a3b8); + margin: 0 0 .4rem 0; +} +.cart-fallback-textarea { + width: 100%; + min-height: 110px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: .8rem; + padding: .4rem .5rem; + background: var(--bg, #0f172a); + color: var(--text, #e5e7eb); + border: 1px solid var(--border, #333); + border-radius: 4px; + resize: vertical; + box-sizing: border-box; +} +.cart-cb-th { + width: 2.2rem; + text-align: center; + padding: .4rem .25rem; + border-bottom: 1px solid var(--border, #333); +} +.cart-cb-td { + text-align: center; + padding: .35rem .25rem; + border-bottom: 1px solid var(--border-subtle, #222); +} + +/* Top-center toast for buy/cart copy confirmation */ +.cart-toast-top { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + background: var(--panel, #1a1f2e); + color: var(--text, #e5e7eb); + border: 1px solid #34d399; + border-radius: 10px; + padding: .6rem 1.4rem; + font-size: 1rem; + font-weight: 600; + box-shadow: 0 8px 28px rgba(0,0,0,.45); + z-index: 10000; + white-space: nowrap; + pointer-events: none; + transition: opacity .25s ease, transform .25s ease; +} +.cart-toast-top.hide { opacity: 0; transform: translateX(-50%) translateY(-6px); } + + diff --git a/code/web/static/ts/cardHover.ts b/code/web/static/ts/cardHover.ts index 067ff71..7725684 100644 --- a/code/web/static/ts/cardHover.ts +++ b/code/web/static/ts/cardHover.ts @@ -396,12 +396,24 @@ interface PointerEventLike { nameEl.textContent = nm; rarityEl.textContent = rarity; if (priceEl) { - const priceRaw = (attr('data-price') || '').trim(); - const priceNum = priceRaw ? parseFloat(priceRaw) : NaN; const isStale = attr('data-stale') === '1'; - priceEl.innerHTML = !isNaN(priceNum) - ? '$' + priceNum.toFixed(2) + (isStale ? ' \u23F1' : '') - : ''; + const staleSpan = isStale ? ' \u23F1' : ''; + // Prefer the shared price cache (populated by initPriceDisplay with both TCG+CK) + const globalCache = (window as any)._priceNum as Record | undefined; + const cached = globalCache && globalCache[nm]; + if (cached && (cached.tcg !== null || cached.ck !== null)) { + const parts: string[] = []; + if (cached.tcg !== null) parts.push('TCG $' + (cached.tcg as number).toFixed(2) + staleSpan); + if (cached.ck !== null) parts.push('CK $' + (cached.ck as number).toFixed(2)); + priceEl.innerHTML = parts.join(' \u00B7 '); + } else { + // Fallback: data-price (TCG only, set server-side in _step5.html) + const priceRaw = (attr('data-price') || '').trim(); + const priceNum = priceRaw ? parseFloat(priceRaw) : NaN; + priceEl.innerHTML = !isNaN(priceNum) + ? 'TCG $' + priceNum.toFixed(2) + staleSpan + : ''; + } } const roleLabel = displayLabel(role); @@ -680,7 +692,12 @@ interface PointerEventLike { // Recognized container classes const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card'); - if (container) return container; + if (container) { + // .card-preview is also used as a plain layout aside (no data-card-name on it); + // only treat it as a hover target when the attribute lives directly on the element. + if (container.classList.contains('card-preview') && !container.hasAttribute('data-card-name')) return null; + return container; + } // Image-based detection (any card image carrying data-card-name) if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) { diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 1c82d20..1520c64 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -434,7 +434,10 @@ fetch('/api/price/' + encodeURIComponent(name)) .then(function(r){ return r.ok ? r.json() : null; }) .then(function(d){ - var label = (d && d.found && d.price != null) ? ('$' + parseFloat(d.price).toFixed(2)) : 'Price unavailable'; + var parts = []; + if (d && d.found && d.price != null) parts.push('TCG: $' + parseFloat(d.price).toFixed(2)); + if (d && d.ck_price != null) parts.push('CK: $' + parseFloat(d.ck_price).toFixed(2)); + var label = parts.length ? parts.join(' ') : 'Price unavailable'; _priceCache[name] = label; _showTip(el, label); }) @@ -454,7 +457,8 @@ 'Snow-Covered Plains','Snow-Covered Island','Snow-Covered Swamp', 'Snow-Covered Mountain','Snow-Covered Forest' ]); - var _priceNum = {}; // card name -> float|null + var _priceNum = {}; // card name -> {tcg, ck}|null + window._priceNum = _priceNum; // expose for cardHover.js var _deckPrices = {}; // accumulated across build stages: card name -> float var _buildToken = null; function _fetchNum(name) { @@ -462,9 +466,12 @@ return fetch('/api/price/' + encodeURIComponent(name)) .then(function(r){ return r.ok ? r.json() : null; }) .then(function(d){ - var p = (d && d.found && d.price != null) ? parseFloat(d.price) : null; - _priceNum[name] = p; return p; - }).catch(function(){ _priceNum[name] = null; return null; }); + var obj = { + tcg: (d && d.found && d.price != null) ? parseFloat(d.price) : null, + ck: (d && d.ck_price != null) ? parseFloat(d.ck_price) : null, + }; + _priceNum[name] = obj; return obj; + }).catch(function(){ var obj = {tcg:null,ck:null}; _priceNum[name] = obj; return obj; }); } function _getBuildToken() { var el = document.querySelector('[data-build-id]'); @@ -507,17 +514,24 @@ var prevTotal = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0); var promises = []; - toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(p){ return {name:name,price:p}; })); }); + toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(obj){ return {name:name,price:obj}; })); }); Promise.all(promises).then(function(results){ var map = {}; var prevTotal2 = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0); - results.forEach(function(r){ map[r.name] = r.price; if (r.price !== null) _deckPrices[r.name] = r.price; }); + results.forEach(function(r){ map[r.name] = r.price; if (r.price && r.price.tcg !== null) _deckPrices[r.name] = r.price.tcg; }); overlays.forEach(function(el){ var name = el.dataset.priceFor; if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; } - var p = map[name]; - el.textContent = p !== null ? ('$' + p.toFixed(2)) : ''; - if (ceiling !== null && p !== null && p > ceiling) { + var obj = map[name]; + var tcg = obj ? obj.tcg : null; + var ck = obj ? obj.ck : null; + if (tcg !== null || ck !== null) { + var parts = []; + if (tcg !== null) parts.push('TCG $' + tcg.toFixed(2)); + if (ck !== null) parts.push('CK $' + ck.toFixed(2)); + el.innerHTML = parts.join('
'); + } else { el.innerHTML = ''; } + if (ceiling !== null && tcg !== null && tcg > ceiling) { var tile = el.closest('.card-tile,.stack-card'); if (tile) tile.classList.add('over-budget'); } @@ -525,9 +539,16 @@ inlines.forEach(function(el){ var name = el.dataset.priceFor; if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; } - var p = map[name]; - el.textContent = p !== null ? ('$' + p.toFixed(2)) : ''; - if (ceiling !== null && p !== null && p > ceiling) { + var obj = map[name]; + var tcg = obj ? obj.tcg : null; + var ck = obj ? obj.ck : null; + if (tcg !== null || ck !== null) { + var parts = []; + if (tcg !== null) parts.push('TCG $' + tcg.toFixed(2)); + if (ck !== null) parts.push('CK $' + ck.toFixed(2)); + el.innerHTML = parts.join(' · '); + } else { el.innerHTML = ''; } + if (ceiling !== null && tcg !== null && tcg > ceiling) { var row = el.closest('.list-row'); if (row) row.classList.add('over-budget'); } @@ -543,7 +564,7 @@ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n) && !allNames.has(n)) { allNames.add(n); - sumTotal += (map[n] || 0); + sumTotal += (map[n] ? (map[n].tcg || 0) : 0); } }); if (totalCap !== null) { diff --git a/code/web/templates/browse/cards/_card_tile.html b/code/web/templates/browse/cards/_card_tile.html index 8e233e2..a44c9bf 100644 --- a/code/web/templates/browse/cards/_card_tile.html +++ b/code/web/templates/browse/cards/_card_tile.html @@ -19,6 +19,9 @@ {{ card.name }} + {# Price overlay #} + + {# Owned indicator #} {% if card.is_owned %}
diff --git a/code/web/templates/browse/cards/_similar_cards.html b/code/web/templates/browse/cards/_similar_cards.html index e2870f3..208e210 100644 --- a/code/web/templates/browse/cards/_similar_cards.html +++ b/code/web/templates/browse/cards/_similar_cards.html @@ -84,6 +84,7 @@ cursor: pointer; border-radius: 8px; transition: transform 0.2s; + position: relative; } .similar-card-image:hover { @@ -235,6 +236,8 @@
+ {# Price overlay #} + {{ card.name }}{{ card.rarity | capitalize }}
{% endif %} + +
+ {% if card.text %} diff --git a/code/web/templates/build/_alternatives.html b/code/web/templates/build/_alternatives.html index ae41bed..494e496 100644 --- a/code/web/templates/build/_alternatives.html +++ b/code/web/templates/build/_alternatives.html @@ -39,7 +39,7 @@ hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick"> - {{ it.name }}{% if it.price %} ${{ "%.2f"|format(it.price|float) }}{% endif %} + {{ it.name }}{% if it.price or it.ck_price %} {% if it.price %}TCG ${{ "%.2f"|format(it.price|float) }}{% endif %}{% if it.price and it.ck_price %} · {% endif %}{% if it.ck_price %}CK ${{ "%.2f"|format(it.ck_price|float) }}{% endif %}{% endif %} {% endfor %} diff --git a/code/web/templates/build/_budget_review.html b/code/web/templates/build/_budget_review.html index 56a49bc..04dcd8b 100644 --- a/code/web/templates/build/_budget_review.html +++ b/code/web/templates/build/_budget_review.html @@ -55,7 +55,7 @@ {% if alt.price %}data-price="{{ alt.price }}"{% endif %} title="{{ alt.shared_tags|join(', ') if alt.shared_tags else '' }}"> {{ alt.name }} - {% if alt.price %}${{ '%.2f'|format(alt.price|float) }}{% endif %} + {% if alt.price or alt.ck_price %}{% if alt.price %}TCG ${{ '%.2f'|format(alt.price|float) }}{% endif %}{% if alt.price and alt.ck_price %} · {% endif %}{% if alt.ck_price %}CK ${{ '%.2f'|format(alt.ck_price|float) }}{% endif %}{% endif %} {% endfor %} diff --git a/code/web/templates/decks/pickups.html b/code/web/templates/decks/pickups.html index eb67d48..adc0d26 100644 --- a/code/web/templates/decks/pickups.html +++ b/code/web/templates/decks/pickups.html @@ -1,7 +1,7 @@ {% extends "base.html" %} -{% block banner_subtitle %}Budget Pickups{% endblock %} +{% block banner_subtitle %}Upgrade Suggestions{% endblock %} {% block content %} -

Pickups List

+

Upgrade Suggestions

{% if commander %}
Deck: {{ commander }}{% if name %} — {{ name }}{% endif %}
{% endif %} @@ -30,13 +30,35 @@ {% if budget_report.pickups_list %}

- Cards you don't own yet that fit the deck's themes and budget. Sorted by theme match priority. + Cards that fit the deck's themes and budget. Owned cards are free upgrades — just swap one in. Sorted by theme match priority. + TCG = TCGPlayer · CK = Card Kingdom

+ +{# Cart toolbar #} +
+ + + + +
+{# Fallback textarea (shown when Clipboard API is unavailable) #} + + + - + + + @@ -44,16 +66,33 @@ {% for card in budget_report.pickups_list %} + + + @@ -64,8 +103,84 @@ {% endfor %}
CardPriceTCGCKOwned Tier Priority
+ + {{ card.card }} - {% if card.price is not none %} + {% if owned_names is defined and card.card|lower in owned_names %} + owned + {% elif card.price is not none %} ${{ "%.2f"|format(card.price) }}{% if stale_prices is defined and card.card|lower in stale_prices %}{% endif %} {% else %} {% endif %} + {% if card.ck_price is not none %} + ${{ "%.2f"|format(card.ck_price) }} + {% else %} + + {% endif %} + + {% if owned_names is defined and card.card|lower in owned_names %} + yes + {% endif %} + {{ card.tier }}
+ + + {% else %} -
No pickups suggestions available — your deck may already fit the budget well.
+
No upgrade suggestions available — your deck may already fit the budget well.
{% endif %} {% if budget_report.over_budget_cards %} @@ -102,3 +217,4 @@ All Decks
{% endblock %} + diff --git a/code/web/templates/decks/view.html b/code/web/templates/decks/view.html index 33f4605..bae35eb 100644 --- a/code/web/templates/decks/view.html +++ b/code/web/templates/decks/view.html @@ -45,6 +45,8 @@ {% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %} {% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %} width="320" /> + {# Price overlay — ensures commander price is loaded into window._priceNum for the hover panel #} +
Commander: Compare… {% if budget_report %} - Pickups List + Upgrade Suggestions {% endif %}
+ {# Buy This Deck: collect all cards, strip DFC names, open vendor + copy to clipboard #} + {%- set _buy_cards = [] -%} + {%- if commander_base -%} + {%- set _ = _buy_cards.append({'name': commander_base, 'count': 1}) -%} + {%- endif -%} + {%- if summary and summary.type_breakdown and summary.type_breakdown.cards -%} + {%- for _btype, _bclist in summary.type_breakdown.cards.items() -%} + {%- for _bc in _bclist -%} + {%- if _bc.name -%} + {%- set _ = _buy_cards.append({'name': _bc.name, 'count': (_bc.count if _bc.count and _bc.count > 1 else 1)}) -%} + {%- endif -%} + {%- endfor -%} + {%- endfor -%} + {%- endif -%} + {% if _buy_cards %} +
+ Buy this deck: +
+ + +
+
+ + + {% endif %} {% if budget_report %} {% set bstatus = budget_report.budget_status %}
diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html index beaef23..166765d 100644 --- a/code/web/templates/partials/deck_summary.html +++ b/code/web/templates/partials/deck_summary.html @@ -8,6 +8,7 @@
Card Types
View: + TCG = TCGPlayer · CK = Card Kingdom
diff --git a/code/web/templates/themes/detail_fragment.html b/code/web/templates/themes/detail_fragment.html index 922d07e..8312449 100644 --- a/code/web/templates/themes/detail_fragment.html +++ b/code/web/templates/themes/detail_fragment.html @@ -184,8 +184,9 @@ {% if theme.example_cards %} {% for c in theme.example_cards %} {% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %} -
+
{{ c }} image +
{{ c }}
{% endfor %} @@ -198,8 +199,9 @@ {% if theme.example_commanders %} {% for c in theme.example_commanders %} {% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %} -
+
{{ c }} image +
{{ c }}
{% endfor %} diff --git a/config/themes/theme_list.json b/config/themes/theme_list.json index f3ef182..1e42016 100644 --- a/config/themes/theme_list.json +++ b/config/themes/theme_list.json @@ -4263,10 +4263,10 @@ "theme": "Converge", "synergies": [ "+1/+1 Counters", - "Counters Matter", "Spells Matter", "Spellslinger", - "Voltron" + "Counters Matter", + "Burn" ], "primary_color": "Blue", "secondary_color": "Green", @@ -24807,27 +24807,27 @@ ], "frequencies_by_base_color": { "white": { - "Aggro": 2179, - "Artifacts Matter": 1123, - "Combat Matters": 2179, + "Aggro": 2180, + "Artifacts Matter": 1125, + "Combat Matters": 2180, "Equip": 70, "Equipment": 73, "Equipment Matters": 300, - "Voltron": 1363, + "Voltron": 1364, "Big Mana": 1742, "Bird Kindred": 225, - "Blink": 1090, - "Enter the Battlefield": 1090, + "Blink": 1091, + "Enter the Battlefield": 1091, "Flying": 1016, "Guest Kindred": 2, - "Leave the Battlefield": 1101, - "Life Matters": 1672, - "Lifegain": 1671, - "Little Fellas": 2455, + "Leave the Battlefield": 1102, + "Life Matters": 1674, + "Lifegain": 1673, + "Little Fellas": 2457, "Toughness Matters": 1393, - "Mill": 673, - "Spells Matter": 1679, - "Spellslinger": 1679, + "Mill": 674, + "Spells Matter": 1681, + "Spellslinger": 1681, "Auras": 468, "Enchantments Matter": 1240, "Ally Kindred": 110, @@ -24840,8 +24840,8 @@ "Tokens Matter": 1012, "Cantrips": 126, "Card Draw": 661, - "Combat Tricks": 272, - "Interaction": 1324, + "Combat Tricks": 273, + "Interaction": 1327, "Unconditional Draw": 229, "Bending": 23, "Cost Reduction": 135, @@ -24850,9 +24850,9 @@ "Topdeck": 302, "Waterbend": 4, "Waterbending": 5, - "+1/+1 Counters": 754, + "+1/+1 Counters": 755, "Aristocrats": 285, - "Counters Matter": 1104, + "Counters Matter": 1105, "Reanimate": 362, "Sacrifice Matters": 285, "Vigilance": 430, @@ -24869,7 +24869,7 @@ "Reach": 28, "Spirit Kindred": 341, "Trample": 90, - "Lifelink": 423, + "Lifelink": 424, "Beast Kindred": 60, "Sloth Kindred": 4, "Gargoyle Kindred": 19, @@ -24882,12 +24882,12 @@ "Choose a background": 5, "Soldier Kindred": 842, "Warrior Kindred": 307, - "Control": 379, - "Toolbox": 143, + "Control": 380, + "Toolbox": 145, "Bard Kindred": 14, "First strike": 184, - "Removal": 592, - "Burn": 513, + "Removal": 594, + "Burn": 514, "Deserts Matter": 14, "Land Types Matter": 92, "Pingers": 98, @@ -24946,7 +24946,7 @@ "Convoke": 39, "Artificer Kindred": 82, "Vehicles": 90, - "Dwarf Kindred": 64, + "Dwarf Kindred": 65, "Crew": 25, "Elephant Kindred": 43, "Performer Kindred": 7, @@ -25007,8 +25007,8 @@ "Mount Kindred": 24, "Deathtouch": 25, "Faerie Kindred": 20, - "Outlaw Kindred": 101, - "Warlock Kindred": 28, + "Outlaw Kindred": 102, + "Warlock Kindred": 29, "Golem Kindred": 25, "Flurry": 6, "Elf Kindred": 90, @@ -26276,20 +26276,20 @@ "Enter the Battlefield": 1152, "Guest Kindred": 6, "Leave the Battlefield": 1153, - "Little Fellas": 2024, - "Mill": 1566, + "Little Fellas": 2026, + "Mill": 1567, "Open an Attraction": 9, "Reanimate": 1447, "Roll to Visit Your Attractions": 2, "Zombie Kindred": 641, - "+1/+1 Counters": 668, - "Aggro": 2049, + "+1/+1 Counters": 669, + "Aggro": 2050, "Aristocrats": 957, - "Artifacts Matter": 794, - "Big Mana": 1984, - "Burn": 1375, - "Combat Matters": 2049, - "Counters Matter": 1071, + "Artifacts Matter": 795, + "Big Mana": 1985, + "Burn": 1376, + "Combat Matters": 2050, + "Counters Matter": 1072, "Creature Tokens": 579, "Druid Kindred": 36, "Historics Matter": 1162, @@ -26299,19 +26299,19 @@ "Sacrifice Matters": 953, "Token Creation": 774, "Tokens Matter": 779, - "Voltron": 1001, + "Voltron": 1002, "Astartes Kindred": 11, "Cascade": 10, "Exile Matters": 227, "Mark of Chaos Ascendant": 1, "Trample": 141, "Warrior Kindred": 298, - "Spells Matter": 1903, - "Spellslinger": 1903, + "Spells Matter": 1907, + "Spellslinger": 1907, "X Spells": 221, "First strike": 62, - "Life Matters": 1311, - "Lifegain": 1308, + "Life Matters": 1315, + "Lifegain": 1312, "Toughness Matters": 972, "-1/-1 Counters": 164, "Demon Kindred": 219, @@ -26323,7 +26323,7 @@ "Bird Kindred": 53, "Lifelink": 304, "Combat Tricks": 211, - "Interaction": 1227, + "Interaction": 1229, "Midrange": 122, "Horror Kindred": 239, "Card Draw": 1043, @@ -26335,7 +26335,7 @@ "Elf Kindred": 114, "Menace": 232, "Vigilance": 59, - "Removal": 741, + "Removal": 743, "Basic landcycling": 4, "Cycling": 83, "Landcycling": 15, @@ -26357,10 +26357,10 @@ "Stax": 401, "Specter Kindred": 27, "Enchantments Matter": 807, - "Spirit Kindred": 221, + "Spirit Kindred": 222, "Mana Rock": 67, "Sacrifice to Draw": 151, - "Toolbox": 123, + "Toolbox": 124, "Unconditional Draw": 248, "Cleric Kindred": 183, "Dog Kindred": 24, @@ -26475,13 +26475,14 @@ "Lord of the Pyrrhian Legions": 1, "Necron Kindred": 25, "Hellbent": 15, - "Frog Kindred": 17, + "Frog Kindred": 18, "Landwalk": 53, "Swampwalk": 29, "Gorgon Kindred": 27, "Snake Kindred": 42, "Crew": 19, "Merfolk Kindred": 17, + "Converge": 4, "Modular": 1, "Turtle Kindred": 8, "Defender": 34, @@ -26933,7 +26934,6 @@ "Eon Counters": 1, "Impending": 1, "Toy Kindred": 2, - "Converge": 3, "Fade Counters": 3, "Fading": 3, "Barbarian Kindred": 5, @@ -27036,20 +27036,20 @@ }, "red": { "Burn": 2169, - "Enchantments Matter": 781, - "Blink": 730, - "Enter the Battlefield": 730, + "Enchantments Matter": 780, + "Blink": 731, + "Enter the Battlefield": 731, "Goblin Kindred": 480, "Guest Kindred": 4, - "Leave the Battlefield": 731, + "Leave the Battlefield": 732, "Little Fellas": 1890, "Mana Dork": 161, "Ramp": 295, - "Aggro": 2358, + "Aggro": 2357, "Astartes Kindred": 8, - "Big Mana": 2058, + "Big Mana": 2059, "Cascade": 28, - "Combat Matters": 2358, + "Combat Matters": 2357, "Exile Matters": 373, "Historics Matter": 1205, "Legends Matter": 1205, @@ -27058,16 +27058,16 @@ "Warrior Kindred": 569, "Card Draw": 725, "Discard Matters": 465, - "Spells Matter": 2178, - "Spellslinger": 2178, + "Spells Matter": 2180, + "Spellslinger": 2180, "Unconditional Draw": 255, - "Combat Tricks": 212, - "Interaction": 967, + "Combat Tricks": 213, + "Interaction": 968, "Madness": 20, "Mill": 634, - "Reanimate": 479, + "Reanimate": 478, "Flashback": 69, - "Artifacts Matter": 1148, + "Artifacts Matter": 1149, "Human Kindred": 1085, "Impulse": 202, "Monk Kindred": 42, @@ -27082,7 +27082,7 @@ "Tokens Matter": 795, "Zombie Kindred": 58, "Removal": 351, - "Toolbox": 137, + "Toolbox": 138, "Deserts Matter": 14, "Land Types Matter": 80, "Lands Matter": 590, @@ -27097,14 +27097,14 @@ "Wheels": 145, "+1/+1 Counters": 535, "Renown": 5, - "Voltron": 916, - "Auras": 245, - "Enchant": 181, + "Voltron": 915, + "Auras": 244, + "Enchant": 180, "Goad": 49, "Rad Counters": 2, "Stax": 508, "Theft": 179, - "Control": 289, + "Control": 290, "Spirit Kindred": 119, "Clash": 5, "Flying": 496, @@ -27199,7 +27199,7 @@ "Raid": 24, "Golem Kindred": 20, "Scry": 68, - "Topdeck": 270, + "Topdeck": 271, "Infect": 12, "Ore Counters": 65, "Vampire Kindred": 96, @@ -27255,6 +27255,7 @@ "Living metal": 6, "More Than Meets the Eye": 7, "Robot Kindred": 40, + "Converge": 4, "Verse Counters": 3, "Sphinx Kindred": 2, "Wolf Kindred": 30, @@ -27635,7 +27636,6 @@ "Training": 2, "Ingenuity Counters": 1, "Token Modification": 4, - "Converge": 3, "Dryad Kindred": 1, "Symphony of Pain": 1, "Sigil of Corruption": 1, @@ -27786,7 +27786,7 @@ "Token Creation": 910, "Tokens Matter": 921, "Ally Kindred": 55, - "Artifacts Matter": 764, + "Artifacts Matter": 765, "Avatar Kindred": 55, "Historics Matter": 1081, "Legends Matter": 1081, @@ -27812,11 +27812,11 @@ "Survivor Kindred": 9, "Zombie Kindred": 60, "Heavy Power Hammer": 1, - "Interaction": 854, - "Little Fellas": 2037, + "Interaction": 855, + "Little Fellas": 2038, "Mutant Kindred": 83, "Ravenous": 9, - "Removal": 423, + "Removal": 424, "Tyranid Kindred": 28, "X Spells": 281, "Stax": 431, @@ -27835,14 +27835,14 @@ "Age Counters": 26, "Cumulative upkeep": 20, "Elemental Kindred": 241, - "Spells Matter": 1592, - "Spellslinger": 1592, + "Spells Matter": 1593, + "Spellslinger": 1593, "Unconditional Draw": 268, "Auras": 309, "Cantrips": 122, "Enchant": 224, "Midrange": 173, - "Spirit Kindred": 140, + "Spirit Kindred": 141, "Mana Rock": 68, "Ramp": 767, "Sacrifice to Draw": 84, @@ -27850,8 +27850,8 @@ "Shaman Kindred": 201, "Toolbox": 185, "Cleric Kindred": 62, - "Life Matters": 657, - "Lifegain": 657, + "Life Matters": 659, + "Lifegain": 659, "Mana Dork": 313, "Lifelink": 76, "Warrior Kindred": 438, @@ -27978,7 +27978,7 @@ "Cascade": 17, "Heroic": 6, "Rooms Matter": 6, - "Frog Kindred": 53, + "Frog Kindred": 54, "Threshold": 26, "God Kindred": 22, "Mole Kindred": 9, @@ -28468,12 +28468,12 @@ "generated_from": "merge (analytics + curated YAML + whitelist)", "metadata_info": { "mode": "merge", - "generated_at": "2026-03-27T17:14:22", - "curated_yaml_files": 735, + "generated_at": "2026-04-04T04:35:11", + "curated_yaml_files": 762, "synergy_cap": 5, "inference": "pmi", "version": "phase-b-merge-v1", - "catalog_hash": "432b90659a971abc5c1a2335fac603c798399e556f4b4c14098795e18f398426" + "catalog_hash": "a43e47eaac087fc871542d09d2dce9b163bea5c37d5dd010661996080caf9258" }, "description_fallback_summary": null } \ No newline at end of file