mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
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:
parent
cc16c6f13a
commit
6c48fb3437
38 changed files with 2042 additions and 131 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -11,6 +11,9 @@ __pycache__/
|
|||
csv_files/
|
||||
dist/
|
||||
logs/
|
||||
deck_files/
|
||||
csv_files/
|
||||
!config/card_lists/*.json
|
||||
!config/deck.json
|
||||
RELEASE_NOTES.md
|
||||
*.bkp
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -12,6 +12,21 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.2.1] - 2025-09-01
|
||||
### Added
|
||||
- Combos & Synergies: detect curated two-card combos/synergies and surface them in a chip-style panel with badges (cheap/early, setup) on Step 5 and Finished Decks.
|
||||
- Dual hover previews for combo rows: hovering a combo shows both cards side-by-side in the standard preview popout; individual names still preview a single card.
|
||||
- Headless (Web Configs): JSON configs now persist and honor combo preferences:
|
||||
- `prefer_combos` (bool)
|
||||
- `combo_target_count` (int)
|
||||
- `combo_balance` ("early" | "late" | "mix")
|
||||
Exported interactive run-config JSON includes these fields when used.
|
||||
- Finished Deck summary includes detected combos/synergies and curated list version badges.
|
||||
- When `prefer_combos` is enabled, Auto-Complete Combos runs before theme fill/monolithic spells so partners aren’t clamped away. Existing completed pairs count toward the target before adding partners.
|
||||
- Step 5 Combos panel updated to the same chip-style as Finished Decks for consistency.
|
||||
- Auto-combos respect color identity by resolving from the filtered pool only; off-color/unavailable partners are skipped.
|
||||
- Added type/mana enrichment for auto-added partners and lock placeholders to avoid “Other” category leakage.
|
||||
|
||||
## [2.1.1] - 2025-08-29
|
||||
### Added
|
||||
- Multi-copy archetypes (Web): opt-in modal suggests packages like Persistent Petitioners, Dragon's Approach, and Shadowborn Apostle when viable; choose quantity and optionally add Thrumming Stone. Applied as the first stage with ideal count adjustments and a per-stage 100-card safety clamp. UI surfaces adjustments and a clamp chip.
|
||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -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 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name.
|
||||
- Compare page now includes a Copy summary button to quickly share diffs.
|
||||
- New Deck modal: shows selected themes and their order (1, 2, 3) inline while picking.
|
||||
- Commander search UX: press Enter to select the first suggestion; arrow key navigation removed per feedback; browser autofill disabled.
|
||||
- Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources.
|
||||
- Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback).
|
||||
- Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts.
|
||||
- Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads.
|
||||
- Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation.
|
||||
- Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label.
|
||||
- Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews.
|
||||
- Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesn’t shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability.
|
||||
- Finished Decks: banner and lists prefer the run’s custom Name when provided; runs include a sidecar `.summary.json` with `meta.name` for display.
|
||||
- Replace toggle includes a tooltip explaining that reruns will replace that stage’s picks when enabled.
|
||||
- Bracket selector labels now include numbers (e.g., "Bracket 3: Upgraded"). Default bracket is 3 when creating a new deck.
|
||||
- Exports: CSV/TXT/JSON now share the same filename stem derived from the optional Name in the modal.
|
||||
|
||||
### Diagnostics and error handling
|
||||
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
|
||||
- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation.
|
||||
- Friendly HTML error pages for 404/4xx/500 with a "Go home" link (browser requests).
|
||||
- Feature flags: `SHOW_DIAGNOSTICS=1` to enable a diagnostics page with test tools; `SHOW_LOGS=1` to enable a logs page and `/status/logs?tail=N`.
|
||||
- Diagnostics page surfaces resolved theme and preference, with a Reset preference action.
|
||||
- Combos & Synergies: detect curated two-card combos and synergies, surface them in a unified chip-style panel on Step 5 and Finished Decks, and preview both cards on hover.
|
||||
- Auto-Complete Combos: optional mode that adds missing partners up to a target before theme fill/monolithic spells so added pairs persist.
|
||||
|
||||
## What’s new
|
||||
- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel.
|
||||
- Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode.
|
||||
- Reporting: deck summary includes per-color card lists for Pips and Sources; colorless 'C' surfaced and totals corrected.
|
||||
- UI Polish: list-mode highlight wraps only the card name. Chart tooltips include a Copy action with hover persistence.
|
||||
- Exports: CSV gains fallback oracle text for basic lands (Plains/Island/Swamp/Mountain/Forest/Wastes) when missing.
|
||||
- Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`).
|
||||
- Prefer-owned bias across creatures and spells selections; Review step includes a toggle next to the owned-only control.
|
||||
- Owned page features and performance improvements via upload-time enrichment and persistence.
|
||||
- Staged build UI can surface skipped stages when enabled.
|
||||
- Detection: exact two-card combos and curated synergies with list version badges (combos.json/synergies.json).
|
||||
- UI polish:
|
||||
- Chip-style rows with compact badges (cheap/early, setup) in both the end-of-build panel and finished deck summary.
|
||||
- Dual-card hover: moving your mouse over a combo row previews both cards side-by-side; hovering a single name shows that card alone.
|
||||
- Ordering: when enabled, Auto-Complete Combos runs earlier (before theme fill and monolithic spells) to retain partners.
|
||||
- Enforcement:
|
||||
- Color identity respected via the filtered pool; off-color or unavailable partners are skipped gracefully.
|
||||
- Honors Locks, Owned-only, and Replace toggles.
|
||||
- Persistence & Headless parity:
|
||||
- Interactive runs export these JSON fields and Web headless runs accept them:
|
||||
- prefer_combos (bool)
|
||||
- combo_target_count (int)
|
||||
- combo_balance ("early" | "late" | "mix")
|
||||
|
||||
## Docker
|
||||
- CLI and Web UI in the same image.
|
||||
- docker-compose includes a `web` service exposing port 8080 by default.
|
||||
- Persistent volumes:
|
||||
- /app/deck_files
|
||||
- /app/logs
|
||||
- /app/csv_files
|
||||
- /app/owned_cards
|
||||
- /app/config
|
||||
|
||||
### Web UI performance tuning
|
||||
- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging during initial setup/tagging in the Web UI
|
||||
- `WEB_TAG_WORKERS=<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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
89
code/deck_builder/combos.py
Normal file
89
code/deck_builder/combos.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
45
code/tagging/combo_schema.py
Normal file
45
code/tagging/combo_schema.py
Normal 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)
|
153
code/tagging/combo_tag_applier.py
Normal file
153
code/tagging/combo_tag_applier.py
Normal 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}")
|
61
code/tests/test_combo_schema_validation.py
Normal file
61
code/tests/test_combo_schema_validation.py
Normal 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))
|
109
code/tests/test_combo_tag_applier.py
Normal file
109
code/tests/test_combo_tag_applier.py
Normal 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": "Thassa’s Oracle", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
{"name": "Demonic Consultation", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
]
|
||||
_write_csv(csv_dir, "blue", rows)
|
||||
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [{"a": "Thassa's Oracle", "b": "Demonic Consultation"}],
|
||||
}
|
||||
combos_path = combos_dir / "combos.json"
|
||||
combos_path.write_text(json.dumps(combos), encoding="utf-8")
|
||||
|
||||
counts = apply_combo_tags(colors=["blue"], combos_path=str(combos_path), csv_dir=str(csv_dir))
|
||||
assert counts.get("blue", 0) >= 1
|
||||
df = pd.read_csv(csv_dir / "blue_cards.csv")
|
||||
row = df[df["name"] == "Thassa’s Oracle"].iloc[0]
|
||||
assert "Demonic Consultation" in row["comboTags"]
|
||||
|
||||
|
||||
def test_split_card_face_matching(tmp_path: Path):
|
||||
csv_dir = tmp_path / "csv"
|
||||
csv_dir.mkdir(parents=True)
|
||||
# Card stored as split name in CSV
|
||||
rows = [
|
||||
{"name": "Fire // Ice", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
{"name": "Isochron Scepter", "themeTags": "[]", "creatureTypes": "[]"},
|
||||
]
|
||||
_write_csv(csv_dir, "izzet", rows)
|
||||
|
||||
combos_dir = tmp_path / "config" / "card_lists"
|
||||
combos_dir.mkdir(parents=True)
|
||||
combos = {
|
||||
"list_version": "0.1.0",
|
||||
"generated_at": None,
|
||||
"pairs": [{"a": "Ice", "b": "Isochron Scepter"}],
|
||||
}
|
||||
combos_path = combos_dir / "combos.json"
|
||||
combos_path.write_text(json.dumps(combos), encoding="utf-8")
|
||||
|
||||
counts = apply_combo_tags(colors=["izzet"], combos_path=str(combos_path), csv_dir=str(csv_dir))
|
||||
assert counts.get("izzet", 0) >= 1
|
||||
df = pd.read_csv(csv_dir / "izzet_cards.csv")
|
||||
row = df[df["name"] == "Fire // Ice"].iloc[0]
|
||||
assert "Isochron Scepter" in row["comboTags"]
|
51
code/tests/test_detect_combos.py
Normal file
51
code/tests/test_detect_combos.py
Normal 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 = ["Thassa’s Oracle", "Demonic Consultation", "Island"]
|
||||
found = detect_combos(deck, combos_path=str(cpath))
|
||||
assert any((fc.a.startswith("Thassa") and fc.b.startswith("Demonic")) for fc in found)
|
||||
assert any(fc.cheap_early for fc in found)
|
||||
|
||||
|
||||
def test_detect_synergies_positive(tmp_path: Path):
|
||||
syn = {
|
||||
"list_version": "0.1.0",
|
||||
"pairs": [
|
||||
{"a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats"]},
|
||||
],
|
||||
}
|
||||
spath = tmp_path / "config/card_lists/synergies.json"
|
||||
_write_json(spath, syn)
|
||||
|
||||
deck = ["Swamp", "Grave Pact", "Phyrexian Altar"]
|
||||
found = detect_synergies(deck, synergies_path=str(spath))
|
||||
assert any((fs.a == "Grave Pact" and fs.b == "Phyrexian Altar") for fs in found)
|
||||
|
||||
|
||||
def test_detect_combos_negative(tmp_path: Path):
|
||||
combos = {"list_version": "0.1.0", "pairs": [{"a": "A", "b": "B"}]}
|
||||
cpath = tmp_path / "config/card_lists/combos.json"
|
||||
_write_json(cpath, combos)
|
||||
found = detect_combos(["A"], combos_path=str(cpath))
|
||||
assert not found
|
17
code/tests/test_detect_combos_expanded.py
Normal file
17
code/tests/test_detect_combos_expanded.py
Normal 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
|
19
code/tests/test_detect_combos_more_new.py
Normal file
19
code/tests/test_detect_combos_more_new.py
Normal 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
|
58
code/tests/test_diagnostics_combos_api.py
Normal file
58
code/tests/test_diagnostics_combos_api.py
Normal 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": ["Thassa’s Oracle", "Demonic Consultation", "Grave Pact", "Phyrexian Altar"],
|
||||
"combos_path": str(cpath),
|
||||
"synergies_path": str(spath),
|
||||
}
|
||||
resp = client.post("/diagnostics/combos", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["counts"]["combos"] == 1
|
||||
assert data["counts"]["synergies"] == 1
|
||||
assert data["versions"]["combos"] == "0.1.0"
|
||||
# Ensure flags are present from payload
|
||||
c = data["combos"][0]
|
||||
assert c.get("cheap_early") is True
|
||||
assert c.get("setup_dependent") is False
|
24
code/tests/test_diagnostics_page.py
Normal file
24
code/tests/test_diagnostics_page.py
Normal 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
|
|
@ -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],
|
||||
}
|
||||
)
|
||||
|
|
|
@ -10,6 +10,8 @@ from ..services.tasks import get_session, new_sid
|
|||
from html import escape as _esc
|
||||
from deck_builder.builder import DeckBuilder
|
||||
from deck_builder import builder_utils as bu
|
||||
from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies
|
||||
from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
||||
|
@ -67,6 +69,9 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
|||
locks=locks,
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
except Exception:
|
||||
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
||||
|
@ -287,6 +292,8 @@ async def multicopy_save(
|
|||
return HTMLResponse(chip)
|
||||
|
||||
|
||||
|
||||
|
||||
# Unified "New Deck" modal (steps 1–3 condensed)
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def build_new_modal(request: Request) -> HTMLResponse:
|
||||
|
@ -350,6 +357,9 @@ async def build_new_submit(
|
|||
wipes: int = Form(None),
|
||||
card_advantage: int = Form(None),
|
||||
protection: int = Form(None),
|
||||
prefer_combos: bool = Form(False),
|
||||
combo_count: int | None = Form(None),
|
||||
combo_balance: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
@ -372,6 +382,9 @@ async def build_new_submit(
|
|||
"tertiary_tag": tertiary_tag or "",
|
||||
"tag_mode": tag_mode or "AND",
|
||||
"bracket": bracket,
|
||||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
}
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
|
@ -416,6 +429,24 @@ async def build_new_submit(
|
|||
except Exception:
|
||||
pass
|
||||
sess["ideals"] = ideals
|
||||
# Persist preferences
|
||||
try:
|
||||
sess["prefer_combos"] = bool(prefer_combos)
|
||||
except Exception:
|
||||
sess["prefer_combos"] = False
|
||||
# Combos config from modal
|
||||
try:
|
||||
if combo_count is not None:
|
||||
sess["combo_target_count"] = max(0, min(10, int(combo_count)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if combo_balance:
|
||||
bval = str(combo_balance).strip().lower()
|
||||
if bval in ("early","late","mix"):
|
||||
sess["combo_balance"] = bval
|
||||
except Exception:
|
||||
pass
|
||||
# Clear any old staged build context
|
||||
for k in ["build_ctx", "locks", "replace_mode"]:
|
||||
if k in sess:
|
||||
|
@ -465,6 +496,9 @@ async def build_new_submit(
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
|
||||
status = "Build complete" if res.get("done") else "Stage complete"
|
||||
|
@ -500,6 +534,9 @@ async def build_new_submit(
|
|||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -707,6 +744,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
ctx = sess["build_ctx"]
|
||||
# Run forward until reaching target
|
||||
|
@ -748,6 +788,9 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
|
|||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"history": ctx.get("history", []),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -990,6 +1033,183 @@ async def build_step4_get(request: Request) -> HTMLResponse:
|
|||
)
|
||||
|
||||
|
||||
# --- Combos & Synergies panel (M3) ---
|
||||
def _get_current_deck_names(sess: dict) -> list[str]:
|
||||
try:
|
||||
ctx = sess.get("build_ctx") or {}
|
||||
b = ctx.get("builder")
|
||||
lib = getattr(b, "card_library", {}) if b is not None else {}
|
||||
names = [str(n) for n in lib.keys()]
|
||||
return sorted(dict.fromkeys(names))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/combos", response_class=HTMLResponse)
|
||||
async def build_combos_panel(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
names = _get_current_deck_names(sess)
|
||||
if not names:
|
||||
# No active build; render nothing to avoid UI clutter
|
||||
return HTMLResponse("")
|
||||
|
||||
# Preferences (persisted in session)
|
||||
policy = (sess.get("combos_policy") or "neutral").lower()
|
||||
if policy not in {"avoid", "neutral", "prefer"}:
|
||||
policy = "neutral"
|
||||
try:
|
||||
target = int(sess.get("combos_target") or 0)
|
||||
except Exception:
|
||||
target = 0
|
||||
if target < 0:
|
||||
target = 0
|
||||
|
||||
# Load lists and run detection
|
||||
try:
|
||||
combos_model = _load_combos("config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos_model = None
|
||||
try:
|
||||
combos = _detect_combos(names, combos_path="config/card_lists/combos.json")
|
||||
except Exception:
|
||||
combos = []
|
||||
try:
|
||||
synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json")
|
||||
except Exception:
|
||||
synergies = []
|
||||
try:
|
||||
synergies_model = _load_synergies("config/card_lists/synergies.json")
|
||||
except Exception:
|
||||
synergies_model = None
|
||||
|
||||
# Suggestions
|
||||
suggestions: list[dict] = []
|
||||
present = {s.strip().lower() for s in names}
|
||||
suggested_names: set[str] = set()
|
||||
if combos_model is not None:
|
||||
# Prefer policy: suggest adding a missing partner to hit target count
|
||||
if policy == "prefer":
|
||||
try:
|
||||
for p in combos_model.pairs:
|
||||
a = str(p.a).strip()
|
||||
b = str(p.b).strip()
|
||||
a_in = a.lower() in present
|
||||
b_in = b.lower() in present
|
||||
if a_in ^ b_in: # exactly one present
|
||||
missing = b if a_in else a
|
||||
have = a if a_in else b
|
||||
item = {
|
||||
"kind": "add",
|
||||
"have": have,
|
||||
"name": missing,
|
||||
"cheap_early": bool(getattr(p, "cheap_early", False)),
|
||||
"setup_dependent": bool(getattr(p, "setup_dependent", False)),
|
||||
}
|
||||
key = str(missing).strip().lower()
|
||||
if key not in present and key not in suggested_names:
|
||||
suggestions.append(item)
|
||||
suggested_names.add(key)
|
||||
# Rank: cheap/early first, then setup-dependent, then name
|
||||
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
|
||||
# If we still have room below target, add synergy-based suggestions
|
||||
rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions)
|
||||
if rem > 0 and synergies_model is not None:
|
||||
# lightweight tag weights to bias common engines
|
||||
weights = {
|
||||
"treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3,
|
||||
"engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9,
|
||||
"counters": 1.8, "equipment": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
|
||||
"damage": 1.3, "stax": 1.2
|
||||
}
|
||||
syn_sugs: list[dict] = []
|
||||
for p in synergies_model.pairs:
|
||||
a = str(p.a).strip()
|
||||
b = str(p.b).strip()
|
||||
a_in = a.lower() in present
|
||||
b_in = b.lower() in present
|
||||
if a_in ^ b_in:
|
||||
missing = b if a_in else a
|
||||
have = a if a_in else b
|
||||
mkey = missing.strip().lower()
|
||||
if mkey in present or mkey in suggested_names:
|
||||
continue
|
||||
tags = list(getattr(p, "tags", []) or [])
|
||||
score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1)
|
||||
syn_sugs.append({
|
||||
"kind": "add",
|
||||
"have": have,
|
||||
"name": missing,
|
||||
"cheap_early": False,
|
||||
"setup_dependent": False,
|
||||
"tags": tags,
|
||||
"_score": score,
|
||||
})
|
||||
suggested_names.add(mkey)
|
||||
# rank by score desc then name
|
||||
syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower()))
|
||||
if rem > 0:
|
||||
suggestions.extend(syn_sugs[:rem])
|
||||
# Finally trim to target or default cap
|
||||
cap = (int(target) if target > 0 else 8)
|
||||
suggestions = suggestions[:cap]
|
||||
except Exception:
|
||||
suggestions = []
|
||||
elif policy == "avoid":
|
||||
# Avoid policy: suggest cutting one piece from detected combos
|
||||
try:
|
||||
for c in combos:
|
||||
# pick the second card as default cut to vary suggestions
|
||||
suggestions.append({
|
||||
"kind": "cut",
|
||||
"name": c.b,
|
||||
"partner": c.a,
|
||||
"cheap_early": bool(getattr(c, "cheap_early", False)),
|
||||
"setup_dependent": bool(getattr(c, "setup_dependent", False)),
|
||||
})
|
||||
# Rank: cheap/early first
|
||||
suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower()))
|
||||
if target > 0:
|
||||
suggestions = suggestions[: target]
|
||||
else:
|
||||
suggestions = suggestions[: 8]
|
||||
except Exception:
|
||||
suggestions = []
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"policy": policy,
|
||||
"target": target,
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": {
|
||||
"combos": getattr(combos_model, "list_version", None) if combos_model else None,
|
||||
"synergies": getattr(synergies_model, "list_version", None) if synergies_model else None,
|
||||
},
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
return templates.TemplateResponse("build/_combos_panel.html", ctx)
|
||||
|
||||
|
||||
@router.post("/combos/prefs", response_class=HTMLResponse)
|
||||
async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
pol = (policy or "neutral").strip().lower()
|
||||
if pol not in {"avoid", "neutral", "prefer"}:
|
||||
pol = "neutral"
|
||||
try:
|
||||
tgt = int(target)
|
||||
except Exception:
|
||||
tgt = 0
|
||||
if tgt < 0:
|
||||
tgt = 0
|
||||
sess["combos_policy"] = pol
|
||||
sess["combos_target"] = tgt
|
||||
# Re-render the panel
|
||||
return await build_combos_panel(request)
|
||||
|
||||
|
||||
@router.post("/toggle-owned-review", response_class=HTMLResponse)
|
||||
async def build_toggle_owned_review(
|
||||
request: Request,
|
||||
|
@ -1056,6 +1276,9 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
|||
"skipped": False,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -1098,6 +1321,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
else:
|
||||
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
|
||||
|
@ -1196,6 +1422,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
"skipped": bool(res.get("skipped")),
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -1236,6 +1465,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
else:
|
||||
# Ensure latest locks are reflected in the existing context
|
||||
|
@ -1408,6 +1640,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
show_skipped = False
|
||||
try:
|
||||
|
@ -1572,12 +1807,14 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
|
|||
"skipped": False,
|
||||
"locks": list(sess.get("locks", [])),
|
||||
"replace_mode": bool(sess.get("replace_mode")),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
# --- Phase 8: Lock/Replace/Compare/Permalink minimal API ---
|
||||
|
||||
@router.post("/lock")
|
||||
|
|
|
@ -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"))
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
|
|
28
code/web/templates/build/_combo_limit_modal.html
Normal file
28
code/web/templates/build/_combo_limit_modal.html
Normal 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>
|
76
code/web/templates/build/_combos_panel.html
Normal file
76
code/web/templates/build/_combos_panel.html
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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){
|
||||
|
|
|
@ -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"]');
|
||||
|
|
182
config/card_lists/combos.json
Normal file
182
config/card_lists/combos.json
Normal 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"] }
|
||||
]
|
||||
}
|
124
config/card_lists/synergies.json
Normal file
124
config/card_lists/synergies.json
Normal 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" }
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue