diff --git a/.gitignore b/.gitignore index 1948e14..ea6405a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ __pycache__/ csv_files/ dist/ logs/ +deck_files/ +csv_files/ +!config/card_lists/*.json !config/deck.json RELEASE_NOTES.md *.bkp \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 88cd3c5..daa3b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] +## [2.2.1] - 2025-09-01 +### Added +- Combos & Synergies: detect curated two-card combos/synergies and surface them in a chip-style panel with badges (cheap/early, setup) on Step 5 and Finished Decks. +- Dual hover previews for combo rows: hovering a combo shows both cards side-by-side in the standard preview popout; individual names still preview a single card. +- Headless (Web Configs): JSON configs now persist and honor combo preferences: + - `prefer_combos` (bool) + - `combo_target_count` (int) + - `combo_balance` ("early" | "late" | "mix") + Exported interactive run-config JSON includes these fields when used. +- Finished Deck summary includes detected combos/synergies and curated list version badges. +- When `prefer_combos` is enabled, Auto-Complete Combos runs before theme fill/monolithic spells so partners aren’t clamped away. Existing completed pairs count toward the target before adding partners. +- Step 5 Combos panel updated to the same chip-style as Finished Decks for consistency. +- Auto-combos respect color identity by resolving from the filtered pool only; off-color/unavailable partners are skipped. +- Added type/mana enrichment for auto-added partners and lock placeholders to avoid “Other” category leakage. + ## [2.1.1] - 2025-08-29 ### Added - Multi-copy archetypes (Web): opt-in modal suggests packages like Persistent Petitioners, Dragon's Approach, and Shadowborn Apostle when viable; choose quantity and optionally add Thrumming Stone. Applied as the first stage with ideal count adjustments and a per-stage 100-card safety clamp. UI surfaces adjustments and a clamp chip. diff --git a/README.md b/README.md index f63fe22..c12c58f 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 78e5827..c56d7bf 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,119 +1,35 @@ # MTG Python Deckbuilder ${VERSION} ## Highlights -- New Web UI: FastAPI + Jinja front-end with a staged build view and clear reasons per stage. Step 2 now includes AND/OR combine mode with tooltips and selection-order display. Footer includes Scryfall attribution per their guidelines. -- AND/OR combine mode: OR (default) recommends across any selected themes with overlap preference; AND prioritizes multi-theme intersections. In creatures, an AND pre-pass selects "all selected themes" creatures first, then fills by weighted overlap. Staged reasons show which selected themes each all-theme creature hits. -- Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config. -- Owned cards workflow: Prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. Owned-only builds filter the pool; if the deck can't reach 100, it remains incomplete and notes it. When not owned-only, owned cards are marked with an `Owned` column in the final CSV. -- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`). -- Theming: Consolidated Light (Blend) as the only light palette; default THEME can be system|light|dark. Header includes a Reset Theme control to clear saved preference; diagnostics shows resolved theme and stored preference. -- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`. -- Web setup speed: initial tagging runs in parallel by default for the Web UI. Configure with `WEB_TAG_PARALLEL=1|0` and `WEB_TAG_WORKERS=` (compose default: 4). Falls back to sequential if parallel init fails. - - Phase 8 UI upgrade: Unified “New Deck” modal (steps 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name. - - Compare page now includes a Copy summary button to quickly share diffs. - - New Deck modal: shows selected themes and their order (1, 2, 3) inline while picking. - - Commander search UX: press Enter to select the first suggestion; arrow key navigation removed per feedback; browser autofill disabled. - - Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources. - - Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback). - - Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts. - - Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads. - - Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation. - - Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label. - - Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews. - - Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesn’t shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability. - - Finished Decks: banner and lists prefer the run’s custom Name when provided; runs include a sidecar `.summary.json` with `meta.name` for display. - - Replace toggle includes a tooltip explaining that reruns will replace that stage’s picks when enabled. - - Bracket selector labels now include numbers (e.g., "Bracket 3: Upgraded"). Default bracket is 3 when creating a new deck. - - Exports: CSV/TXT/JSON now share the same filename stem derived from the optional Name in the modal. - -### Diagnostics and error handling -- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`. -- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation. -- Friendly HTML error pages for 404/4xx/500 with a "Go home" link (browser requests). -- Feature flags: `SHOW_DIAGNOSTICS=1` to enable a diagnostics page with test tools; `SHOW_LOGS=1` to enable a logs page and `/status/logs?tail=N`. - - Diagnostics page surfaces resolved theme and preference, with a Reset preference action. +- Combos & Synergies: detect curated two-card combos and synergies, surface them in a unified chip-style panel on Step 5 and Finished Decks, and preview both cards on hover. +- Auto-Complete Combos: optional mode that adds missing partners up to a target before theme fill/monolithic spells so added pairs persist. ## What’s new -- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel. -- Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode. - - Reporting: deck summary includes per-color card lists for Pips and Sources; colorless 'C' surfaced and totals corrected. - - UI Polish: list-mode highlight wraps only the card name. Chart tooltips include a Copy action with hover persistence. - - Exports: CSV gains fallback oracle text for basic lands (Plains/Island/Swamp/Mountain/Forest/Wastes) when missing. -- Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`). - - Prefer-owned bias across creatures and spells selections; Review step includes a toggle next to the owned-only control. - - Owned page features and performance improvements via upload-time enrichment and persistence. - - Staged build UI can surface skipped stages when enabled. +- Detection: exact two-card combos and curated synergies with list version badges (combos.json/synergies.json). +- UI polish: + - Chip-style rows with compact badges (cheap/early, setup) in both the end-of-build panel and finished deck summary. + - Dual-card hover: moving your mouse over a combo row previews both cards side-by-side; hovering a single name shows that card alone. +- Ordering: when enabled, Auto-Complete Combos runs earlier (before theme fill and monolithic spells) to retain partners. +- Enforcement: + - Color identity respected via the filtered pool; off-color or unavailable partners are skipped gracefully. + - Honors Locks, Owned-only, and Replace toggles. +- Persistence & Headless parity: + - Interactive runs export these JSON fields and Web headless runs accept them: + - prefer_combos (bool) + - combo_target_count (int) + - combo_balance ("early" | "late" | "mix") -## Docker -- CLI and Web UI in the same image. -- docker-compose includes a `web` service exposing port 8080 by default. -- Persistent volumes: - - /app/deck_files - - /app/logs - - /app/csv_files - - /app/owned_cards - - /app/config - -### Web UI performance tuning -- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging during initial setup/tagging in the Web UI -- `WEB_TAG_WORKERS=` — number of worker processes (omit to auto-pick; compose default: 4) - -### Quick Start -```powershell -# CLI from Docker Hub -docker run -it --rm ` - -v "${PWD}/deck_files:/app/deck_files" ` - -v "${PWD}/logs:/app/logs" ` - -v "${PWD}/csv_files:/app/csv_files" ` - -v "${PWD}/owned_cards:/app/owned_cards" ` - -v "${PWD}/config:/app/config" ` - mwisnowski/mtg-python-deckbuilder:latest - -# Web UI from Docker Hub -docker run --rm ` - -p 8080:8080 ` - -v "${PWD}/deck_files:/app/deck_files" ` - -v "${PWD}/logs:/app/logs" ` - -v "${PWD}/csv_files:/app/csv_files" ` - -v "${PWD}/owned_cards:/app/owned_cards" ` - -v "${PWD}/config:/app/config" ` - mwisnowski/mtg-python-deckbuilder:latest ` - bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" - -# From source with Compose (CLI) -docker compose build -docker compose run --rm mtg-deckbuilder - -# From source with Compose (Web) -docker compose build web -docker compose up --no-deps web +## JSON (Web Configs) — example +```json +{ + "prefer_combos": true, + "combo_target_count": 3, + "combo_balance": "mix" +} ``` -## Changes -- Web UI: staged view, Step 2 AND/OR radios with tips, selection order display, improved Why panel readability, and Scryfall attribution footer. -- Builder: AND-mode creatures pre-pass with matched-themes reasons; spells prefer overlap in AND mode. +## Notes +- Curated list versions are displayed in the UI for transparency. +- Existing completed pairs are counted toward the target; only missing partners are added. +- No changes to CLI inputs for this feature in this release. - Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON. -- Docs: README, DOCKER, and Windows Docker guide updated; PowerShell-friendly examples. -- Docker: compose `web` service added; volumes clarified. - - Visual summaries and diagnostics: added `/healthz` endpoint with version/uptime and request-id propagation on all responses. - - Review step consolidates owned-only and prefer-owned controls; Step 5 is status-only with an "Edit in Review" link for changes. - - Owned lists processing moved to upload-time in Web; per-request parsing removed. Enriched store powers fast Owned page and deck-building. - - Finished Decks page uses a dropdown theme filter with shareable state. - - Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity. - - Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths. - - Data integrity: per-color/guild CSVs now consistently respect the Commander banned list using exact, case-insensitive name/faceName matching. - -### Tagging updates -- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter. -- Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood. -- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters; Energy enriched. -- Spawn/Scion creators now map to Aristocrats and Ramp. - -## Known Issues -- First run downloads card data (takes a few minutes) -- Use `docker compose run --rm` (not `up`) for interactive CLI sessions -- Ensure volumes are mounted to persist files outside the container - -## Links -- Repo: https://github.com/mwisnowski/mtg_python_deckbuilder -- Issues: https://github.com/mwisnowski/mtg_python_deckbuilder/issues diff --git a/code/deck_builder/__init__.py b/code/deck_builder/__init__.py index 3f168f8..c992bac 100644 --- a/code/deck_builder/__init__.py +++ b/code/deck_builder/__init__.py @@ -1,7 +1,9 @@ -from .builder import DeckBuilder -from .builder_utils import * -from .builder_constants import * +__all__ = ['DeckBuilder'] -__all__ = [ - 'DeckBuilder', -] \ No newline at end of file + +def __getattr__(name): + # Lazy-load DeckBuilder to avoid side effects during import of submodules + if name == 'DeckBuilder': + from .builder import DeckBuilder # type: ignore + return DeckBuilder + raise AttributeError(name) diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 7efe2a4..a33c217 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1024,6 +1024,24 @@ class DeckBuilder( return except Exception: pass + + # Enforce color identity / card-pool legality: if the card is not present in the + # current dataframes snapshot (which is filtered by color identity), skip it. + # Allow the commander to bypass this check. + try: + if not is_commander: + df_src = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df + if df_src is not None and not df_src.empty and 'name' in df_src.columns: + if df_src[df_src['name'].astype(str).str.lower() == str(card_name).lower()].empty: + # Not in the legal pool (likely off-color or unavailable) + try: + self.output_func(f"Skipped illegal/off-pool card: {card_name}") + except Exception: + pass + return + except Exception: + # If any unexpected error occurs, fall through (do not block legitimate adds) + pass if creature_types is None: creature_types = [] if tags is None: @@ -1094,6 +1112,19 @@ class DeckBuilder( tags = [p for p in parts if p] except Exception: pass + # Enrich missing type and mana_cost for accurate categorization + if (not card_type) or (not mana_cost): + try: + df_src = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df + if df_src is not None and not df_src.empty and 'name' in df_src.columns: + row_match2 = df_src[df_src['name'].astype(str).str.lower() == str(card_name).lower()] + if not row_match2.empty: + if not card_type: + card_type = str(row_match2.iloc[0].get('type', row_match2.iloc[0].get('type_line', '')) or '') + if not mana_cost: + mana_cost = str(row_match2.iloc[0].get('mana_cost', row_match2.iloc[0].get('manaCost', '')) or '') + except Exception: + pass # Normalize & dedupe tags norm_tags: list[str] = [] seen_tag = set() diff --git a/code/deck_builder/combos.py b/code/deck_builder/combos.py new file mode 100644 index 0000000..60c9e8b --- /dev/null +++ b/code/deck_builder/combos.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional + +from tagging.combo_schema import ( + load_and_validate_combos, + load_and_validate_synergies, + CombosListModel, + SynergiesListModel, +) + + +def _canonicalize(name: str) -> str: + s = str(name or "").strip() + s = s.replace("\u2019", "'").replace("\u2018", "'") + s = s.replace("\u201C", '"').replace("\u201D", '"') + s = s.replace("\u2013", "-").replace("\u2014", "-") + s = " ".join(s.split()) + return s + + +@dataclass(frozen=True) +class DetectedCombo: + a: str + b: str + cheap_early: bool + setup_dependent: bool + tags: Optional[List[str]] = None + + +@dataclass(frozen=True) +class DetectedSynergy: + a: str + b: str + tags: Optional[List[str]] = None + + +def _detect_combos_from_model(names_norm: set[str], combos: CombosListModel) -> List[DetectedCombo]: + out: List[DetectedCombo] = [] + for p in combos.pairs: + a = _canonicalize(p.a).casefold() + b = _canonicalize(p.b).casefold() + if a in names_norm and b in names_norm: + out.append( + DetectedCombo( + a=p.a, + b=p.b, + cheap_early=bool(p.cheap_early), + setup_dependent=bool(p.setup_dependent), + tags=list(p.tags or []), + ) + ) + return out + + +def detect_combos(names: Iterable[str], combos_path: str | Path = "config/card_lists/combos.json") -> List[DetectedCombo]: + names_norm = set() + for n in names: + c = _canonicalize(n).casefold() + if not c: + continue + names_norm.add(c) + + if not names_norm: + return [] + + combos = load_and_validate_combos(combos_path) + return _detect_combos_from_model(names_norm, combos) + + +def _detect_synergies_from_model(names_norm: set[str], syn: SynergiesListModel) -> List[DetectedSynergy]: + out: List[DetectedSynergy] = [] + for p in syn.pairs: + a = _canonicalize(p.a).casefold() + b = _canonicalize(p.b).casefold() + if a in names_norm and b in names_norm: + out.append(DetectedSynergy(a=p.a, b=p.b, tags=list(p.tags or []))) + return out + + +def detect_synergies(names: Iterable[str], synergies_path: str | Path = "config/card_lists/synergies.json") -> List[DetectedSynergy]: + names_norm = {_canonicalize(n).casefold() for n in names if str(n).strip()} + if not names_norm: + return [] + syn = load_and_validate_synergies(synergies_path) + return _detect_synergies_from_model(names_norm, syn) + diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 93e394d..7bd3058 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -704,6 +704,10 @@ class ReportingMixin: "add_lands": True, "add_creatures": True, "add_non_creature_spells": True, + # Combos preferences (if set during build) + "prefer_combos": bool(getattr(self, 'prefer_combos', False)), + "combo_target_count": (int(getattr(self, 'combo_target_count', 0)) if getattr(self, 'prefer_combos', False) else None), + "combo_balance": (getattr(self, 'combo_balance', None) if getattr(self, 'prefer_combos', False) else None), # chosen fetch land count (others intentionally omitted for variance) "fetch_count": chosen_fetch, # actual ideal counts used for this run diff --git a/code/tagging/combo_schema.py b/code/tagging/combo_schema.py new file mode 100644 index 0000000..e0129a4 --- /dev/null +++ b/code/tagging/combo_schema.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + +import json +from pydantic import BaseModel, Field + + +class ComboPairModel(BaseModel): + a: str + b: str + cheap_early: bool = False + setup_dependent: bool = False + tags: List[str] | None = None + notes: Optional[str] = None + + +class CombosListModel(BaseModel): + list_version: str + generated_at: Optional[str] = None + pairs: List[ComboPairModel] = Field(default_factory=list) + + +class SynergyPairModel(BaseModel): + a: str + b: str + tags: List[str] | None = None + notes: Optional[str] = None + + +class SynergiesListModel(BaseModel): + list_version: str + generated_at: Optional[str] = None + pairs: List[SynergyPairModel] = Field(default_factory=list) + + +def load_and_validate_combos(path: str | Path) -> CombosListModel: + obj = json.loads(Path(path).read_text(encoding="utf-8")) + return CombosListModel.model_validate(obj) + + +def load_and_validate_synergies(path: str | Path) -> SynergiesListModel: + obj = json.loads(Path(path).read_text(encoding="utf-8")) + return SynergiesListModel.model_validate(obj) diff --git a/code/tagging/combo_tag_applier.py b/code/tagging/combo_tag_applier.py new file mode 100644 index 0000000..52d7496 --- /dev/null +++ b/code/tagging/combo_tag_applier.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +import ast +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Set, DefaultDict +from collections import defaultdict + +import pandas as pd + +from settings import CSV_DIRECTORY, SETUP_COLORS + + +@dataclass(frozen=True) +class ComboPair: + a: str + b: str + cheap_early: bool = False + setup_dependent: bool = False + tags: List[str] | None = None + + +def _load_pairs(path: Path) -> List[ComboPair]: + data = json.loads(path.read_text(encoding="utf-8")) + pairs = [] + for entry in data.get("pairs", []): + pairs.append( + ComboPair( + a=entry["a"].strip(), + b=entry["b"].strip(), + cheap_early=bool(entry.get("cheap_early", False)), + setup_dependent=bool(entry.get("setup_dependent", False)), + tags=list(entry.get("tags", [])), + ) + ) + return pairs + + +def _canonicalize(name: str) -> str: + # Canonicalize for matching: trim, unify punctuation/quotes, collapse spaces, casefold later + if name is None: + return "" + s = str(name).strip() + # Normalize common unicode punctuation variants + s = s.replace("\u2019", "'") # curly apostrophe to straight + s = s.replace("\u2018", "'") + s = s.replace("\u201C", '"').replace("\u201D", '"') + s = s.replace("\u2013", "-").replace("\u2014", "-") # en/em dash -> hyphen + # Collapse multiple spaces + s = " ".join(s.split()) + return s + + +def _ensure_combo_cols(df: pd.DataFrame) -> None: + if "comboTags" not in df.columns: + df["comboTags"] = [[] for _ in range(len(df))] + + +def _apply_partner_to_names(df: pd.DataFrame, target_names: Set[str], partner: str) -> None: + if not target_names: + return + mask = df["name"].isin(target_names) + if not mask.any(): + return + current = df.loc[mask, "comboTags"] + df.loc[mask, "comboTags"] = current.apply( + lambda tags: sorted(list({*tags, partner})) if isinstance(tags, list) else [partner] + ) + + +def _safe_list_parse(s: object) -> List[str]: + if isinstance(s, list): + return s + if not isinstance(s, str) or not s.strip(): + return [] + txt = s.strip() + # Try JSON first + try: + v = json.loads(txt) + if isinstance(v, list): + return v + except Exception: + pass + # Fallback to Python literal + try: + v = ast.literal_eval(txt) + if isinstance(v, list): + return v + except Exception: + pass + return [] + + +def apply_combo_tags(colors: List[str] | None = None, combos_path: str | Path = "config/card_lists/combos.json", csv_dir: str | Path | None = None) -> Dict[str, int]: + """Apply bidirectional comboTags to per-color CSVs based on combos.json. + + Returns a dict of color->updated_row_count for quick reporting. + """ + colors = colors or list(SETUP_COLORS) + combos_file = Path(combos_path) + pairs = _load_pairs(combos_file) + + updated_counts: Dict[str, int] = {} + base_dir = Path(csv_dir) if csv_dir is not None else Path(CSV_DIRECTORY) + for color in colors: + csv_path = base_dir / f"{color}_cards.csv" + if not csv_path.exists(): + continue + df = pd.read_csv(csv_path, converters={ + "themeTags": _safe_list_parse, + "creatureTypes": _safe_list_parse, + "comboTags": _safe_list_parse, + }) + + _ensure_combo_cols(df) + before_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() + + # Build an index of canonicalized keys -> actual DF row names to update. + name_index: DefaultDict[str, Set[str]] = defaultdict(set) + for nm in df["name"].astype(str).tolist(): + canon = _canonicalize(nm) + cf = canon.casefold() + name_index[cf].add(nm) + # If split/fused faces exist, map each face to the combined row name as well + if " // " in canon: + for part in canon.split(" // "): + p = part.strip().casefold() + if p: + name_index[p].add(nm) + + for p in pairs: + a = _canonicalize(p.a) + b = _canonicalize(p.b) + a_key = a.casefold() + b_key = b.casefold() + # Apply A<->B bidirectionally to any matching DF rows + _apply_partner_to_names(df, name_index.get(a_key, set()), b) + _apply_partner_to_names(df, name_index.get(b_key, set()), a) + + after_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() + if before_hash != after_hash: + df.to_csv(csv_path, index=False) + updated_counts[color] = int((df["comboTags"].apply(bool)).sum()) + + return updated_counts + + +if __name__ == "__main__": + counts = apply_combo_tags() + print("Updated comboTags counts:") + for k, v in counts.items(): + print(f" {k}: {v}") diff --git a/code/tests/test_combo_schema_validation.py b/code/tests/test_combo_schema_validation.py new file mode 100644 index 0000000..74e6359 --- /dev/null +++ b/code/tests/test_combo_schema_validation.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tagging.combo_schema import ( + load_and_validate_combos, + load_and_validate_synergies, +) + + +def test_validate_combos_schema_ok(tmp_path: Path): + combos_dir = tmp_path / "config" / "card_lists" + combos_dir.mkdir(parents=True) + combos = { + "list_version": "0.1.0", + "generated_at": None, + "pairs": [ + {"a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": True, "tags": ["wincon"]}, + {"a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts", "setup_dependent": False}, + ], + } + path = combos_dir / "combos.json" + path.write_text(json.dumps(combos), encoding="utf-8") + model = load_and_validate_combos(str(path)) + assert len(model.pairs) == 2 + assert model.pairs[0].a == "Thassa's Oracle" + + +def test_validate_synergies_schema_ok(tmp_path: Path): + syn_dir = tmp_path / "config" / "card_lists" + syn_dir.mkdir(parents=True) + syn = { + "list_version": "0.1.0", + "generated_at": None, + "pairs": [ + {"a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats"]}, + ], + } + path = syn_dir / "synergies.json" + path.write_text(json.dumps(syn), encoding="utf-8") + model = load_and_validate_synergies(str(path)) + assert len(model.pairs) == 1 + assert model.pairs[0].b == "Phyrexian Altar" + + +def test_validate_combos_schema_invalid(tmp_path: Path): + combos_dir = tmp_path / "config" / "card_lists" + combos_dir.mkdir(parents=True) + invalid = { + "list_version": "0.1.0", + "pairs": [ + {"a": 123, "b": "Demonic Consultation"}, # a must be str + ], + } + path = combos_dir / "bad_combos.json" + path.write_text(json.dumps(invalid), encoding="utf-8") + with pytest.raises(Exception): + load_and_validate_combos(str(path)) diff --git a/code/tests/test_combo_tag_applier.py b/code/tests/test_combo_tag_applier.py new file mode 100644 index 0000000..6fe7c30 --- /dev/null +++ b/code/tests/test_combo_tag_applier.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +from tagging.combo_tag_applier import apply_combo_tags + + +def _write_csv(dirpath: Path, color: str, rows: list[dict]): + df = pd.DataFrame(rows) + df.to_csv(dirpath / f"{color}_cards.csv", index=False) + + +def test_apply_combo_tags_bidirectional(tmp_path: Path): + # Arrange: create a minimal CSV for blue with two combo cards + csv_dir = tmp_path / "csv" + csv_dir.mkdir(parents=True) + rows = [ + {"name": "Thassa's Oracle", "themeTags": "[]", "creatureTypes": "[]"}, + {"name": "Demonic Consultation", "themeTags": "[]", "creatureTypes": "[]"}, + {"name": "Zealous Conscripts", "themeTags": "[]", "creatureTypes": "[]"}, + ] + _write_csv(csv_dir, "blue", rows) + + # And a combos.json in a temp location + combos_dir = tmp_path / "config" / "card_lists" + combos_dir.mkdir(parents=True) + combos = { + "list_version": "0.1.0", + "generated_at": None, + "pairs": [ + {"a": "Thassa's Oracle", "b": "Demonic Consultation"}, + {"a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts"}, + ], + } + combos_path = combos_dir / "combos.json" + combos_path.write_text(json.dumps(combos), encoding="utf-8") + + # Act + counts = apply_combo_tags(colors=["blue"], combos_path=str(combos_path), csv_dir=str(csv_dir)) + + # Assert + assert counts.get("blue", 0) > 0 + df = pd.read_csv(csv_dir / "blue_cards.csv") + # Oracle should list Consultation + row_oracle = df[df["name"] == "Thassa's Oracle"].iloc[0] + assert "Demonic Consultation" in row_oracle["comboTags"] + # Consultation should list Oracle + row_consult = df[df["name"] == "Demonic Consultation"].iloc[0] + assert "Thassa's Oracle" in row_consult["comboTags"] + # Zealous Conscripts is present but not its partner in this CSV; we still record the partner name + row_conscripts = df[df["name"] == "Zealous Conscripts"].iloc[0] + assert "Kiki-Jiki, Mirror Breaker" in row_conscripts.get("comboTags") + + +def test_name_normalization_curly_apostrophes(tmp_path: Path): + csv_dir = tmp_path / "csv" + csv_dir.mkdir(parents=True) + # Use curly apostrophe in CSV name, straight in combos + rows = [ + {"name": "Thassa’s Oracle", "themeTags": "[]", "creatureTypes": "[]"}, + {"name": "Demonic Consultation", "themeTags": "[]", "creatureTypes": "[]"}, + ] + _write_csv(csv_dir, "blue", rows) + + combos_dir = tmp_path / "config" / "card_lists" + combos_dir.mkdir(parents=True) + combos = { + "list_version": "0.1.0", + "generated_at": None, + "pairs": [{"a": "Thassa's Oracle", "b": "Demonic Consultation"}], + } + combos_path = combos_dir / "combos.json" + combos_path.write_text(json.dumps(combos), encoding="utf-8") + + counts = apply_combo_tags(colors=["blue"], combos_path=str(combos_path), csv_dir=str(csv_dir)) + assert counts.get("blue", 0) >= 1 + df = pd.read_csv(csv_dir / "blue_cards.csv") + row = df[df["name"] == "Thassa’s Oracle"].iloc[0] + assert "Demonic Consultation" in row["comboTags"] + + +def test_split_card_face_matching(tmp_path: Path): + csv_dir = tmp_path / "csv" + csv_dir.mkdir(parents=True) + # Card stored as split name in CSV + rows = [ + {"name": "Fire // Ice", "themeTags": "[]", "creatureTypes": "[]"}, + {"name": "Isochron Scepter", "themeTags": "[]", "creatureTypes": "[]"}, + ] + _write_csv(csv_dir, "izzet", rows) + + combos_dir = tmp_path / "config" / "card_lists" + combos_dir.mkdir(parents=True) + combos = { + "list_version": "0.1.0", + "generated_at": None, + "pairs": [{"a": "Ice", "b": "Isochron Scepter"}], + } + combos_path = combos_dir / "combos.json" + combos_path.write_text(json.dumps(combos), encoding="utf-8") + + counts = apply_combo_tags(colors=["izzet"], combos_path=str(combos_path), csv_dir=str(csv_dir)) + assert counts.get("izzet", 0) >= 1 + df = pd.read_csv(csv_dir / "izzet_cards.csv") + row = df[df["name"] == "Fire // Ice"].iloc[0] + assert "Isochron Scepter" in row["comboTags"] diff --git a/code/tests/test_detect_combos.py b/code/tests/test_detect_combos.py new file mode 100644 index 0000000..9bb9327 --- /dev/null +++ b/code/tests/test_detect_combos.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from deck_builder.combos import detect_combos, detect_synergies + + +def _write_json(path: Path, obj: dict): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(obj), encoding="utf-8") + + +def test_detect_combos_positive(tmp_path: Path): + combos = { + "list_version": "0.1.0", + "pairs": [ + {"a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": True, "tags": ["wincon"]}, + {"a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts"}, + ], + } + cpath = tmp_path / "config/card_lists/combos.json" + _write_json(cpath, combos) + + deck = ["Thassa’s Oracle", "Demonic Consultation", "Island"] + found = detect_combos(deck, combos_path=str(cpath)) + assert any((fc.a.startswith("Thassa") and fc.b.startswith("Demonic")) for fc in found) + assert any(fc.cheap_early for fc in found) + + +def test_detect_synergies_positive(tmp_path: Path): + syn = { + "list_version": "0.1.0", + "pairs": [ + {"a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats"]}, + ], + } + spath = tmp_path / "config/card_lists/synergies.json" + _write_json(spath, syn) + + deck = ["Swamp", "Grave Pact", "Phyrexian Altar"] + found = detect_synergies(deck, synergies_path=str(spath)) + assert any((fs.a == "Grave Pact" and fs.b == "Phyrexian Altar") for fs in found) + + +def test_detect_combos_negative(tmp_path: Path): + combos = {"list_version": "0.1.0", "pairs": [{"a": "A", "b": "B"}]} + cpath = tmp_path / "config/card_lists/combos.json" + _write_json(cpath, combos) + found = detect_combos(["A"], combos_path=str(cpath)) + assert not found diff --git a/code/tests/test_detect_combos_expanded.py b/code/tests/test_detect_combos_expanded.py new file mode 100644 index 0000000..2ff8ee9 --- /dev/null +++ b/code/tests/test_detect_combos_expanded.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from deck_builder.combos import detect_combos + + +def test_detect_expanded_pairs(): + names = [ + "Isochron Scepter", + "Dramatic Reversal", + "Basalt Monolith", + "Rings of Brighthearth", + "Some Other Card", + ] + combos = detect_combos(names, combos_path="config/card_lists/combos.json") + found = {(c.a, c.b) for c in combos} + assert ("Isochron Scepter", "Dramatic Reversal") in found + assert ("Basalt Monolith", "Rings of Brighthearth") in found diff --git a/code/tests/test_detect_combos_more_new.py b/code/tests/test_detect_combos_more_new.py new file mode 100644 index 0000000..7bfb054 --- /dev/null +++ b/code/tests/test_detect_combos_more_new.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from deck_builder.combos import detect_combos + + +def test_detect_more_new_pairs(): + names = [ + "Godo, Bandit Warlord", + "Helm of the Host", + "Narset, Parter of Veils", + "Windfall", + "Grand Architect", + "Pili-Pala", + ] + combos = detect_combos(names, combos_path="config/card_lists/combos.json") + pairs = {(c.a, c.b) for c in combos} + assert ("Godo, Bandit Warlord", "Helm of the Host") in pairs + assert ("Narset, Parter of Veils", "Windfall") in pairs + assert ("Grand Architect", "Pili-Pala") in pairs diff --git a/code/tests/test_diagnostics_combos_api.py b/code/tests/test_diagnostics_combos_api.py new file mode 100644 index 0000000..5746cfa --- /dev/null +++ b/code/tests/test_diagnostics_combos_api.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from starlette.testclient import TestClient + + +def _write_json(path: Path, obj: dict): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(obj), encoding="utf-8") + + +def test_diagnostics_combos_endpoint(tmp_path: Path, monkeypatch): + # Enable diagnostics + monkeypatch.setenv("SHOW_DIAGNOSTICS", "1") + + # Lazy import app after env set + import importlib + import code.web.app as app_module + importlib.reload(app_module) + + client = TestClient(app_module.app) + + cpath = tmp_path / "config/card_lists/combos.json" + spath = tmp_path / "config/card_lists/synergies.json" + _write_json( + cpath, + { + "list_version": "0.1.0", + "pairs": [ + {"a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": True, "setup_dependent": False} + ], + }, + ) + _write_json( + spath, + { + "list_version": "0.1.0", + "pairs": [{"a": "Grave Pact", "b": "Phyrexian Altar"}], + }, + ) + + payload = { + "names": ["Thassa’s Oracle", "Demonic Consultation", "Grave Pact", "Phyrexian Altar"], + "combos_path": str(cpath), + "synergies_path": str(spath), + } + resp = client.post("/diagnostics/combos", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["counts"]["combos"] == 1 + assert data["counts"]["synergies"] == 1 + assert data["versions"]["combos"] == "0.1.0" + # Ensure flags are present from payload + c = data["combos"][0] + assert c.get("cheap_early") is True + assert c.get("setup_dependent") is False \ No newline at end of file diff --git a/code/tests/test_diagnostics_page.py b/code/tests/test_diagnostics_page.py new file mode 100644 index 0000000..f363f99 --- /dev/null +++ b/code/tests/test_diagnostics_page.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import importlib +from starlette.testclient import TestClient + + +def test_diagnostics_page_gated_and_visible(monkeypatch): + # Ensure disabled first + monkeypatch.delenv("SHOW_DIAGNOSTICS", raising=False) + import code.web.app as app_module + importlib.reload(app_module) + client = TestClient(app_module.app) + r = client.get("/diagnostics") + assert r.status_code == 404 + + # Enabled: should render + monkeypatch.setenv("SHOW_DIAGNOSTICS", "1") + importlib.reload(app_module) + client2 = TestClient(app_module.app) + r2 = client2.get("/diagnostics") + assert r2.status_code == 200 + body = r2.text + assert "Diagnostics" in body + assert "Combos & Synergies" in body diff --git a/code/web/app.py b/code/web/app.py index 382bba0..a335de6 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -2,6 +2,11 @@ from __future__ import annotations from fastapi import FastAPI, Request, HTTPException, Query from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response +from deck_builder.combos import ( + detect_combos as _detect_combos, + detect_synergies as _detect_synergies, +) +from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from pathlib import Path @@ -396,3 +401,42 @@ async def diagnostics_perf(request: Request) -> HTMLResponse: if not SHOW_DIAGNOSTICS: raise HTTPException(status_code=404, detail="Not Found") return templates.TemplateResponse("diagnostics/perf.html", {"request": request}) + +# --- Diagnostics: combos & synergies --- +@app.post("/diagnostics/combos") +async def diagnostics_combos(request: Request) -> JSONResponse: + if not SHOW_DIAGNOSTICS: + raise HTTPException(status_code=404, detail="Diagnostics disabled") + try: + payload = await request.json() + except Exception: + payload = {} + names = payload.get("names") or [] + combos_path = payload.get("combos_path") or "config/card_lists/combos.json" + synergies_path = payload.get("synergies_path") or "config/card_lists/synergies.json" + + combos_model = _load_combos(combos_path) + synergies_model = _load_synergies(synergies_path) + combos = _detect_combos(names, combos_path=combos_path) + synergies = _detect_synergies(names, synergies_path=synergies_path) + + def as_dict_combo(c): + return { + "a": c.a, + "b": c.b, + "cheap_early": bool(c.cheap_early), + "setup_dependent": bool(c.setup_dependent), + "tags": list(c.tags or []), + } + + def as_dict_syn(s): + return {"a": s.a, "b": s.b, "tags": list(s.tags or [])} + + return JSONResponse( + { + "counts": {"combos": len(combos), "synergies": len(synergies)}, + "versions": {"combos": combos_model.list_version, "synergies": synergies_model.list_version}, + "combos": [as_dict_combo(c) for c in combos], + "synergies": [as_dict_syn(s) for s in synergies], + } + ) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index a78918d..aa58d01 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -10,6 +10,8 @@ from ..services.tasks import get_session, new_sid from html import escape as _esc from deck_builder.builder import DeckBuilder from deck_builder import builder_utils as bu +from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies +from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies router = APIRouter(prefix="/build") @@ -67,6 +69,9 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None: locks=locks, custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), ) except Exception: # If rebuild fails (e.g., commander not found in test), fall back to injecting @@ -287,6 +292,8 @@ async def multicopy_save( return HTMLResponse(chip) + + # Unified "New Deck" modal (steps 1–3 condensed) @router.get("/new", response_class=HTMLResponse) async def build_new_modal(request: Request) -> HTMLResponse: @@ -350,6 +357,9 @@ async def build_new_submit( wipes: int = Form(None), card_advantage: int = Form(None), protection: int = Form(None), + prefer_combos: bool = Form(False), + combo_count: int | None = Form(None), + combo_balance: str | None = Form(None), ) -> HTMLResponse: """Handle New Deck modal submit and immediately start the build (skip separate review page).""" sid = request.cookies.get("sid") or new_sid() @@ -372,6 +382,9 @@ async def build_new_submit( "tertiary_tag": tertiary_tag or "", "tag_mode": tag_mode or "AND", "bracket": bracket, + "combo_count": combo_count, + "combo_balance": (combo_balance or "mix"), + "prefer_combos": bool(prefer_combos), } } resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) @@ -416,6 +429,24 @@ async def build_new_submit( except Exception: pass sess["ideals"] = ideals + # Persist preferences + try: + sess["prefer_combos"] = bool(prefer_combos) + except Exception: + sess["prefer_combos"] = False + # Combos config from modal + try: + if combo_count is not None: + sess["combo_target_count"] = max(0, min(10, int(combo_count))) + except Exception: + pass + try: + if combo_balance: + bval = str(combo_balance).strip().lower() + if bval in ("early","late","mix"): + sess["combo_balance"] = bval + except Exception: + pass # Clear any old staged build context for k in ["build_ctx", "locks", "replace_mode"]: if k in sess: @@ -465,6 +496,9 @@ async def build_new_submit( locks=list(sess.get("locks", [])), custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), ) res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) status = "Build complete" if res.get("done") else "Stage complete" @@ -500,6 +534,9 @@ async def build_new_submit( "skipped": bool(res.get("skipped")), "locks": list(sess.get("locks", [])), "replace_mode": bool(sess.get("replace_mode", True)), + "prefer_combos": bool(sess.get("prefer_combos")), + "combo_target_count": int(sess.get("combo_target_count", 2)), + "combo_balance": str(sess.get("combo_balance", "mix")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -707,6 +744,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo locks=list(sess.get("locks", [])), custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), ) ctx = sess["build_ctx"] # Run forward until reaching target @@ -748,6 +788,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo "locks": list(sess.get("locks", [])), "replace_mode": bool(sess.get("replace_mode", True)), "history": ctx.get("history", []), + "prefer_combos": bool(sess.get("prefer_combos")), + "combo_target_count": int(sess.get("combo_target_count", 2)), + "combo_balance": str(sess.get("combo_balance", "mix")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -990,6 +1033,183 @@ async def build_step4_get(request: Request) -> HTMLResponse: ) +# --- Combos & Synergies panel (M3) --- +def _get_current_deck_names(sess: dict) -> list[str]: + try: + ctx = sess.get("build_ctx") or {} + b = ctx.get("builder") + lib = getattr(b, "card_library", {}) if b is not None else {} + names = [str(n) for n in lib.keys()] + return sorted(dict.fromkeys(names)) + except Exception: + return [] + + +@router.get("/combos", response_class=HTMLResponse) +async def build_combos_panel(request: Request) -> HTMLResponse: + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + names = _get_current_deck_names(sess) + if not names: + # No active build; render nothing to avoid UI clutter + return HTMLResponse("") + + # Preferences (persisted in session) + policy = (sess.get("combos_policy") or "neutral").lower() + if policy not in {"avoid", "neutral", "prefer"}: + policy = "neutral" + try: + target = int(sess.get("combos_target") or 0) + except Exception: + target = 0 + if target < 0: + target = 0 + + # Load lists and run detection + try: + combos_model = _load_combos("config/card_lists/combos.json") + except Exception: + combos_model = None + try: + combos = _detect_combos(names, combos_path="config/card_lists/combos.json") + except Exception: + combos = [] + try: + synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json") + except Exception: + synergies = [] + try: + synergies_model = _load_synergies("config/card_lists/synergies.json") + except Exception: + synergies_model = None + + # Suggestions + suggestions: list[dict] = [] + present = {s.strip().lower() for s in names} + suggested_names: set[str] = set() + if combos_model is not None: + # Prefer policy: suggest adding a missing partner to hit target count + if policy == "prefer": + try: + for p in combos_model.pairs: + a = str(p.a).strip() + b = str(p.b).strip() + a_in = a.lower() in present + b_in = b.lower() in present + if a_in ^ b_in: # exactly one present + missing = b if a_in else a + have = a if a_in else b + item = { + "kind": "add", + "have": have, + "name": missing, + "cheap_early": bool(getattr(p, "cheap_early", False)), + "setup_dependent": bool(getattr(p, "setup_dependent", False)), + } + key = str(missing).strip().lower() + if key not in present and key not in suggested_names: + suggestions.append(item) + suggested_names.add(key) + # Rank: cheap/early first, then setup-dependent, then name + suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower())) + # If we still have room below target, add synergy-based suggestions + rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions) + if rem > 0 and synergies_model is not None: + # lightweight tag weights to bias common engines + weights = { + "treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3, + "engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9, + "counters": 1.8, "equipment": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4, + "damage": 1.3, "stax": 1.2 + } + syn_sugs: list[dict] = [] + for p in synergies_model.pairs: + a = str(p.a).strip() + b = str(p.b).strip() + a_in = a.lower() in present + b_in = b.lower() in present + if a_in ^ b_in: + missing = b if a_in else a + have = a if a_in else b + mkey = missing.strip().lower() + if mkey in present or mkey in suggested_names: + continue + tags = list(getattr(p, "tags", []) or []) + score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1) + syn_sugs.append({ + "kind": "add", + "have": have, + "name": missing, + "cheap_early": False, + "setup_dependent": False, + "tags": tags, + "_score": score, + }) + suggested_names.add(mkey) + # rank by score desc then name + syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower())) + if rem > 0: + suggestions.extend(syn_sugs[:rem]) + # Finally trim to target or default cap + cap = (int(target) if target > 0 else 8) + suggestions = suggestions[:cap] + except Exception: + suggestions = [] + elif policy == "avoid": + # Avoid policy: suggest cutting one piece from detected combos + try: + for c in combos: + # pick the second card as default cut to vary suggestions + suggestions.append({ + "kind": "cut", + "name": c.b, + "partner": c.a, + "cheap_early": bool(getattr(c, "cheap_early", False)), + "setup_dependent": bool(getattr(c, "setup_dependent", False)), + }) + # Rank: cheap/early first + suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower())) + if target > 0: + suggestions = suggestions[: target] + else: + suggestions = suggestions[: 8] + except Exception: + suggestions = [] + + ctx = { + "request": request, + "policy": policy, + "target": target, + "combos": combos, + "synergies": synergies, + "versions": { + "combos": getattr(combos_model, "list_version", None) if combos_model else None, + "synergies": getattr(synergies_model, "list_version", None) if synergies_model else None, + }, + "suggestions": suggestions, + } + return templates.TemplateResponse("build/_combos_panel.html", ctx) + + +@router.post("/combos/prefs", response_class=HTMLResponse) +async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse: + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + pol = (policy or "neutral").strip().lower() + if pol not in {"avoid", "neutral", "prefer"}: + pol = "neutral" + try: + tgt = int(target) + except Exception: + tgt = 0 + if tgt < 0: + tgt = 0 + sess["combos_policy"] = pol + sess["combos_target"] = tgt + # Re-render the panel + return await build_combos_panel(request) + + @router.post("/toggle-owned-review", response_class=HTMLResponse) async def build_toggle_owned_review( request: Request, @@ -1056,6 +1276,9 @@ async def build_step5_get(request: Request) -> HTMLResponse: "skipped": False, "game_changers": bc.GAME_CHANGERS, "replace_mode": bool(sess.get("replace_mode", True)), + "prefer_combos": bool(sess.get("prefer_combos")), + "combo_target_count": int(sess.get("combo_target_count", 2)), + "combo_balance": str(sess.get("combo_balance", "mix")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -1098,6 +1321,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse: locks=list(sess.get("locks", [])), custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), ) else: # If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet @@ -1196,6 +1422,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse: "skipped": bool(res.get("skipped")), "locks": list(sess.get("locks", [])), "replace_mode": bool(sess.get("replace_mode", True)), + "prefer_combos": bool(sess.get("prefer_combos")), + "combo_target_count": int(sess.get("combo_target_count", 2)), + "combo_balance": str(sess.get("combo_balance", "mix")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -1236,6 +1465,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: locks=list(sess.get("locks", [])), custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), ) else: # Ensure latest locks are reflected in the existing context @@ -1408,6 +1640,9 @@ async def build_step5_start(request: Request) -> HTMLResponse: locks=list(sess.get("locks", [])), custom_export_base=sess.get("custom_export_base"), multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), ) show_skipped = False try: @@ -1572,12 +1807,14 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse: "skipped": False, "locks": list(sess.get("locks", [])), "replace_mode": bool(sess.get("replace_mode")), + "prefer_combos": bool(sess.get("prefer_combos")), + "combo_target_count": int(sess.get("combo_target_count", 2)), + "combo_balance": str(sess.get("combo_balance", "mix")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp - # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") diff --git a/code/web/routes/configs.py b/code/web/routes/configs.py index b3849d7..2487c9c 100644 --- a/code/web/routes/configs.py +++ b/code/web/routes/configs.py @@ -8,6 +8,8 @@ import json from ..app import templates from ..services import owned_store from ..services import orchestrator as orch +from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies +from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies from deck_builder import builder_constants as bc @@ -143,6 +145,33 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s owned_names = owned_store.get_names() if owned_flag else None + # Optional combos preferences + prefer_combos = False + try: + pc = cfg.get("prefer_combos") + if isinstance(pc, bool): + prefer_combos = pc + elif isinstance(pc, str): + prefer_combos = pc.strip().lower() in ("1","true","yes","on") + except Exception: + prefer_combos = False + combo_target_count = None + try: + ctc = cfg.get("combo_target_count") + if isinstance(ctc, int): + combo_target_count = ctc + elif isinstance(ctc, str) and ctc.strip().isdigit(): + combo_target_count = int(ctc.strip()) + except Exception: + combo_target_count = None + combo_balance = None + try: + cb = cfg.get("combo_balance") + if isinstance(cb, str) and cb.strip().lower() in ("early","late","mix"): + combo_balance = cb.strip().lower() + except Exception: + combo_balance = None + # Run build headlessly with orchestrator res = orch.run_build( commander=commander, @@ -152,6 +181,10 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s tag_mode=tag_mode, use_owned_only=owned_flag, owned_names=owned_names, + # Thread combo prefs through staged headless run + prefer_combos=prefer_combos, + combo_target_count=combo_target_count, + combo_balance=combo_balance, ) if not res.get("ok"): return templates.TemplateResponse( @@ -183,6 +216,23 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s "use_owned_only": owned_flag, "owned_set": {n.lower() for n in owned_store.get_names()}, "game_changers": bc.GAME_CHANGERS, + # Combos & Synergies for summary panel + **(lambda _sum: (lambda names: (lambda _cm,_sm: { + "combos": (_detect_combos(names, combos_path="config/card_lists/combos.json") if names else []), + "synergies": (_detect_synergies(names, synergies_path="config/card_lists/synergies.json") if names else []), + "versions": { + "combos": getattr(_cm, 'list_version', None) if _cm else None, + "synergies": getattr(_sm, 'list_version', None) if _sm else None, + } + })( + (lambda: (_load_combos("config/card_lists/combos.json")))(), + (lambda: (_load_synergies("config/card_lists/synergies.json")))(), + ))( + (lambda s, cmd: (lambda names_set: sorted(names_set | ({cmd} if cmd else set())))( + set([str((c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))) for _t, cl in (((s or {}).get('type_breakdown', {}) or {}).get('cards', {}).items()) for c in (cl or []) if (c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))]) + | set([str((c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))) for _b, cl in ((((s or {}).get('mana_curve', {}) or {}).get('cards', {}) or {}).items()) for c in (cl or []) if (c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))]) + ))(_sum, commander) + ))(res.get("summary")) }, ) diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index deb35bc..4fb67b1 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -9,6 +9,8 @@ from typing import Dict, List, Tuple, Optional from ..app import templates from ..services import owned_store +from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies +from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies from deck_builder import builder_constants as bc @@ -292,6 +294,61 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: parts = stem.split('_') commander_name = parts[0] if parts else '' + # Prepare combos/synergies detections for summary panel + combos = [] + synergies = [] + versions = {"combos": None, "synergies": None} + try: + # Collect deck card names from summary (types + curve) and include commander + names_set: set[str] = set() + try: + tb = (summary or {}).get('type_breakdown', {}) + cards_by_type = tb.get('cards', {}) if isinstance(tb, dict) else {} + for _typ, clist in (cards_by_type.items() if isinstance(cards_by_type, dict) else []): + for c in (clist or []): + n = str(c.get('name') if isinstance(c, dict) else getattr(c, 'name', '')) + if n: + names_set.add(n) + except Exception: + pass + # Also pull from mana curve cards for robustness + try: + mc = (summary or {}).get('mana_curve', {}) + curve_cards = mc.get('cards', {}) if isinstance(mc, dict) else {} + for _bucket, clist in (curve_cards.items() if isinstance(curve_cards, dict) else []): + for c in (clist or []): + n = str(c.get('name') if isinstance(c, dict) else getattr(c, 'name', '')) + if n: + names_set.add(n) + except Exception: + pass + # Ensure commander is included + if commander_name: + names_set.add(str(commander_name)) + + names = sorted(names_set) + if names: + try: + combos = _detect_combos(names, combos_path="config/card_lists/combos.json") + except Exception: + combos = [] + try: + synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json") + except Exception: + synergies = [] + try: + cm = _load_combos("config/card_lists/combos.json") + versions["combos"] = getattr(cm, 'list_version', None) + except Exception: + pass + try: + sm = _load_synergies("config/card_lists/synergies.json") + versions["synergies"] = getattr(sm, 'list_version', None) + except Exception: + pass + except Exception: + pass + ctx = { "request": request, "name": p.name, @@ -303,6 +360,9 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: "display_name": display_name, "game_changers": bc.GAME_CHANGERS, "owned_set": {n.lower() for n in owned_store.get_names()}, + "combos": combos, + "synergies": synergies, + "versions": versions, } return templates.TemplateResponse("decks/view.html", ctx) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 1107c58..97c7fc1 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -668,7 +668,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: _write_status({"running": False, "phase": "error", "message": "Setup check failed"}) -def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None) -> Dict[str, Any]: +def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None, prefer_combos: bool | None = None, combo_target_count: int | None = None, combo_balance: str | None = None) -> Dict[str, Any]: """Run the deck build end-to-end with provided selections and capture logs. Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] } @@ -751,6 +751,19 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception as e: out(f"Failed to load color identity/card pool: {e}") + # Thread combo preferences (if provided) + try: + if prefer_combos is not None: + b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined] + if combo_target_count is not None: + b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] + if combo_balance: + bal = str(combo_balance).strip().lower() + if bal in ('early','late','mix'): + b.combo_balance = bal # type: ignore[attr-defined] + except Exception: + pass + try: b._run_land_build_steps() except Exception as e: @@ -763,6 +776,126 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i out(f"Creature phase failed: {e}") try: if hasattr(b, 'add_spells_phase'): + # When combos are preferred, run auto-complete before bulk spells so additions aren't clamped + try: + if bool(getattr(b, 'prefer_combos', False)): + # Re-use the staged runner logic for auto-combos + _ = run_stage # anchor for mypy + # Minimal inline runner: mimic '__auto_complete_combos__' block + try: + # Load curated combos + from tagging.combo_schema import load_and_validate_combos as _load_combos + combos_model = None + try: + combos_model = _load_combos("config/card_lists/combos.json") + except Exception: + combos_model = None + # Build current name set including commander + names: list[str] = [] + try: + names.extend(list(getattr(b, 'card_library', {}).keys())) + except Exception: + pass + try: + cmd = getattr(b, 'commander_name', None) + if cmd: + names.append(cmd) + except Exception: + pass + # Count existing completed combos to reduce target budget + existing_pairs = 0 + try: + if combos_model: + present = {str(x).strip().lower() for x in names if str(x).strip()} + for p in combos_model.pairs: + a = str(p.a).strip().lower() + bnm = str(p.b).strip().lower() + if a in present and bnm in present: + existing_pairs += 1 + except Exception: + existing_pairs = 0 + # Determine target and balance + try: + target_total = int(getattr(b, 'combo_target_count', 2)) + except Exception: + target_total = 2 + try: + balance = str(getattr(b, 'combo_balance', 'mix')).strip().lower() + except Exception: + balance = 'mix' + if balance not in ('early','late','mix'): + balance = 'mix' + remaining_pairs = max(0, target_total - existing_pairs) + lib_lower = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} + added_any = False + # Determine missing partners + candidates: list[tuple[int, str]] = [] + for p in (combos_model.pairs if combos_model else []): + a = str(p.a).strip() + bnm = str(p.b).strip() + a_l = a.lower() + b_l = bnm.lower() + has_a = (a_l in lib_lower) or (a_l == str(getattr(b, 'commander_name', '')).lower()) + has_b = (b_l in lib_lower) or (b_l == str(getattr(b, 'commander_name', '')).lower()) + target: str | None = None + if has_a and not has_b: + target = bnm + elif has_b and not has_a: + target = a + if not target: + continue + # Score per balance + score = 0 + try: + if balance == 'early': + score += (5 if getattr(p, 'cheap_early', False) else 0) + score += (0 if getattr(p, 'setup_dependent', False) else 1) + elif balance == 'late': + score += (4 if getattr(p, 'setup_dependent', False) else 0) + score += (0 if getattr(p, 'cheap_early', False) else 1) + else: + score += (3 if getattr(p, 'cheap_early', False) else 0) + score += (2 if getattr(p, 'setup_dependent', False) else 0) + except Exception: + pass + candidates.append((score, target)) + candidates.sort(key=lambda x: (-x[0], x[1].lower())) + for _ in range(remaining_pairs): + if not candidates: + break + _score, pick = candidates.pop(0) + # Resolve in current pool; enrich type/mana + try: + df_pool = getattr(b, '_combined_cards_df', None) + df_full = getattr(b, '_full_cards_df', None) + row = None + for df in (df_pool, df_full): + if df is not None and not df.empty and 'name' in df.columns: + r = df[df['name'].astype(str).str.lower() == pick.lower()] + if not r.empty: + row = r + break + if row is None or row.empty: + continue + pick = str(row.iloc[0]['name']) + card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') + mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') + except Exception: + card_type = '' + mana_cost = '' + try: + b.add_card(pick, card_type=card_type, mana_cost=mana_cost, role='Support', sub_role='Combo Partner', added_by='AutoCombos') + out(f"Auto-Complete Combos: added '{pick}' to complete a detected pair.") + added_any = True + lib_lower.add(pick.lower()) + except Exception: + continue + if not added_any: + out("No combo partners added.") + except Exception as _e: + out(f"Auto-Complete Combos failed: {_e}") + except Exception: + pass b.add_spells_phase() except Exception as e: out(f"Spell phase failed: {e}") @@ -859,6 +992,7 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: # Multi-Copy package first (if selected) so lands & targets can account for it if mc_selected: stages.append({"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}) + # Note: Combos auto-complete now runs late (near theme autofill), so we defer adding it here. # Land steps 1..8 (if present) for i in range(1, 9): fn = getattr(b, f"run_land_step{i}", None) @@ -896,10 +1030,23 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: # Web UI: omit confirm stages; show only the action stage label_action = label.replace("Confirm ", "") stages.append({"key": f"spells_{key}", "label": label_action, "runner_name": runner}) + # When combos are preferred, run Auto-Complete Combos BEFORE final theme fill so there is room to add partners. + try: + prefer_c = bool(getattr(b, 'prefer_combos', False)) + except Exception: + prefer_c = False + if prefer_c: + stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) # Ensure we include the theme filler step to top up to 100 cards if callable(getattr(b, 'fill_remaining_theme_spells', None)): stages.append({"key": "spells_fill", "label": "Theme Spell Fill", "runner_name": "fill_remaining_theme_spells"}) elif hasattr(b, 'add_spells_phase'): + # For monolithic spells, insert combos BEFORE the big spells stage so additions aren't clamped away + try: + if bool(getattr(b, 'prefer_combos', False)): + stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) + except Exception: + pass stages.append({"key": "spells", "label": "Spells", "runner_name": "add_spells_phase"}) # Post-adjust if hasattr(b, 'post_spell_land_adjust'): @@ -924,6 +1071,9 @@ def start_build_ctx( locks: List[str] | None = None, custom_export_base: str | None = None, multi_copy: Dict[str, Any] | None = None, + prefer_combos: bool | None = None, + combo_target_count: int | None = None, + combo_balance: str | None = None, ) -> Dict[str, Any]: logs: List[str] = [] @@ -994,6 +1144,24 @@ def start_build_ctx( b._web_multi_copy = (multi_copy or None) except Exception: pass + # Preference flags + try: + b.prefer_combos = bool(prefer_combos) + except Exception: + pass + # Thread combo config + try: + if combo_target_count is not None: + b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] + except Exception: + pass + try: + if combo_balance: + bal = str(combo_balance).strip().lower() + if bal in ('early','late','mix'): + b.combo_balance = bal # type: ignore[attr-defined] + except Exception: + pass # Stages stages = _make_stages(b) ctx = { @@ -1308,6 +1476,143 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal logs.append("No multi-copy additions (empty selection).") except Exception as e: logs.append(f"Stage '{label}' failed: {e}") + elif runner_name == '__auto_complete_combos__': + try: + # Load curated combos + from tagging.combo_schema import load_and_validate_combos as _load_combos + combos_model = None + try: + combos_model = _load_combos("config/card_lists/combos.json") + except Exception: + combos_model = None + # Build current name set including commander + names: list[str] = [] + try: + names.extend(list(getattr(b, 'card_library', {}).keys())) + except Exception: + pass + try: + cmd = getattr(b, 'commander_name', None) + if cmd: + names.append(cmd) + except Exception: + pass + # Count existing completed combos to reduce target budget + existing_pairs = 0 + try: + if combos_model: + present = {str(x).strip().lower() for x in names if str(x).strip()} + for p in combos_model.pairs: + a = str(p.a).strip().lower() + bnm = str(p.b).strip().lower() + if a in present and bnm in present: + existing_pairs += 1 + except Exception: + existing_pairs = 0 + # Determine target and balance + try: + target_total = int(getattr(b, 'combo_target_count', 2)) + except Exception: + target_total = 2 + try: + balance = str(getattr(b, 'combo_balance', 'mix')).strip().lower() + except Exception: + balance = 'mix' + if balance not in ('early','late','mix'): + balance = 'mix' + # Remaining pairs to aim for + remaining_pairs = max(0, target_total - existing_pairs) + # Determine missing partners for any pair where exactly one is present + lib_lower = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} + locks_lower = locks_set + added_any = False + if remaining_pairs <= 0: + logs.append("Combo plan met by existing pairs; no additions needed.") + # Build candidate list with scoring for balance + candidates: list[tuple[int, str, dict]] = [] # (score, target_name, enrich_meta) + for p in (combos_model.pairs if combos_model else []): + a = str(p.a).strip() + bname = str(p.b).strip() + a_l = a.lower() + b_l = bname.lower() + has_a = (a_l in lib_lower) or (a_l == str(getattr(b, 'commander_name', '')).lower()) + has_b = (b_l in lib_lower) or (b_l == str(getattr(b, 'commander_name', '')).lower()) + # If exactly one side present, attempt to add the other + target: str | None = None + if has_a and not has_b: + target = bname + elif has_b and not has_a: + target = a + if not target: + continue + # Respect locks + if target.lower() in locks_lower: + continue + # Owned-only check + try: + if getattr(b, 'use_owned_only', False): + owned = getattr(b, 'owned_card_names', set()) or set() + if owned and target.lower() not in {n.lower() for n in owned}: + continue + except Exception: + pass + # Score per balance + score = 0 + try: + if balance == 'early': + score += (5 if getattr(p, 'cheap_early', False) else 0) + score += (0 if getattr(p, 'setup_dependent', False) else 1) + elif balance == 'late': + score += (4 if getattr(p, 'setup_dependent', False) else 0) + score += (0 if getattr(p, 'cheap_early', False) else 1) + else: # mix + score += (3 if getattr(p, 'cheap_early', False) else 0) + score += (2 if getattr(p, 'setup_dependent', False) else 0) + except Exception: + pass + # Prefer targets that aren't already in library (already ensured), and stable name sort as tiebreaker + score_tuple = (score, target.lower(), {}) + candidates.append(score_tuple) + # Sort candidates descending by score then name asc + candidates.sort(key=lambda x: (-x[0], x[1])) + # Add up to remaining_pairs partners + for _ in range(remaining_pairs): + if not candidates: + break + _score, pick, meta = candidates.pop(0) + # Resolve display name and enrich type/mana + card_type = '' + mana_cost = '' + try: + # Only consider the current filtered pool first (color-identity compliant). + df_pool = getattr(b, '_combined_cards_df', None) + df_full = getattr(b, '_full_cards_df', None) + row = None + for df in (df_pool, df_full): + if df is not None and not df.empty and 'name' in df.columns: + r = df[df['name'].astype(str).str.lower() == pick.lower()] + if not r.empty: + row = r + break + if row is None or row.empty: + # Skip if we cannot resolve in current pool (likely off-color/unavailable) + continue + pick = str(row.iloc[0]['name']) + card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') + mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') + except Exception: + pass + try: + b.add_card(pick, card_type=card_type, mana_cost=mana_cost, role='Support', sub_role='Combo Partner', added_by='AutoCombos') + logs.append(f"Auto-Complete Combos: added '{pick}' to complete a detected pair.") + added_any = True + lib_lower.add(pick.lower()) + except Exception: + continue + if not added_any: + logs.append("No combo partners added.") + except Exception as e: + logs.append(f"Stage '{label}' failed: {e}") elif callable(fn): try: fn() @@ -1331,12 +1636,26 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal row = df[df['name'].astype(str).str.lower() == lname] if not row.empty: target_name = str(row.iloc[0]['name']) + target_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') + target_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') + else: + target_type = '' + target_cost = '' + else: + target_type = '' + target_cost = '' except Exception: target_name = None + target_type = '' + target_cost = '' + # Only add a lock placeholder if we can resolve this name in the current pool if target_name is None: - target_name = lname + # Unresolvable (likely off-color or unavailable) -> skip placeholder + continue b.card_library[target_name] = { 'Count': 1, + 'Card Type': target_type, + 'Mana Cost': target_cost, 'Role': 'Locked', 'SubRole': '', 'AddedBy': 'Lock', diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 16a3ea4..106e16d 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -104,6 +104,9 @@ .card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; } .card-hover-inner { display:flex; gap:12px; align-items:flex-start; } .card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background: var(--panel); } + .card-hover .dual { + display:flex; gap:12px; align-items:flex-start; + } .card-meta { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); } .card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; } .card-meta li { margin:.1rem 0; } @@ -180,9 +183,15 @@ inner.className = 'card-hover-inner'; var img = document.createElement('img'); img.alt = 'Card preview'; + var img2 = document.createElement('img'); + img2.alt = 'Card preview'; img2.style.display = 'none'; var meta = document.createElement('div'); meta.className = 'card-meta'; - inner.appendChild(img); + var dual = document.createElement('div'); + dual.className = 'dual'; + dual.appendChild(img); + dual.appendChild(img2); + inner.appendChild(dual); inner.appendChild(meta); pop.appendChild(inner); document.body.appendChild(pop); @@ -259,12 +268,14 @@ if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px'; if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px'; } - function attachCardHover() { + function attachCardHover() { document.querySelectorAll('[data-card-name]').forEach(function(el) { if (el.__cardHoverBound) return; // avoid duplicate bindings el.__cardHoverBound = true; el.addEventListener('mouseenter', function(e) { - var img = cardPop.querySelector('img'); + var img = cardPop.querySelector('.card-hover-inner img'); + var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)'); + if (img2) img2.style.display = 'none'; var meta = cardPop.querySelector('.card-meta'); var name = el.getAttribute('data-card-name') || ''; var vi = 0; // always start at 'normal' on hover @@ -304,6 +315,33 @@ el.addEventListener('mousemove', positionCard); el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; }); }); + // Dual-card hover for combo rows + document.querySelectorAll('[data-combo-names]').forEach(function(el){ + if (el.__comboHoverBound) return; el.__comboHoverBound = true; + el.addEventListener('mouseenter', function(e){ + var namesAttr = el.getAttribute('data-combo-names') || ''; + var parts = namesAttr.split('||'); + var a = (parts[0]||'').trim(); var b = (parts[1]||'').trim(); + if (!a || !b) return; + var img = cardPop.querySelector('.card-hover-inner img'); + var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)'); + var meta = cardPop.querySelector('.card-meta'); + if (img2) img2.style.display = ''; + var vi1 = 0, vi2 = 0; var triedNoCache1 = false, triedNoCache2 = false; + img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false); + img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false); + function err1(){ if (vi1 < PREVIEW_VERSIONS.length - 1){ vi1 += 1; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);} else if (!triedNoCache1){ triedNoCache1 = true; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], true);} else { img.removeEventListener('error', err1);} } + function err2(){ if (vi2 < PREVIEW_VERSIONS.length - 1){ vi2 += 1; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);} else if (!triedNoCache2){ triedNoCache2 = true; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], true);} else { img2.removeEventListener('error', err2);} } + img.addEventListener('error', err1, { once:false }); + img2.addEventListener('error', err2, { once:false }); + img.addEventListener('load', function on1(){ img.removeEventListener('load', on1); img.removeEventListener('error', err1); }); + img2.addEventListener('load', function on2(){ img2.removeEventListener('load', on2); img2.removeEventListener('error', err2); }); + meta.style.display = 'none'; meta.innerHTML = ''; + positionCard(e); + }); + el.addEventListener('mousemove', positionCard); + el.addEventListener('mouseleave', function(){ cardPop.style.display='none'; }); + }); } attachCardHover(); bindAllCardImageRetries(); diff --git a/code/web/templates/build/_combo_limit_modal.html b/code/web/templates/build/_combo_limit_modal.html new file mode 100644 index 0000000..58181fc --- /dev/null +++ b/code/web/templates/build/_combo_limit_modal.html @@ -0,0 +1,28 @@ + diff --git a/code/web/templates/build/_combos_panel.html b/code/web/templates/build/_combos_panel.html new file mode 100644 index 0000000..dba2cc6 --- /dev/null +++ b/code/web/templates/build/_combos_panel.html @@ -0,0 +1,76 @@ +
+
+

Combos & Synergies

+ {% if versions and (versions.combos or versions.synergies) %} + lists v{{ versions.combos }}{% if versions.synergies %} / {{ versions.synergies }}{% endif %} + {% endif %} +
+ +
+
Detected combos ({{ combos|length }})
+ {% if combos and combos|length %} +
    + {% for c in combos %} +
  • + {{ c.a }} + + + {{ c.b }} + {% if c.cheap_early or c.setup_dependent %} + + {% if c.cheap_early %}cheap/early{% endif %} + {% if c.setup_dependent %}setup{% endif %} + + {% endif %} +
  • + {% endfor %} +
+ {% else %} +
None found.
+ {% endif %} +
+ +
+
Detected synergies ({{ synergies|length }})
+ {% if synergies and synergies|length %} +
    + {% for s in synergies %} +
  • + {{ s.a }} + + + {{ s.b }} + {% if s.tags %} + + {% for t in s.tags %}{{ t }}{% endfor %} + + {% endif %} +
  • + {% endfor %} +
+ {% else %} +
None found.
+ {% endif %} +
+ {% if suggestions and suggestions|length %} +
+

Suggestions

+
    + {% for s in suggestions %} +
  • + {% if s.kind == 'add' %}Add {{ s.name }} (partner: {{ s.have }}) + {% elif s.kind == 'cut' %}Cut {{ s.name }} (pairs with {{ s.partner }}) + {% else %}{{ s.kind|title }} {{ s.name }}{% endif %} + {% set badges = [] %} + {% if s.cheap_early %}{% set _ = badges.append('cheap/early') %}{% endif %} + {% if s.setup_dependent %}{% set _ = badges.append('setup-dependent') %}{% endif %} + {% if badges and badges|length %} + { {{ badges|join(', ') }} } + {% endif %} + {% if s.tags and s.tags|length %} + [{{ s.tags|join(', ') }}] + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} +
diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index d4f38c6..df5d412 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -49,6 +49,32 @@ +
+ Preferences + + +
Advanced options (ideals)
@@ -146,4 +172,18 @@ function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } } document.addEventListener('keydown', onKey); })(); + + // Toggle combos config visibility based on checkbox + (function(){ + try { + var form = document.querySelector('.modal form'); + var chk = form && form.querySelector('#pref-combos-chk'); + var box = form && form.querySelector('#pref-combos-config'); + if (!chk || !box) return; + function sync(){ box.style.display = chk.checked ? 'block' : 'none'; } + chk.addEventListener('change', sync); + // Initial state + sync(); + } catch(_){} + })(); diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 3756e95..7d6f636 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -26,7 +26,7 @@
-
+

Commander: {{ commander }}

Tags: {{ tags|default([])|join(', ') }}

@@ -49,6 +49,9 @@ {% if added_total is not none %} Added {{ added_total }} {% endif %} + {% if prefer_combos %} + Combos: {{ combo_target_count }} ({{ combo_balance }}) + {% endif %} {% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %} Clamped {{ clamped_overflow }} {% endif %} @@ -77,6 +80,10 @@
{% endif %} + {% if status and status.startswith('Build complete') %} +
+ {% endif %} + {% if locked_cards is defined and locked_cards %}
Locked cards (always kept) diff --git a/code/web/templates/configs/run_result.html b/code/web/templates/configs/run_result.html index e040fc9..95647b0 100644 --- a/code/web/templates/configs/run_result.html +++ b/code/web/templates/configs/run_result.html @@ -39,7 +39,7 @@ {% if summary %} - {{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }} + {{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }} {% endif %} {% endif %} diff --git a/code/web/templates/decks/compare.html b/code/web/templates/decks/compare.html index 7f25859..058b73c 100644 --- a/code/web/templates/decks/compare.html +++ b/code/web/templates/decks/compare.html @@ -7,7 +7,7 @@
{% endif %} - {{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }} + {{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }} {% else %}
No summary available.
{% endif %} diff --git a/code/web/templates/diagnostics/index.html b/code/web/templates/diagnostics/index.html index c1fcab1..5f5601f 100644 --- a/code/web/templates/diagnostics/index.html +++ b/code/web/templates/diagnostics/index.html @@ -20,6 +20,16 @@
Render count: 0
+
+

Combos & Synergies (ad-hoc)

+
Paste card names (one per line) and detect two-card combos and synergies using current lists.
+ +
+ + Runs in diagnostics mode only. +
+

+  
{% if enable_pwa %}

PWA status

@@ -86,6 +96,48 @@ }); } }catch(_){ } + // Combos & synergies ad-hoc tester + try{ + var runBtn = document.getElementById('diag-combos-run'); + var ta = document.getElementById('diag-combos-input'); + var out = document.getElementById('diag-combos-out'); + function parseLines(){ + var v = (ta && ta.value) || ''; + return v.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean); + } + async function run(){ + if (!ta || !out) return; + out.textContent = 'Running…'; + try{ + var resp = await fetch('/diagnostics/combos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names: parseLines() })}); + if (!resp.ok){ out.textContent = 'Error '+resp.status; return; } + var data = await resp.json(); + var lines = []; + // Versions + try{ + if (data.versions){ + var vLine = 'List versions: '; + if (data.versions.combos) vLine += 'combos v'+ String(data.versions.combos); + if (data.versions.synergies) vLine += (data.versions.combos? ', ' : '') + 'synergies v'+ String(data.versions.synergies); + lines.push(vLine); + } + }catch(_){ } + lines.push('Combos: '+ data.counts.combos); + (data.combos||[]).forEach(function(c){ + var badges = []; + if (c.cheap_early) badges.push('cheap/early'); + if (c.setup_dependent) badges.push('setup-dependent'); + var tagStr = (c.tags && c.tags.length? ' ['+c.tags.join(', ')+']' : ''); + var badgeStr = badges.length ? ' {'+badges.join(', ')+'}' : ''; + lines.push(' - '+c.a+' + '+c.b+ tagStr + badgeStr); + }); + lines.push('Synergies: '+ data.counts.synergies); + (data.synergies||[]).forEach(function(s){ lines.push(' - '+s.a+' + '+s.b+(s.tags && s.tags.length? ' ['+s.tags.join(', ')+']':'')); }); + out.textContent = lines.join('\n'); + }catch(e){ out.textContent = 'Failed: '+ (e && e.message? e.message : 'Unknown error'); } + } + if (runBtn){ runBtn.addEventListener('click', run); } + }catch(_){ } try{ var p = document.getElementById('pwaStatus'); if (p){ diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html index 5483fa3..e2f492d 100644 --- a/code/web/templates/partials/deck_summary.html +++ b/code/web/templates/partials/deck_summary.html @@ -1,11 +1,60 @@

Deck Summary

+{% if versions and (versions.combos or versions.synergies) %} +
Combos/Synergies lists: v{{ versions.combos or '?' }} / v{{ versions.synergies or '?' }}
+{% endif %}
Legend: Game Changer (green highlight) Owned • Not owned
+ +{% if combos or synergies %} +
+
Combos & Synergies
+ {% if combos %} +
+
Detected Combos ({{ combos|length }})
+
    + {% for c in combos %} +
  • + {{ c.a }} + + + {{ c.b }} + {% if c.cheap_early or c.setup_dependent %} + + {% if c.cheap_early %}cheap/early{% endif %} + {% if c.setup_dependent %}setup{% endif %} + + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} + {% if synergies %} +
+
Detected Synergies ({{ synergies|length }})
+
    + {% for s in synergies %} +
  • + {{ s.a }} + + + {{ s.b }} + {% if s.tags %} + + {% for t in s.tags %}{{ t }}{% endfor %} + + {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} +
+{% endif %} +
Card Types
@@ -99,6 +148,7 @@
No type data available.
{% endif %}
+