feat(combos): add Combos & Synergies detection, chip-style UI with dual hover; JSON persistence and headless honoring; stage ordering; docs and tests; bump to v2.2.1

This commit is contained in:
mwisnowski 2025-09-01 16:55:24 -07:00
parent cc16c6f13a
commit 6c48fb3437
38 changed files with 2042 additions and 131 deletions

3
.gitignore vendored
View file

@ -11,6 +11,9 @@ __pycache__/
csv_files/
dist/
logs/
deck_files/
csv_files/
!config/card_lists/*.json
!config/deck.json
RELEASE_NOTES.md
*.bkp

View file

@ -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 arent 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.

BIN
README.md

Binary file not shown.

View file

@ -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=<N>` (compose default: 4). Falls back to sequential if parallel init fails.
- Phase 8 UI upgrade: Unified “New Deck” modal (steps 13), 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 doesnt 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 runs 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 stages 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.
## Whats 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=<N>` — 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

View file

@ -1,7 +1,9 @@
from .builder import DeckBuilder
from .builder_utils import *
from .builder_constants import *
__all__ = ['DeckBuilder']
__all__ = [
'DeckBuilder',
]
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)

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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}")

View file

@ -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))

View file

@ -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": "Thassas 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"] == "Thassas 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"]

View file

@ -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 = ["Thassas 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

View file

@ -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

View file

@ -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

View file

@ -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": ["Thassas 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

View file

@ -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

View file

@ -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],
}
)

View file

@ -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 13 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")

View file

@ -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"))
},
)

View file

@ -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)

View file

@ -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',

View file

@ -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();

View file

@ -0,0 +1,28 @@
<div class="modal" id="combo-modal" role="dialog" aria-modal="true" aria-labelledby="combo-modal-title">
<div class="modal-content">
<h3 id="combo-modal-title" style="margin-top:0;">Combos & Synergies — Auto-complete plan</h3>
<p class="muted" style="margin:.25rem 0 .75rem 0;">You're prioritizing combos. Choose how many to aim for and the balance of early vs late-game pieces.</p>
<form hx-post="/build/combos/save" hx-target="#combo-modal" hx-swap="outerHTML" style="display:grid; gap:.75rem;">
<div>
<label for="combo_count"><strong>How many combos would you like?</strong></label>
<input id="combo_count" name="count" type="number" min="0" max="10" step="1" value="{{ count|default(2) }}" style="width:6rem; margin-left:.5rem;" />
</div>
<fieldset style="border:1px solid var(--border); padding:.5rem; border-radius:8px;">
<legend><strong>Balance of early-game vs late-game</strong></legend>
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
<input type="radio" name="balance" value="early" {% if balance == 'early' %}checked{% endif %}/> Early-game focus (cheap, quick setups)
</label>
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
<input type="radio" name="balance" value="late" {% if balance == 'late' %}checked{% endif %}/> Late-game focus (setup-dependent payoffs)
</label>
<label style="display:flex; align-items:center; gap:.35rem; margin:.25rem 0;">
<input type="radio" name="balance" value="mix" {% if balance == 'mix' or not balance %}checked{% endif %}/> Mix of both
</label>
</fieldset>
<div style="display:flex; gap:.5rem; justify-content:flex-end;">
<button type="submit" class="btn">Save</button>
<button type="button" class="btn" hx-post="/build/combos/save" hx-vals='{"skip":"1"}' hx-target="#combo-modal" hx-swap="outerHTML">Dismiss</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,76 @@
<div class="panel" style="margin-top:1rem;">
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
<h3 style="margin:0;">Combos & Synergies</h3>
{% if versions and (versions.combos or versions.synergies) %}
<span class="muted">lists v{{ versions.combos }}{% if versions.synergies %} / {{ versions.synergies }}{% endif %}</span>
{% endif %}
</div>
<section style="margin-top:.5rem;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected combos ({{ combos|length }})</div>
{% if combos and combos|length %}
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for c in combos %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
{% if c.cheap_early or c.setup_dependent %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="muted">None found.</div>
{% endif %}
</section>
<section style="margin-top:.5rem;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected synergies ({{ synergies|length }})</div>
{% if synergies and synergies|length %}
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for s in synergies %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
{% if s.tags %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="muted">None found.</div>
{% endif %}
</section>
{% if suggestions and suggestions|length %}
<div style="margin-top:.75rem;">
<h4 style="margin:0 0 .25rem 0;">Suggestions</h4>
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
{% for s in suggestions %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;">
{% if s.kind == 'add' %}Add <strong data-card-name="{{ s.name }}">{{ s.name }}</strong> (partner: <span data-card-name="{{ s.have }}">{{ s.have }}</span>)
{% elif s.kind == 'cut' %}Cut <strong data-card-name="{{ s.name }}">{{ s.name }}</strong> (pairs with <span data-card-name="{{ s.partner }}">{{ s.partner }}</span>)
{% else %}{{ s.kind|title }} <strong data-card-name="{{ s.name }}">{{ s.name }}</strong>{% 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 %}
<span class="muted">{ {{ badges|join(', ') }} }</span>
{% endif %}
{% if s.tags and s.tags|length %}
<span class="muted">[{{ s.tags|join(', ') }}]</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>

View file

@ -49,6 +49,32 @@
</label>
</div>
</fieldset>
<fieldset>
<legend>Preferences</legend>
<label title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" /> Prioritize combos (auto-complete partners)
</label>
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<label>
<span>How many combos?</span>
<input type="number" name="combo_count" min="0" max="10" step="1" value="{{ form.combo_count if form and form.combo_count is not none else 2 }}" style="width:6rem; margin-left:.5rem;" />
</label>
<div>
<div class="muted" style="font-size:12px; margin-bottom:.25rem;">Balance of early vs late-game</div>
<label style="display:inline-flex; align-items:center; gap:.25rem; margin-right:.5rem;">
<input type="radio" name="combo_balance" value="early" {% if form and form.combo_balance == 'early' %}checked{% endif %}/> Early
</label>
<label style="display:inline-flex; align-items:center; gap:.25rem; margin-right:.5rem;">
<input type="radio" name="combo_balance" value="late" {% if form and form.combo_balance == 'late' %}checked{% endif %}/> Late
</label>
<label style="display:inline-flex; align-items:center; gap:.25rem;">
<input type="radio" name="combo_balance" value="mix" {% if not form or (form and (not form.combo_balance or form.combo_balance == 'mix')) %}checked{% endif %}/> Mix
</label>
</div>
</div>
</div>
</fieldset>
<details style="margin-top:.5rem;">
<summary>Advanced options (ideals)</summary>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
@ -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(_){}
})();
</script>

View file

@ -26,7 +26,7 @@
</aside>
<div class="grow" data-skeleton>
<div hx-get="/build/banner" hx-trigger="load"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -49,6 +49,9 @@
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
{% endif %}
{% if prefer_combos %}
<span class="chip" title="Combos plan"><span class="dot" style="background: var(--orange-main);"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
{% endif %}
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
<span class="chip" title="Trimmed overflow from this stage"><span class="dot" style="background: var(--red-main);"></span> Clamped {{ clamped_overflow }}</span>
{% endif %}
@ -77,6 +80,10 @@
</div>
{% endif %}
{% if status and status.startswith('Build complete') %}
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
{% endif %}
{% if locked_cards is defined and locked_cards %}
<details id="locked-section" style="margin-top:.5rem;">
<summary>Locked cards (always kept)</summary>

View file

@ -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 %}

View file

@ -7,7 +7,7 @@
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label>Deck A
<select name="A" required>
<option value="">Choose…</option>
<option value="" data-mtime="0">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
@ -15,7 +15,7 @@
</label>
<label>Deck B
<select name="B" required>
<option value="">Choose…</option>
<option value="" data-mtime="0">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}

View file

@ -58,7 +58,7 @@
</div>
</div>
{% 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 %}
<div class="muted">No summary available.</div>
{% endif %}

View file

@ -20,6 +20,16 @@
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Combos & Synergies (ad-hoc)</h3>
<div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div>
<textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea>
<div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center">
<button class="btn" id="diag-combos-run">Detect</button>
<small class="muted">Runs in diagnostics mode only.</small>
</div>
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
</div>
{% if enable_pwa %}
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">PWA status</h3>
@ -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){

View file

@ -1,11 +1,60 @@
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
{% if versions and (versions.combos or versions.synergies) %}
<div class="muted" style="font-size:12px; margin:.1rem 0 .4rem 0;">Combos/Synergies lists: v{{ versions.combos or '?' }} / v{{ versions.synergies or '?' }}</div>
{% endif %}
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span>Legend:</span>
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Not owned</span>
</div>
<!-- Detected Combos & Synergies (top) -->
{% if combos or synergies %}
<section style="margin-top:.25rem;">
<h5>Combos & Synergies</h5>
{% if combos %}
<div style="margin:.25rem 0 .5rem 0;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Combos ({{ combos|length }})</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for c in combos %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
{% if c.cheap_early or c.setup_dependent %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if synergies %}
<div style="margin:.25rem 0 .5rem 0;">
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Synergies ({{ synergies|length }})</div>
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
{% for s in synergies %}
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
<span class="muted"> + </span>
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
{% if s.tags %}
<span class="muted" style="margin-left:.4rem; font-size:12px;">
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</section>
{% endif %}
<!-- Card Type Breakdown with names-only list and hover preview -->
<section style="margin-top:.5rem;">
<h5>Card Types</h5>
@ -99,6 +148,7 @@
<div class="muted">No type data available.</div>
{% endif %}
</section>
<script>
(function(){
var listBtn = document.querySelector('.seg-btn[data-view="list"]');

View file

@ -0,0 +1,182 @@
{
"list_version": "0.3.0",
"generated_at": null,
"pairs": [
{ "a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Thassa's Oracle", "b": "Tainted Pact", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts", "cheap_early": true, "setup_dependent": false, "tags": ["infinite"] },
{ "a": "Devoted Druid", "b": "Vizier of Remedies", "cheap_early": true, "setup_dependent": false, "tags": ["infinite"] },
{ "a": "Heliod, Sun-Crowned", "b": "Walking Ballista", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Isochron Scepter", "b": "Dramatic Reversal", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] },
{ "a": "Underworld Breach", "b": "Brain Freeze", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "storm"] },
{ "a": "Auriok Salvagers", "b": "Lion's Eye Diamond", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] },
{ "a": "Worldgorger Dragon", "b": "Animate Dead", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] },
{ "a": "Exquisite Blood", "b": "Sanguine Bond", "cheap_early": false, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Exquisite Blood", "b": "Vito, Thorn of the Dusk Rose", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
{ "a": "Exquisite Blood", "b": "Marauding Blight-Priest", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
{ "a": "Exquisite Blood", "b": "Vizkopa Guildmage", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
{ "a": "Exquisite Blood", "b": "Cliffhaven Vampire", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
{ "a": "Exquisite Blood", "b": "Enduring Tenacity", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
{ "a": "Mikaeus, the Unhallowed", "b": "Triskelion", "cheap_early": false, "setup_dependent": false, "tags": ["wincon", "infinite"] },
{ "a": "Basalt Monolith", "b": "Rings of Brighthearth", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Basalt Monolith", "b": "Forsaken Monument", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Basalt Monolith", "b": "Forensic Gadgeteer", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Basalt Monolith", "b": "Nyxbloom Ancient", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Power Artifact", "b": "Grim Monolith", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Painter's Servant", "b": "Grindstone", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Rest in Peace", "b": "Helm of Obedience", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Thopter Foundry", "b": "Sword of the Meek", "cheap_early": true, "setup_dependent": false, "tags": ["engine"] },
{ "a": "Karmic Guide", "b": "Reveillark", "cheap_early": false, "setup_dependent": true, "tags": ["loop", "infinite"] },
{ "a": "Food Chain", "b": "Misthollow Griffin", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Food Chain", "b": "Eternal Scourge", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Food Chain", "b": "Squee, the Immortal", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
{ "a": "Deadeye Navigator", "b": "Peregrine Drake", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] }
,{ "a": "Godo, Bandit Warlord", "b": "Helm of the Host", "cheap_early": false, "setup_dependent": false, "tags": ["wincon"] }
,{ "a": "Aurelia, the Warleader", "b": "Helm of the Host", "cheap_early": false, "setup_dependent": false, "tags": ["wincon", "combat"] }
,{ "a": "Combat Celebrant", "b": "Helm of the Host", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Narset, Parter of Veils", "b": "Windfall", "cheap_early": true, "setup_dependent": false, "tags": ["lock"] }
,{ "a": "Knowledge Pool", "b": "Teferi, Mage of Zhalfir", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
,{ "a": "Knowledge Pool", "b": "Teferi, Time Raveler", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
,{ "a": "Possibility Storm", "b": "Rule of Law", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
,{ "a": "Possibility Storm", "b": "Eidolon of Rhetoric", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
,{ "a": "Grand Architect", "b": "Pili-Pala", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] }
,{ "a": "Umbral Mantle", "b": "Priest of Titania", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Umbral Mantle", "b": "Elvish Archdruid", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Umbral Mantle", "b": "Marwyn, the Nurturer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Umbral Mantle", "b": "Circle of Dreams Druid", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Staff of Domination", "b": "Priest of Titania", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Staff of Domination", "b": "Elvish Archdruid", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Staff of Domination", "b": "Marwyn, the Nurturer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Staff of Domination", "b": "Circle of Dreams Druid", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Staff of Domination", "b": "Selvala, Heart of the Wilds", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Freed from the Real", "b": "Bloom Tender", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Freed from the Real", "b": "Faeburrow Elder", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Kinnan, Bonder Prodigy", "b": "Basalt Monolith", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] }
,{ "a": "Melira, Sylvok Outcast", "b": "Kitchen Finks", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "life"] }
,{ "a": "Vizier of Remedies", "b": "Kitchen Finks", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "life"] }
,{ "a": "Devoted Druid", "b": "Quillspike", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "power"] }
,{ "a": "Devoted Druid", "b": "Swift Reconfiguration", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
,{ "a": "Heliod, Sun-Crowned", "b": "Spike Feeder", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "life"] }
,{ "a": "Mind Over Matter", "b": "Temple Bell", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "draw"] }
,{ "a": "Saheeli Rai", "b": "Felidar Guardian", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Felidar Guardian", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
,{ "a": "Felidar Guardian", "b": "Restoration Angel", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "etb"] }
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Restoration Angel", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "etb"] }
,{ "a": "Niv-Mizzet, Parun", "b": "Curiosity", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
,{ "a": "Niv-Mizzet, the Firemind", "b": "Curiosity", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
,{ "a": "Niv-Mizzet, Parun", "b": "Ophidian Eye", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
,{ "a": "Niv-Mizzet, the Firemind", "b": "Ophidian Eye", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
,{ "a": "Niv-Mizzet, Parun", "b": "Tandem Lookout", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
,{ "a": "Niv-Mizzet, the Firemind", "b": "Tandem Lookout", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
,{ "a": "Bloodchief Ascension", "b": "Mindcrank", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
,{ "a": "Gravecrawler", "b": "Phyrexian Altar", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "death"] }
,{ "a": "Goblin Sharpshooter", "b": "Basilisk Collar", "cheap_early": true, "setup_dependent": true, "tags": ["lock", "removal"] }
,{ "a": "Malcolm, Keen-Eyed Navigator", "b": "Glint-Horn Buccaneer", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "damage"] }
,{ "a": "Professor Onyx", "b": "Chain of Smog", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }
,{ "a": "Witherbloom Apprentice", "b": "Chain of Smog", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }
,{ "a": "Solphim, Mayhem Dominus", "b": "Heartless Hidetsugu", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "damage"] }
,{ "a": "Karn, the Great Creator", "b": "Mycosynth Lattice", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
,{ "a": "Mycosynth Lattice", "b": "Vandalblast", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
,{ "a": "Animate Dead", "b": "Abdel Adrian, Gorion's Ward", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
,{ "a": "Ratadrabik of Urborg", "b": "Boromir, Warden of the Tower", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "death"] }
,{ "a": "Tivit, Seller of Secrets", "b": "Time Sieve", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "turns"] }
,{ "a": "Blasphemous Act", "b": "Repercussion", "cheap_early": true, "setup_dependent": true, "tags": ["damage", "boardwipe"] }
,{ "a": "Toralf, God of Fury", "b": "Blasphemous Act", "cheap_early": true, "setup_dependent": true, "tags": ["damage", "boardwipe"] }
,{ "a": "Aggravated Assault", "b": "Sword of Feast and Famine", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Aggravated Assault", "b": "Savage Ventmaw", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Aggravated Assault", "b": "Neheb, the Eternal", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Aggravated Assault", "b": "The Reaver Cleaver", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Aggravated Assault", "b": "Selvala, Heart of the Wilds", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Ashaya, Soul of the Wild", "b": "Quirion Ranger", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
,{ "a": "Scurry Oak", "b": "Ivy Lane Denizen", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
,{ "a": "Rosie Cotton of South Lane", "b": "Scurry Oak", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
,{ "a": "Basking Broodscale", "b": "Rosie Cotton of South Lane", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
,{ "a": "The Gitrog Monster", "b": "Dakmor Salvage", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mill"] }
,{ "a": "Maddening Cacophony", "b": "Bruvac the Grandiloquent", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "mill"] }
,{ "a": "Traumatize", "b": "Bruvac the Grandiloquent", "cheap_early": false, "setup_dependent": false, "tags": ["wincon", "mill"] }
,{ "a": "Cut Your Losses", "b": "Bruvac the Grandiloquent", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
,{ "a": "Cut Your Losses", "b": "Fraying Sanity", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
,{ "a": "Terisian Mindbreaker", "b": "Bruvac the Grandiloquent", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
,{ "a": "Terisian Mindbreaker", "b": "Fraying Sanity", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
,{ "a": "Dualcaster Mage", "b": "Heat Shimmer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
,{ "a": "Dualcaster Mage", "b": "Molten Duplication", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
,{ "a": "Dualcaster Mage", "b": "Saw in Half", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
,{ "a": "Dualcaster Mage", "b": "Ghostly Flicker", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
,{ "a": "Naru Meha, Master Wizard", "b": "Ghostly Flicker", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Village Bell-Ringer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Combat Celebrant", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Demonic Consultation", "b": "Laboratory Maniac", "cheap_early": true, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Peregrin Took", "b": "Experimental Confectioner", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "draw"] }
,{ "a": "Peregrin Took", "b": "Nuka-Cola Vending Machine", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "treasure"] }
,{ "a": "Aggravated Assault", "b": "Bear Umbra", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
,{ "a": "Nest of Scarabs", "b": "Blowfly Infestation", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "death"] }
,{ "a": "Ondu Spiritdancer", "b": "Secret Arcade // Dusty Parlor", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
,{ "a": "Storm-Kiln Artist", "b": "Haze of Rage", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "storm"] }
,{ "a": "Bloodthirsty Conqueror", "b": "Vito, Thorn of the Dusk Rose", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Bloodthirsty Conqueror", "b": "Sanguine Bond", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Bloodthirsty Conqueror", "b": "Enduring Tenacity", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Glint-Horn Buccaneer", "b": "Curiosity", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "draw"] }
,{ "a": "Sheoldred, the Apocalypse", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "draw"] }
,{ "a": "Underworld Dreams", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Psychosis Crawler", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "draw"] }
,{ "a": "Orcish Bowmasters", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["damage"] }
,{ "a": "Bloodletter of Aclazotz", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Jeska's Will", "b": "Reiterate", "cheap_early": false, "setup_dependent": true, "tags": ["mana", "storm"] }
,{ "a": "Mana Geyser", "b": "Reiterate", "cheap_early": false, "setup_dependent": true, "tags": ["mana", "storm"] }
,{ "a": "Approach of the Second Sun", "b": "Scroll Rack", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Approach of the Second Sun", "b": "Narset's Reversal", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Approach of the Second Sun", "b": "Reprieve", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Teferi, Temporal Archmage", "b": "The Chain Veil", "cheap_early": false, "setup_dependent": true, "tags": ["planeswalker", "engine"] }
,{ "a": "Old Gnawbone", "b": "Hellkite Charger", "cheap_early": false, "setup_dependent": true, "tags": ["combat", "mana"] }
,{ "a": "Aggravated Assault", "b": "Old Gnawbone", "cheap_early": false, "setup_dependent": true, "tags": ["combat", "mana"] }
,{ "a": "The World Tree", "b": "Maskwood Nexus", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
,{ "a": "The World Tree", "b": "Arcane Adaptation", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
,{ "a": "Solemnity", "b": "Decree of Silence", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
,{ "a": "Gisela, Blade of Goldnight", "b": "Heartless Hidetsugu", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "damage"] }
,{ "a": "Avacyn, Angel of Hope", "b": "Worldslayer", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
,{ "a": "Mindslaver", "b": "Academy Ruins", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
,{ "a": "Brine Elemental", "b": "Vesuvan Shapeshifter", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
,{ "a": "Havoc Festival", "b": "Wound Reflection", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Maze's End", "b": "Scapeshift", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "lands"] }
,{ "a": "Twinning Staff", "b": "Dramatic Reversal", "cheap_early": false, "setup_dependent": true, "tags": ["storm", "mana"] }
,{ "a": "Terror of the Peaks", "b": "Rite of Replication", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
,{ "a": "Zedruu the Greathearted", "b": "Transcendence", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Tivit, Seller of Secrets", "b": "Deadeye Navigator", "cheap_early": false, "setup_dependent": true, "tags": ["etb", "engine"] }
,{ "a": "Brass's Bounty", "b": "Revel in Riches", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
,{ "a": "Bootleggers' Stash", "b": "Revel in Riches", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
,{ "a": "Brass's Bounty", "b": "Mechanized Production", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
,{ "a": "Bootleggers' Stash", "b": "Mechanized Production", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
,{ "a": "Approach of the Second Sun", "b": "Mystical Tutor", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Approach of the Second Sun", "b": "Vampiric Tutor", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "Approach of the Second Sun", "b": "Demonic Tutor", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
,{ "a": "The World Tree", "b": "Purphoros, God of the Forge", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
,{ "a": "The World Tree", "b": "Rukarumel, Biologist", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
,{ "a": "Realmbreaker, the Invasion Tree", "b": "Maskwood Nexus", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
,{ "a": "Beacon of Immortality", "b": "Sanguine Bond", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Vizkopa Guildmage", "b": "Beacon of Immortality", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Drogskol Reaver", "b": "Queza, Augur of Agonies", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "life"] }
,{ "a": "Drogskol Reaver", "b": "Shabraz, the Skyshark", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "life"] }
,{ "a": "Drogskol Reaver", "b": "Sheoldred, the Apocalypse", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "life"] }
,{ "a": "Astral Dragon", "b": "Cursed Mirror", "cheap_early": false, "setup_dependent": true, "tags": ["tokens", "etb"] }
,{ "a": "Kudo, King Among Bears", "b": "Elesh Norn, Grand Cenobite", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
,{ "a": "Shard of the Nightbringer", "b": "Sanguine Bond", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Vito, Thorn of the Dusk Rose", "b": "Shard of the Nightbringer", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Bloodletter of Aclazotz", "b": "Shard of the Nightbringer", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Fraying Omnipotence", "b": "Wound Reflection", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
,{ "a": "Body of Knowledge", "b": "Niv-Mizzet, the Firemind", "cheap_early": false, "setup_dependent": true, "tags": ["draw"] }
,{ "a": "Emry, Lurker of the Loch", "b": "Mindslaver", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
,{ "a": "Ad Nauseam", "b": "Teferi's Protection", "cheap_early": false, "setup_dependent": true, "tags": ["draw"] }
,{ "a": "Wanderwine Prophets", "b": "Deeproot Pilgrimage", "cheap_early": false, "setup_dependent": true, "tags": ["turns"] }
,{ "a": "Orthion, Hero of Lavabrink", "b": "Terror of the Peaks", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
,{ "a": "Orthion, Hero of Lavabrink", "b": "Fanatic of Mogis", "cheap_early": false, "setup_dependent": true, "tags": ["damage"] }
,{ "a": "Maze's End", "b": "Reshape the Earth", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "lands"] }
,{ "a": "Avacyn, Angel of Hope", "b": "Nevinyrral's Disk", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
,{ "a": "Toxrill, the Corrosive", "b": "Maha, Its Feathers Night", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
,{ "a": "Niv-Mizzet, Visionary", "b": "Niv-Mizzet, Parun", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "damage"] }
,{ "a": "Niv-Mizzet, Visionary", "b": "Niv-Mizzet, the Firemind", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "damage"] }
,{ "a": "Dragon Tempest", "b": "Ancient Gold Dragon", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
,{ "a": "Vraska, Betrayal's Sting", "b": "Vorinclex, Monstrous Raider", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "planeswalker"] }
,{ "a": "Polyraptor", "b": "Marauding Raptor", "cheap_early": false, "setup_dependent": true, "tags": ["tokens"] }
,{ "a": "Tivit, Seller of Secrets", "b": "Time Sieve", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "turns"] }
]
}

View file

@ -0,0 +1,124 @@
{
"list_version": "0.4.0",
"generated_at": null,
"pairs": [
{ "a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats", "value"], "notes": "Sacrifice enables repeated edicts" },
{ "a": "Panharmonicon", "b": "Mulldrifter", "tags": ["etb", "value"], "notes": "Amplifies ETB triggers" }
,{ "a": "Doubling Season", "b": "+1/+1 counters", "tags": ["counters", "value"], "notes": "Generic synergy placeholder for counters decks" }
,{ "a": "Skullclamp", "b": "Bitterblossom", "tags": ["card draw", "tokens"], "notes": "Token fodder into draw" }
,{ "a": "Pitiless Plunderer", "b": "Grim Haruspex", "tags": ["aristocrats", "value"], "notes": "Death triggers generate mana and cards" }
,{ "a": "Smothering Tithe", "b": "Wheel of Fortune", "tags": ["treasure", "value"], "notes": "Wheel effects with Tithe produce piles of treasure" }
,{ "a": "Concordant Crossroads", "b": "Craterhoof Behemoth", "tags": ["haste", "finishers"], "notes": "Global haste + overrun finisher" }
,{ "a": "Ashnod's Altar", "b": "Nim Deathmantle", "tags": ["loop"], "notes": "Classic deathmantle loop with ETB/LTB creatures" }
,{ "a": "Anointed Procession", "b": "Smothering Tithe", "tags": ["tokens", "treasure"], "notes": "Doubles Treasure token output" }
,{ "a": "Parallel Lives", "b": "Smothering Tithe", "tags": ["tokens", "treasure"], "notes": "Token doublers amplify Treasure" }
,{ "a": "Mondrak, Glory Dominus", "b": "Smothering Tithe", "tags": ["tokens", "treasure"], "notes": "Doubles opponents' Treasure output to you" }
,{ "a": "Academy Manufactor", "b": "Tireless Provisioner", "tags": ["tokens", "treasure"], "notes": "Each Treasure becomes Food/Clue/Treasure" }
,{ "a": "Academy Manufactor", "b": "Lonis, Cryptozoologist", "tags": ["tokens", "clues"], "notes": "Clues become all three tokens" }
,{ "a": "Academy Manufactor", "b": "Bootleggers' Stash", "tags": ["tokens", "treasure"], "notes": "Lands make Treasures which become all three" }
,{ "a": "Ophiomancer", "b": "Skullclamp", "tags": ["card draw", "tokens"], "notes": "Recurring 1/1 tokens feed Clamp" }
,{ "a": "Pitiless Plunderer", "b": "Ophiomancer", "tags": ["aristocrats", "treasure"], "notes": "Free sac fodder into Treasure every turn" }
,{ "a": "Purphoros, God of the Forge", "b": "Avenger of Zendikar", "tags": ["damage", "tokens"], "notes": "Go-wide ETB burns table" }
,{ "a": "Impact Tremors", "b": "Avenger of Zendikar", "tags": ["damage", "tokens"], "notes": "Budget Purphoros line" }
,{ "a": "Hardened Scales", "b": "Walking Ballista", "tags": ["counters"], "notes": "Extra counters for more pings" }
,{ "a": "Hardened Scales", "b": "The Ozolith", "tags": ["counters"], "notes": "Store and move counters more efficiently" }
,{ "a": "The Ozolith", "b": "Arcbound Ravager", "tags": ["counters", "artifacts"], "notes": "Modular and counter storage play well" }
,{ "a": "Winding Constrictor", "b": "Hangarback Walker", "tags": ["counters"], "notes": "Bigger on entry and dies into more Thopters" }
,{ "a": "Sun Titan", "b": "Altar of Dementia", "tags": ["etb", "mill"], "notes": "Recur 3-drop permanents, mill self/opponents" }
,{ "a": "Sun Titan", "b": "Eternal Witness", "tags": ["recursion", "value"], "notes": "Classic recursion value engine" }
,{ "a": "Muldrotha, the Gravetide", "b": "Seal of Primordium", "tags": ["graveyard", "removal"], "notes": "Repeatable enchantment removal from yard" }
,{ "a": "Meren of Clan Nel Toth", "b": "Sakura-Tribe Elder", "tags": ["graveyard", "ramp"], "notes": "Recurring ramp and experience counters" }
,{ "a": "Phyrexian Reclamation", "b": "Fleshbag Marauder", "tags": ["removal", "graveyard"], "notes": "Repeatable edicts via recursion" }
,{ "a": "Young Pyromancer", "b": "Opt", "tags": ["spellslinger", "tokens"], "notes": "Cheap cantrips fuel token production" }
,{ "a": "Talrand, Sky Summoner", "b": "Opt", "tags": ["spellslinger", "tokens"], "notes": "Drakes on every instant/sorcery" }
,{ "a": "Archmage Emeritus", "b": "Opt", "tags": ["spellslinger", "card draw"], "notes": "Learn triggers on low-cost spells" }
,{ "a": "Storm-Kiln Artist", "b": "Big Score", "tags": ["treasure", "spellslinger"], "notes": "Treasure refunds the spells" }
,{ "a": "Krark-Clan Ironworks", "b": "Myr Retriever", "tags": ["artifacts", "value"], "notes": "Sac outlets plus recursion parts" }
,{ "a": "Scrap Trawler", "b": "Ichor Wellspring", "tags": ["artifacts", "card draw"], "notes": "Dies-to-draw plus Trawler chains" }
,{ "a": "Goblin Engineer", "b": "Wurmcoil Engine", "tags": ["artifacts", "tutor"], "notes": "Entomb and recur big artifact threats" }
,{ "a": "Inspiring Statuary", "b": "Smothering Tithe", "tags": ["artifacts", "treasure"], "notes": "Treasure fuels improvise for non-artifacts" }
,{ "a": "Tireless Tracker", "b": "Evolving Wilds", "tags": ["landfall", "card draw"], "notes": "Crackable Clues on fetch lands" }
,{ "a": "Tireless Provisioner", "b": "Evolving Wilds", "tags": ["landfall", "treasure"], "notes": "Landfall ramps with fetch triggers" }
,{ "a": "Scute Swarm", "b": "Cultivate", "tags": ["landfall", "tokens"], "notes": "Ramp spells explode token count" }
,{ "a": "Avenger of Zendikar", "b": "Scapeshift", "tags": ["landfall", "tokens"], "notes": "Mass landfall into massive board" }
,{ "a": "Sythis, Harvest's Hand", "b": "Wild Growth", "tags": ["enchantress", "ramp"], "notes": "Draw and ramp on cheap auras" }
,{ "a": "Enchantress's Presence", "b": "Utopia Sprawl", "tags": ["enchantress", "ramp"], "notes": "Cantrip ramp aura" }
,{ "a": "Stoneforge Mystic", "b": "Skullclamp", "tags": ["equipment", "tutor"], "notes": "Tutor powerful draw equipment" }
,{ "a": "Puresteel Paladin", "b": "Colossus Hammer", "tags": ["equipment", "card draw"], "notes": "Free equips and cards on cheap equips" }
,{ "a": "Sigarda's Aid", "b": "Colossus Hammer", "tags": ["equipment", "tempo"], "notes": "Flash in and auto-equip the Hammer" }
,{ "a": "Sram, Senior Edificer", "b": "Swiftfoot Boots", "tags": ["equipment", "card draw"], "notes": "Cheap equipment keep cards flowing" }
,{ "a": "Waste Not", "b": "Windfall", "tags": ["discard", "value"], "notes": "Wheel fuels Waste Not payoffs" }
,{ "a": "Nekusar, the Mindrazer", "b": "Wheel of Fortune", "tags": ["damage", "wheels"], "notes": "Wheels turn into burn" }
,{ "a": "Bone Miser", "b": "Wheel of Misfortune", "tags": ["discard", "value"], "notes": "Discard payoffs go wild on wheels" }
,{ "a": "Winter Orb", "b": "Urza, Lord High Artificer", "tags": ["stax", "artifacts"], "notes": "Tap Orb on opponents' turns to break parity" }
,{ "a": "Static Orb", "b": "Tangle Wire", "tags": ["stax"], "notes": "Stacking tap/untap tax pieces" }
,{ "a": "Atraxa, Praetors' Voice", "b": "Evolution Sage", "tags": ["proliferate", "counters"], "notes": "Extra proliferate with lands" }
,{ "a": "Tekuthal, Inquiry Dominus", "b": "Karn's Bastion", "tags": ["proliferate", "counters"], "notes": "Repeatable proliferate engine" }
,{ "a": "Xorn", "b": "Smothering Tithe", "tags": ["treasure", "tokens"], "notes": "More treasures per draw" }
,{ "a": "Academy Manufactor", "b": "Xorn", "tags": ["treasure", "tokens"], "notes": "Token multipliers stack" }
,{ "a": "Professional Face-Breaker", "b": "Bitterblossom", "tags": ["treasure", "combat"], "notes": "Evasive tokens turn hits into Treasure" }
,{ "a": "Chatterfang, Squirrel General", "b": "Pitiless Plunderer", "tags": ["aristocrats", "tokens"], "notes": "Squirrels plus Treasure on death" }
,{ "a": "Chatterfang, Squirrel General", "b": "Parallel Lives", "tags": ["tokens"], "notes": "Token doublers scale Chatterfang" }
,{ "a": "Mayhem Devil", "b": "Pitiless Plunderer", "tags": ["aristocrats", "damage"], "notes": "Sac/death pings stack up" }
,{ "a": "Soul Warden", "b": "Ajani's Pridemate", "tags": ["lifegain", "counters"], "notes": "Gain triggers grow Pridemate" }
,{ "a": "Well of Lost Dreams", "b": "Ajani's Pridemate", "tags": ["lifegain", "card draw"], "notes": "Convert lifegain into cards while growing" }
,{ "a": "Ephemerate", "b": "Mulldrifter", "tags": ["blink", "value"], "notes": "Cheap repeated blink for draw" }
,{ "a": "Deadeye Navigator", "b": "Mulldrifter", "tags": ["blink", "value"], "notes": "Soulbond to blink for cards" }
,{ "a": "Cloudstone Curio", "b": "Elvish Visionary", "tags": ["bounce", "value"], "notes": "Loop cheap ETB cantrips" }
,{ "a": "Kor Spiritdancer", "b": "Ethereal Armor", "tags": ["auras", "card draw"], "notes": "Grow and draw on auras" }
,{ "a": "Sram, Senior Edificer", "b": "Ethereal Armor", "tags": ["auras", "card draw"], "notes": "Cheap aura cantrips" }
,{ "a": "Light-Paws, Emperor's Voice", "b": "All That Glitters", "tags": ["auras", "tutor"], "notes": "Tutor and cheat auras" }
,{ "a": "Entomb", "b": "Reanimate", "tags": ["reanimator", "tutor"], "notes": "Classic yard setup and reanimate" }
,{ "a": "Buried Alive", "b": "Victimize", "tags": ["reanimator", "value"], "notes": "Load the yard, reanimate two" }
,{ "a": "Animate Dead", "b": "Dockside Extortionist", "tags": ["reanimator", "treasure"], "notes": "Reanimate ETB ramp threat" }
,{ "a": "Aesi, Tyrant of Gyre Strait", "b": "Exploration", "tags": ["landfall", "card draw"], "notes": "Extra lands draw extra cards" }
,{ "a": "Tatyova, Benthic Druid", "b": "Exploration", "tags": ["landfall", "card draw"], "notes": "Draw on extra land drops" }
,{ "a": "Crucible of Worlds", "b": "Strip Mine", "tags": ["stax", "lands"], "notes": "Soft lock on lands recursion" }
,{ "a": "Ramunap Excavator", "b": "Evolving Wilds", "tags": ["lands", "value"], "notes": "Repeatable fetchland utility" }
,{ "a": "Life from the Loam", "b": "Lonely Sandbar", "tags": ["dredge", "card draw"], "notes": "Cycling lands plus Loam" }
,{ "a": "Life from the Loam", "b": "Barren Moor", "tags": ["dredge", "card draw"], "notes": "Cycle and recur pattern" }
,{ "a": "Field of the Dead", "b": "Evolving Wilds", "tags": ["landfall", "tokens"], "notes": "Fetchlands trigger Field tokens" }
,{ "a": "Maskwood Nexus", "b": "Kindred Discovery", "tags": ["tribal", "card draw"], "notes": "All creatures share a type for draw" }
,{ "a": "Krenko, Mob Boss", "b": "Skullclamp", "tags": ["goblins", "card draw"], "notes": "Go wide and convert to cards" }
,{ "a": "Krenko, Mob Boss", "b": "Impact Tremors", "tags": ["goblins", "damage"], "notes": "Tokens ping table on entry" }
,{ "a": "Tendershoot Dryad", "b": "Parallel Lives", "tags": ["tokens"], "notes": "Saprolings scale quickly" }
,{ "a": "Mycoloth", "b": "Parallel Lives", "tags": ["tokens"], "notes": "Devour grows exponential tokens" }
,{ "a": "Risen Reef", "b": "Omnath, Locus of the Roil", "tags": ["elementals", "value"], "notes": "Elemental ETBs chain value" }
,{ "a": "Beast Whisperer", "b": "Elvish Mystic", "tags": ["elves", "card draw"], "notes": "Cheap creatures keep cards flowing" }
,{ "a": "Birgi, God of Storytelling", "b": "Runaway Steam-Kin", "tags": ["spellslinger", "mana"], "notes": "Spells return mana and counters" }
,{ "a": "Niv-Mizzet, Parun", "b": "Teferi's Ageless Insight", "tags": ["card draw", "damage"], "notes": "Extra cards fuel more pings" }
,{ "a": "Throne of the God-Pharaoh", "b": "Bitterblossom", "tags": ["tokens", "damage"], "notes": "Tapped tokens drain opponents" }
,{ "a": "Goblin Bombardment", "b": "Ophiomancer", "tags": ["aristocrats", "damage"], "notes": "Recurring sac fodder becomes pings" }
,{ "a": "Urza, Lord High Artificer", "b": "Thopter Foundry", "tags": ["artifacts", "tokens"], "notes": "Thopters feed Urza mana" }
,{ "a": "Urza, Lord High Artificer", "b": "Sword of the Meek", "tags": ["artifacts", "tokens"], "notes": "Sword returns with Thopter tokens" }
,{ "a": "Cathars' Crusade", "b": "Avenger of Zendikar", "tags": ["tokens", "counters"], "notes": "Mass ETB puts counters on everything" }
,{ "a": "Cathars' Crusade", "b": "Scute Swarm", "tags": ["tokens", "counters"], "notes": "Token explosions grow the team" }
,{ "a": "Felidar Retreat", "b": "Evolving Wilds", "tags": ["landfall", "tokens"], "notes": "Fetchlands double-trigger retreat" }
,{ "a": "Skullclamp", "b": "Endrek Sahr, Master Breeder", "tags": ["card draw", "tokens"], "notes": "Clamp the Thrulls for a draw engine" }
,{ "a": "Earthcraft", "b": "Squirrel Nest", "tags": ["tokens", "ramp"], "notes": "Tap the enchanted land to make more squirrels" }
,{ "a": "Bitterblossom", "b": "Contamination", "tags": ["stax", "tokens"], "notes": "Upkeep fodder to maintain the lock" }
,{ "a": "Smokestack", "b": "Ophiomancer", "tags": ["stax", "tokens"], "notes": "Serpents feed sac requirements" }
,{ "a": "Goblin Welder", "b": "Spine of Ish Sah", "tags": ["artifacts", "removal"], "notes": "Repeatable Vindicate with Welder loops" }
,{ "a": "Goblin Welder", "b": "Ichor Wellspring", "tags": ["artifacts", "card draw"], "notes": "Weld for cards and value" }
,{ "a": "Feldon of the Third Path", "b": "Wurmcoil Engine", "tags": ["artifacts", "value"], "notes": "Token copies generate lifelink/deathtouch tokens" }
,{ "a": "Brago, King Eternal", "b": "Strionic Resonator", "tags": ["blink", "engine"], "notes": "Copy Brago's trigger to chain blinks" }
,{ "a": "Sanctum Weaver", "b": "Enchantress's Presence", "tags": ["enchantress", "ramp"], "notes": "Big mana plus steady card draw" }
,{ "a": "Setessan Champion", "b": "Rancor", "tags": ["auras", "card draw"], "notes": "Cheap aura cantrips and sticks around" }
,{ "a": "Invisible Stalker", "b": "All That Glitters", "tags": ["voltron", "auras"], "notes": "Hexproof evasive body for big aura" }
,{ "a": "Hammer of Nazahn", "b": "Colossus Hammer", "tags": ["equipment", "tempo"], "notes": "Auto-equip and protect the carrier" }
,{ "a": "Aetherflux Reservoir", "b": "Storm-Kiln Artist", "tags": ["storm", "lifegain"], "notes": "Treasure refunds spells to grow life total" }
,{ "a": "Dauthi Voidwalker", "b": "Wheel of Fortune", "tags": ["discard", "value"], "notes": "Exile discards and cast best spell" }
,{ "a": "Sheoldred, the Apocalypse", "b": "Windfall", "tags": ["wheels", "lifedrain"], "notes": "Opponents draw many, you gain and they lose" }
,{ "a": "Syr Konrad, the Grim", "b": "Mesmeric Orb", "tags": ["mill", "damage"], "notes": "Self-mill drains table" }
,{ "a": "Syr Konrad, the Grim", "b": "Altar of Dementia", "tags": ["mill", "aristocrats"], "notes": "Sac to mill and drain" }
,{ "a": "Ruin Crab", "b": "Evolving Wilds", "tags": ["mill", "landfall"], "notes": "Fetch lands double mill triggers" }
,{ "a": "Narset, Parter of Veils", "b": "Wheel of Fortune", "tags": ["wheels", "stax"], "notes": "Wheels become one-sided" }
,{ "a": "Narset, Parter of Veils", "b": "Echo of Eons", "tags": ["wheels", "stax"], "notes": "Self-flashback Time Spiral with Narset" }
,{ "a": "Bolas's Citadel", "b": "Aetherflux Reservoir", "tags": ["lifegain", "engine"], "notes": "Casting from top grows life to fuel Citadel" }
,{ "a": "Bolas's Citadel", "b": "Sensei's Divining Top", "tags": ["topdeck", "engine"], "notes": "Top manipulates Citadel flips" }
,{ "a": "Mystic Forge", "b": "Sensei's Divining Top", "tags": ["artifacts", "topdeck"], "notes": "Look/cast from top with Top selection" }
,{ "a": "Amulet of Vigor", "b": "Simic Growth Chamber", "tags": ["lands", "ramp"], "notes": "Bounce lands untap for extra mana" }
,{ "a": "Dryad of the Ilysian Grove", "b": "Valakut, the Molten Pinnacle", "tags": ["lands", "damage"], "notes": "Valakut online in any colors" }
,{ "a": "Azusa, Lost but Seeking", "b": "Valakut, the Molten Pinnacle", "tags": ["lands", "damage"], "notes": "Extra land drops power Valakut" }
]
}

View file

@ -36,7 +36,7 @@ services:
# Default theme for first-time visitors (no local preference yet): system|light|dark
# When set to 'light', it maps to the consolidated Light (Blend) palette in the UI
# - ENABLE_THEMES=1
# - THEME=dark
- THEME=dark
# Logging and error utilities
# - SHOW_LOGS=1
# - SHOW_DIAGNOSTICS=1
@ -46,6 +46,10 @@ services:
- WEB_TAG_WORKERS=4
# Enable virtualization + lazy image tweaks in Step 5
- WEB_VIRTUALIZE=1
# Version label (optional; shown in footer/diagnostics)
- APP_VERSION=v2.2.1
# EDHRec scraping
# - EDHREC_FETCH=1
volumes:
- ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs

View file

@ -1,7 +1,7 @@
services:
web:
image: mwisnowski/mtg-python-deckbuilder:latest
# Tip: pin to a specific tag when available, e.g. :2.0.2 (defaults to Web UI)
# Tip: pin to a specific tag when available, e.g. :2.2.1
container_name: mtg-deckbuilder-web
ports:
- "8080:8080" # Host:Container — open http://localhost:8080
@ -21,7 +21,7 @@ services:
# Note: THEME still applies as the default even if selector is hidden
# Version label (optional; shown in footer/diagnostics)
# - APP_VERSION=v2.0.2
- APP_VERSION=v2.2.1
volumes:
# Persist app data locally; ensure these directories exist next to this compose file

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mtg-deckbuilder"
version = "2.1.1"
version = "2.2.1"
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md"
license = {file = "LICENSE"}

View file

@ -11,4 +11,7 @@ uvicorn[standard]>=0.28.0
Jinja2>=3.1.0
python-multipart>=0.0.9
# Config/schema validation
pydantic>=2.5.0
# Development dependencies are in requirements-dev.txt