web: DRY Step 5 and alternatives (partial+macro), centralize start_ctx/owned_set, adopt builder_*

This commit is contained in:
mwisnowski 2025-09-02 11:39:14 -07:00
parent fe9aabbce9
commit 014bcc37b7
24 changed files with 1200 additions and 766 deletions

8
.gitattributes vendored Normal file
View file

@ -0,0 +1,8 @@
# Normalize line endings and enforce LF for shell scripts
* text=auto eol=lf
# Scripts
*.sh text eol=lf
# Windows-friendly: keep .bat with CRLF
*.bat text eol=crlf

View file

@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5.0.0
- name: Prepare release notes from template - name: Prepare release notes from template
id: notes id: notes
@ -35,10 +35,10 @@ jobs:
echo "version=$VERSION_REF" >> $GITHUB_OUTPUT echo "version=$VERSION_REF" >> $GITHUB_OUTPUT
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3.11.1
- name: Smoke test image boots Web UI by default (amd64) - name: Smoke test image boots Web UI by default (amd64)
shell: bash shell: bash
@ -61,14 +61,14 @@ jobs:
docker rm -f mtg-smoke >/dev/null 2>&1 || true docker rm -f mtg-smoke >/dev/null 2>&1 || true
- name: Docker Hub login - name: Docker Hub login
uses: docker/login-action@v3 uses: docker/login-action@v3.5.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5.8.0
with: with:
images: | images: |
mwisnowski/mtg-python-deckbuilder mwisnowski/mtg-python-deckbuilder
@ -82,7 +82,7 @@ jobs:
org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.revision=${{ github.sha }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6.18.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View file

@ -12,10 +12,10 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5.0.0
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5.6.0
with: with:
python-version: '3.11' python-version: '3.11'
@ -38,7 +38,7 @@ jobs:
if (!(Test-Path dist/mtg-deckbuilder.exe)) { throw 'Build failed: dist/mtg-deckbuilder.exe not found' } if (!(Test-Path dist/mtg-deckbuilder.exe)) { throw 'Build failed: dist/mtg-deckbuilder.exe not found' }
- name: Upload artifact (Windows EXE) - name: Upload artifact (Windows EXE)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4.6.2
with: with:
name: mtg-deckbuilder-windows name: mtg-deckbuilder-windows
path: dist/mtg-deckbuilder.exe path: dist/mtg-deckbuilder.exe
@ -50,7 +50,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5.0.0
- name: Prepare release notes - name: Prepare release notes
id: notes id: notes
@ -70,13 +70,13 @@ jobs:
echo "notes_file=RELEASE_NOTES.md" >> $GITHUB_OUTPUT echo "notes_file=RELEASE_NOTES.md" >> $GITHUB_OUTPUT
- name: Download build artifacts - name: Download build artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5.0.0
with: with:
name: mtg-deckbuilder-windows name: mtg-deckbuilder-windows
path: artifacts path: artifacts
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2.3.2
with: with:
tag_name: ${{ steps.notes.outputs.version }} tag_name: ${{ steps.notes.outputs.version }}
name: ${{ steps.notes.outputs.version }} name: ${{ steps.notes.outputs.version }}

View file

@ -12,6 +12,49 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added
- Web: Setup/Refresh prompt modal shown on Create when environment is missing or stale; routes to `/setup/running` (force on stale) and transitions into the progress view. Template: `web/templates/build/_setup_prompt_modal.html`.
- Orchestrator helpers: `is_setup_ready()` and `is_setup_stale()` for non-invasive readiness/staleness checks from the UI.
- Env flags for setup behavior: `WEB_AUTO_SETUP` (default 1) to enable/disable auto setup, and `WEB_AUTO_REFRESH_DAYS` (default 7) to tune staleness.
- Step 5 error context helper: `web/services/build_utils.step5_error_ctx()` to standardize error payloads for `_step5.html`.
- Templates: reusable lock/unlock button macro at `web/templates/partials/_macros.html`.
- Templates: Alternatives panel partial at `web/templates/build/_alternatives.html` (renders candidates with Owned-only toggle and Replace actions).
### Tests
- Added smoke/unit tests covering:
- `summary_utils.summary_ctx()`
- `build_utils.start_ctx_from_session()` (monkeypatched orchestrator)
- `orchestrator` staleness/setup paths
- `build_utils.step5_error_ctx()` shape and flags
### Changed
- Web cleanup: centralized combos/synergies detection and model/version loading in `web/services/combo_utils.py` and refactored routes to use it:
- `routes/build.py` (Combos panel), `routes/configs.py` (run results), `routes/decks.py` (finished/compare), and diagnostics endpoint in `app.py`.
- Create (New Deck) flow: no longer auto-runs setup on submit; instead presents a modal prompt to run setup/refresh when needed.
- Step 5 builder flow: deduplicated template context assembly via `web/services/build_utils.py` helpers and refactored `web/routes/build.py` accordingly (fewer repeated dicts, consistent fields).
- Staged build context creation centralized via `web/services/build_utils.start_ctx_from_session` and applied across Step 5 flows in `web/routes/build.py` (New submit, Continue, Start, Rerun, Rewind).
- Owned-cards set creation centralized via `web/services/build_utils.owned_set()` and used in `web/routes/build.py`, `web/routes/configs.py`, and `web/routes/decks.py`.
- Step 5: replaced ad-hoc empty context assembly with `web/services/build_utils.step5_empty_ctx()` in GET `/build/step5` and `reset-stage`.
- Builder introspection: adopted `builder_present_names()` and `builder_display_map()` helpers in `web/routes/build.py` for locked-cards and alternatives, reducing duplication and improving casing consistency.
- Alternatives endpoint now renders the new partial (`build/_alternatives.html`) via Jinja and caches the HTML (no more string-built HTML in the route).
### Added
- Deck summary: introduced `web/services/summary_utils.summary_ctx()` to unify summary context (owned_set, game_changers, combos/synergies, versions).
- Alternatives cache helper extracted to `web/services/alts_utils.py`.
### Changed
- Decks and Configs routes now use `summary_ctx()` to render deck summaries, reducing duplication and ensuring consistent fields.
- Build: routed owned names via helper and fixed `_rebuild_ctx_with_multicopy` context indentation.
- Build: moved alternatives TTL cache into `services/alts_utils` for readability.
- Build: Step 5 start error path now uses `step5_error_ctx()` for a consistent UI.
- Build: Extended Step 5 error handling to Continue, Rerun, and Rewind using `step5_error_ctx()`.
### Fixed
- Docker: normalized line endings for `entrypoint.sh` during image build to avoid `env: 'sh\r': No such file or directory` on Windows checkouts.
### Removed
- Duplicate root route removed: `web/routes/home.py` was deleted; the app root is served by `web/app.py`.
## [2.2.3] - 2025-09-01 ## [2.2.3] - 2025-09-01
### Fixes ### Fixes
- Bug causing basic lands to no longer be added due to combined dataframe not including basics - Bug causing basic lands to no longer be added due to combined dataframe not including basics

View file

@ -55,7 +55,9 @@ WORKDIR /app/code
# Add a tiny entrypoint to select Web UI (default) or CLI # Add a tiny entrypoint to select Web UI (default) or CLI
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh # Normalize line endings in case the file was checked out with CRLF on Windows
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && \
chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Expose web port for the optional Web UI # Expose web port for the optional Web UI

View file

@ -0,0 +1,60 @@
from __future__ import annotations
from code.web.services.build_utils import start_ctx_from_session, owned_set, owned_names
def _fake_session(**kw):
# Provide minimal session keys used by start_ctx_from_session
base = {
"commander": "Cmdr",
"tags": ["Aggro", "Spells"],
"bracket": 3,
"ideals": {"creatures": 25},
"tag_mode": "AND",
"use_owned_only": False,
"prefer_owned": False,
"locks": [],
"custom_export_base": "TestDeck",
"multi_copy": None,
"prefer_combos": False,
"combo_target_count": 2,
"combo_balance": "mix",
}
base.update(kw)
return base
def test_owned_helpers_do_not_crash():
# These reflect over the owned store; they should be resilient
s = owned_set()
assert isinstance(s, set)
n = owned_names()
assert isinstance(n, list)
def test_start_ctx_from_session_minimal(monkeypatch):
# Avoid integration dependency by faking orchestrator.start_build_ctx
calls = {}
def _fake_start_build_ctx(**kwargs):
calls.update(kwargs)
return {"builder": object(), "stages": [], "idx": 0, "last_visible_idx": 0}
import code.web.services.build_utils as bu
monkeypatch.setattr(bu.orch, "start_build_ctx", _fake_start_build_ctx)
sess = _fake_session()
ctx = start_ctx_from_session(sess, set_on_session=False)
assert isinstance(ctx, dict)
assert "builder" in ctx
assert "stages" in ctx
assert "idx" in ctx
def test_start_ctx_from_session_sets_on_session(monkeypatch):
def _fake_start_build_ctx(**kwargs):
return {"builder": object(), "stages": [], "idx": 0}
import code.web.services.build_utils as bu
monkeypatch.setattr(bu.orch, "start_build_ctx", _fake_start_build_ctx)
sess = _fake_session()
ctx = start_ctx_from_session(sess, set_on_session=True)
assert sess.get("build_ctx") == ctx

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from code.web.services.orchestrator import is_setup_ready, is_setup_stale
def test_is_setup_ready_false_when_missing():
# On a clean checkout without csv_files, this should be False
assert is_setup_ready() in (False, True) # Function exists and returns a bool
def test_is_setup_stale_never_when_disabled_env(monkeypatch):
monkeypatch.setenv("WEB_AUTO_REFRESH_DAYS", "0")
assert is_setup_stale() is False
def test_is_setup_stale_is_bool():
# We don't assert specific timing behavior in unit tests; just type/robustness
res = is_setup_stale()
assert res in (False, True)

View file

@ -0,0 +1,76 @@
from __future__ import annotations
from types import SimpleNamespace
from code.web.services.build_utils import step5_error_ctx
class _Req(SimpleNamespace):
# minimal object to satisfy template context needs
pass
def test_step5_error_ctx_shape():
req = _Req()
sess = {
"commander": "Atraxa, Praetors' Voice",
"tags": ["+1/+1 Counters"],
"bracket": 3,
"ideals": {"lands": 36},
"use_owned_only": False,
"prefer_owned": False,
"replace_mode": True,
"locks": ["sol ring"],
}
ctx = step5_error_ctx(req, sess, "Boom")
# Ensure required keys for _step5.html are present with safe defaults
for k in (
"request",
"commander",
"tags",
"bracket",
"values",
"owned_only",
"prefer_owned",
"owned_set",
"game_changers",
"replace_mode",
"prefer_combos",
"combo_target_count",
"combo_balance",
"status",
"stage_label",
"log",
"added_cards",
"i",
"n",
"csv_path",
"txt_path",
"summary",
"show_skipped",
"total_cards",
"added_total",
"skipped",
):
assert k in ctx
assert ctx["status"] == "Error"
assert isinstance(ctx["added_cards"], list)
assert ctx["show_skipped"] is False
def test_step5_error_ctx_respects_flags():
req = _Req()
sess = {
"use_owned_only": True,
"prefer_owned": True,
"combo_target_count": 3,
"combo_balance": "early",
}
ctx = step5_error_ctx(req, sess, "Oops", include_name=False, include_locks=False)
assert "name" not in ctx
assert "locks" not in ctx
# Flags should flow through
assert ctx["owned_only"] is True
assert ctx["prefer_owned"] is True
assert ctx["combo_target_count"] == 3
assert ctx["combo_balance"] == "early"

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from code.web.services.summary_utils import summary_ctx
def test_summary_ctx_empty_summary():
ctx = summary_ctx(summary=None, commander="Test Commander", tags=["Aggro"])
assert isinstance(ctx, dict)
assert ctx.get("owned_set") is not None
assert isinstance(ctx.get("combos"), list)
assert isinstance(ctx.get("synergies"), list)
assert ctx.get("versions") == {}
assert ctx.get("commander") == "Test Commander"
assert ctx.get("tags") == ["Aggro"]
def test_summary_ctx_with_summary_basic():
# Minimal fake summary structure sufficient for detect_for_summary to accept
summary = {
"type_breakdown": {"counts": {}, "order": [], "cards": {}, "total": 0},
"pip_distribution": {"counts": {}, "weights": {}},
"mana_generation": {},
"mana_curve": {"total_spells": 0},
"colors": [],
}
ctx = summary_ctx(summary=summary, commander="Cmdr", tags=["Spells"])
assert "owned_set" in ctx and isinstance(ctx["owned_set"], set)
assert "game_changers" in ctx
assert "combos" in ctx and isinstance(ctx["combos"], list)
assert "synergies" in ctx and isinstance(ctx["synergies"], list)
assert "versions" in ctx and isinstance(ctx["versions"], dict)

View file

@ -2,11 +2,6 @@ from __future__ import annotations
from fastapi import FastAPI, Request, HTTPException, Query from fastapi import FastAPI, Request, HTTPException, Query
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response 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.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
@ -17,7 +12,8 @@ import uuid
import logging import logging
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
from typing import Any, Tuple from typing import Any
from .services.combo_utils import detect_all as _detect_all
# Resolve template/static dirs relative to this file # Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent _THIS_DIR = Path(__file__).resolve().parent
@ -76,7 +72,7 @@ templates.env.globals.update({
}) })
# --- Simple fragment cache for template partials (low-risk, TTL-based) --- # --- Simple fragment cache for template partials (low-risk, TTL-based) ---
_FRAGMENT_CACHE: dict[Tuple[str, str], tuple[float, str]] = {} _FRAGMENT_CACHE: dict[tuple[str, str], tuple[float, str]] = {}
_FRAGMENT_TTL_SECONDS = 60.0 _FRAGMENT_TTL_SECONDS = 60.0
def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str: def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str:
@ -415,10 +411,10 @@ async def diagnostics_combos(request: Request) -> JSONResponse:
combos_path = payload.get("combos_path") or "config/card_lists/combos.json" combos_path = payload.get("combos_path") or "config/card_lists/combos.json"
synergies_path = payload.get("synergies_path") or "config/card_lists/synergies.json" synergies_path = payload.get("synergies_path") or "config/card_lists/synergies.json"
combos_model = _load_combos(combos_path) det = _detect_all(names, combos_path=combos_path, synergies_path=synergies_path)
synergies_model = _load_synergies(synergies_path) combos = det.get("combos", [])
combos = _detect_combos(names, combos_path=combos_path) synergies = det.get("synergies", [])
synergies = _detect_synergies(names, synergies_path=synergies_path) versions = det.get("versions", {"combos": None, "synergies": None})
def as_dict_combo(c): def as_dict_combo(c):
return { return {
@ -435,7 +431,7 @@ async def diagnostics_combos(request: Request) -> JSONResponse:
return JSONResponse( return JSONResponse(
{ {
"counts": {"combos": len(combos), "synergies": len(synergies)}, "counts": {"combos": len(combos), "synergies": len(synergies)},
"versions": {"combos": combos_model.list_version, "synergies": synergies_model.list_version}, "versions": {"combos": versions.get("combos"), "synergies": versions.get("synergies")},
"combos": [as_dict_combo(c) for c in combos], "combos": [as_dict_combo(c) for c in combos],
"synergies": [as_dict_syn(s) for s in synergies], "synergies": [as_dict_syn(s) for s in synergies],
} }

View file

@ -2,37 +2,30 @@ from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from ..services.build_utils import (
step5_ctx_from_result,
step5_error_ctx,
step5_empty_ctx,
start_ctx_from_session,
owned_set as owned_set_helper,
builder_present_names,
builder_display_map,
)
from ..app import templates from ..app import templates
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from ..services import orchestrator as orch from ..services import orchestrator as orch
from ..services import owned_store from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore
from ..services.build_utils import owned_names as owned_names_helper
from ..services.tasks import get_session, new_sid from ..services.tasks import get_session, new_sid
from html import escape as _esc from html import escape as _esc
from deck_builder.builder import DeckBuilder from deck_builder.builder import DeckBuilder
from deck_builder import builder_utils as bu from deck_builder import builder_utils as bu
from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies from ..services.combo_utils import detect_all as _detect_all
from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
router = APIRouter(prefix="/build") router = APIRouter(prefix="/build")
# --- lightweight in-memory TTL cache for alternatives (Phase 9 planned item) --- # Alternatives cache moved to services/alts_utils
_ALTS_CACHE: dict[tuple[str, str, bool], tuple[float, str]] = {}
_ALTS_TTL_SECONDS = 60.0 # short TTL; avoids stale UI while helping burst traffic
def _alts_get_cached(key: tuple[str, str, bool]) -> str | None:
try:
ts, html = _ALTS_CACHE.get(key, (0.0, ""))
import time as _t
if ts and (_t.time() - ts) < _ALTS_TTL_SECONDS:
return html
except Exception:
return None
return None
def _alts_set_cached(key: tuple[str, str, bool], html: str) -> None:
try:
import time as _t
_ALTS_CACHE[key] = (_t.time(), html)
except Exception:
pass
def _rebuild_ctx_with_multicopy(sess: dict) -> None: def _rebuild_ctx_with_multicopy(sess: dict) -> None:
@ -49,13 +42,13 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
default_bracket = (opts[0]["level"] if opts else 1) default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket") bracket_val = sess.get("bracket")
try: try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket) safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket
except Exception: except Exception:
safe_bracket = int(default_bracket) safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults() ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only")) use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned")) prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None owned_names = owned_names_helper() if (use_owned or prefer) else None
locks = list(sess.get("locks", [])) locks = list(sess.get("locks", []))
sess["build_ctx"] = orch.start_build_ctx( sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"), commander=sess.get("commander"),
@ -470,75 +463,43 @@ async def build_new_submit(
del sess["custom_export_base"] del sess["custom_export_base"]
except Exception: except Exception:
pass pass
# If setup/tagging is not ready or stale, show a modal prompt instead of auto-running.
try:
if not _is_setup_ready():
return templates.TemplateResponse(
"build/_setup_prompt_modal.html",
{
"request": request,
"title": "Setup required",
"message": "The card database and tags need to be prepared before building a deck.",
"action_url": "/setup/running?start=1&next=/build",
"action_label": "Run Setup",
},
)
if _is_setup_stale():
return templates.TemplateResponse(
"build/_setup_prompt_modal.html",
{
"request": request,
"title": "Data refresh recommended",
"message": "Your card database is stale. Refreshing ensures up-to-date results.",
"action_url": "/setup/running?start=1&force=1&next=/build",
"action_label": "Refresh Now",
},
)
except Exception:
# If readiness check fails, continue and let downstream handling surface errors
pass
# Immediately initialize a build context and run the first stage, like hitting Build Deck on review # Immediately initialize a build context and run the first stage, like hitting Build Deck on review
if "replace_mode" not in sess: if "replace_mode" not in sess:
sess["replace_mode"] = True sess["replace_mode"] = True
opts = orch.bracket_options() # Centralized staged context creation
default_bracket = (opts[0]["level"] if opts else 1) sess["build_ctx"] = start_ctx_from_session(sess)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
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) res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False)
status = "Build complete" if res.get("done") else "Stage complete" status = "Build complete" if res.get("done") else "Stage complete"
sess["last_step"] = 5 sess["last_step"] = 5
resp = templates.TemplateResponse( ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False)
"build/_step5.html", resp = templates.TemplateResponse("build/_step5.html", ctx)
{
"request": request,
"commander": sess.get("commander"),
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": res.get("label"),
"log": res.get("log_delta", ""),
"added_cards": res.get("added_cards", []),
"i": res.get("idx"),
"n": res.get("total"),
"csv_path": res.get("csv_path") if res.get("done") else None,
"txt_path": res.get("txt_path") if res.get("done") else None,
"summary": res.get("summary") if res.get("done") else None,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": False,
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
"mc_adjustments": res.get("mc_adjustments"),
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"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") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -721,33 +682,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
ctx["last_visible_idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1
except Exception: except Exception:
# As a fallback, restart ctx and run forward until target # As a fallback, restart ctx and run forward until target
opts = orch.bracket_options() sess["build_ctx"] = start_ctx_from_session(sess)
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
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"] ctx = sess["build_ctx"]
# Run forward until reaching target # Run forward until reaching target
while True: while True:
@ -757,42 +692,16 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo
if res.get("done"): if res.get("done"):
break break
# Finally show the target stage by running it with show_skipped True to get a view # Finally show the target stage by running it with show_skipped True to get a view
res = orch.run_stage(ctx, rerun=False, show_skipped=True) try:
status = "Stage (rewound)" if not res.get("done") else "Build complete" res = orch.run_stage(ctx, rerun=False, show_skipped=True)
resp = templates.TemplateResponse( status = "Stage (rewound)" if not res.get("done") else "Build complete"
"build/_step5.html", ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={
{
"request": request,
"commander": sess.get("commander"),
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": res.get("label"),
"log": res.get("log_delta", ""),
"added_cards": res.get("added_cards", []),
"i": res.get("idx"),
"n": res.get("total"),
"game_changers": bc.GAME_CHANGERS,
"show_skipped": True,
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
"mc_adjustments": res.get("mc_adjustments"),
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
"history": ctx.get("history", []), "history": ctx.get("history", []),
"prefer_combos": bool(sess.get("prefer_combos")), })
"combo_target_count": int(sess.get("combo_target_count", 2)), except Exception as e:
"combo_balance": str(sess.get("combo_balance", "mix")), sess["last_step"] = 5
}, ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}")
) resp = templates.TemplateResponse("build/_step5.html", ctx_resp)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -1066,22 +975,11 @@ async def build_combos_panel(request: Request) -> HTMLResponse:
target = 0 target = 0
# Load lists and run detection # Load lists and run detection
try: _det = _detect_all(names)
combos_model = _load_combos("config/card_lists/combos.json") combos = _det.get("combos", [])
except Exception: synergies = _det.get("synergies", [])
combos_model = None combos_model = _det.get("combos_model")
try: synergies_model = _det.get("synergies_model")
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
suggestions: list[dict] = [] suggestions: list[dict] = []
@ -1182,10 +1080,7 @@ async def build_combos_panel(request: Request) -> HTMLResponse:
"target": target, "target": target,
"combos": combos, "combos": combos,
"synergies": synergies, "synergies": synergies,
"versions": { "versions": _det.get("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, "suggestions": suggestions,
} }
return templates.TemplateResponse("build/_combos_panel.html", ctx) return templates.TemplateResponse("build/_combos_panel.html", ctx)
@ -1251,36 +1146,8 @@ async def build_step5_get(request: Request) -> HTMLResponse:
# Default replace-mode to ON unless explicitly toggled off # Default replace-mode to ON unless explicitly toggled off
if "replace_mode" not in sess: if "replace_mode" not in sess:
sess["replace_mode"] = True sess["replace_mode"] = True
resp = templates.TemplateResponse( base = step5_empty_ctx(request, sess)
"build/_step5.html", resp = templates.TemplateResponse("build/_step5.html", base)
{
"request": request,
"commander": sess.get("commander"),
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"locks": list(sess.get("locks", [])),
"status": None,
"stage_label": None,
"log": None,
"added_cards": [],
"i": None,
"n": None,
"total_cards": None,
"added_total": 0,
"show_skipped": False,
"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") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -1297,34 +1164,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
return resp return resp
# Ensure build context exists; if not, start it first # Ensure build context exists; if not, start it first
if not sess.get("build_ctx"): if not sess.get("build_ctx"):
opts = orch.bracket_options() sess["build_ctx"] = start_ctx_from_session(sess)
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
# Owned-only integration for staged builds
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
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: else:
# If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet # If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet
try: try:
@ -1371,11 +1211,16 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
show_skipped = True show_skipped = True
except Exception: except Exception:
pass pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) try:
status = "Build complete" if res.get("done") else "Stage complete" res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Build complete" if res.get("done") else "Stage complete"
except Exception as e:
sess["last_step"] = 5
err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
stage_label = res.get("label") stage_label = res.get("label")
log = res.get("log_delta", "")
added_cards = res.get("added_cards", [])
# If we just applied Multi-Copy, stamp the applied key so we don't rebuild again # If we just applied Multi-Copy, stamp the applied key so we don't rebuild again
try: try:
if stage_label == "Multi-Copy Package" and sess.get("multi_copy"): if stage_label == "Multi-Copy Package" and sess.get("multi_copy"):
@ -1383,50 +1228,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception: except Exception:
pass pass
# Progress & downloads
i = res.get("idx")
n = res.get("total")
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
total_cards = res.get("total_cards")
added_total = res.get("added_total")
sess["last_step"] = 5 sess["last_step"] = 5
resp = templates.TemplateResponse( ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
"build/_step5.html", resp = templates.TemplateResponse("build/_step5.html", ctx2)
{
"request": request,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
"added_cards": added_cards,
"i": i,
"n": n,
"csv_path": csv_path,
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"total_cards": total_cards,
"added_total": added_total,
"mc_adjustments": res.get("mc_adjustments"),
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"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") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -1442,33 +1246,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
return resp return resp
# Rerun requires an existing context; if missing, create it and run first stage as rerun # Rerun requires an existing context; if missing, create it and run first stage as rerun
if not sess.get("build_ctx"): if not sess.get("build_ctx"):
opts = orch.bracket_options() sess["build_ctx"] = start_ctx_from_session(sess)
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
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: else:
# Ensure latest locks are reflected in the existing context # Ensure latest locks are reflected in the existing context
try: try:
@ -1484,75 +1262,26 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
# If replace-mode is OFF, keep the stage visible even if no new cards were added # If replace-mode is OFF, keep the stage visible even if no new cards were added
if not bool(sess.get("replace_mode", True)): if not bool(sess.get("replace_mode", True)):
show_skipped = True show_skipped = True
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) try:
status = "Stage rerun complete" if not res.get("done") else "Build complete" res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True)))
stage_label = res.get("label") status = "Stage rerun complete" if not res.get("done") else "Build complete"
log = res.get("log_delta", "") except Exception as e:
added_cards = res.get("added_cards", []) sess["last_step"] = 5
i = res.get("idx") err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}")
n = res.get("total") resp = templates.TemplateResponse("build/_step5.html", err_ctx)
csv_path = res.get("csv_path") if res.get("done") else None resp.set_cookie("sid", sid, httponly=True, samesite="lax")
txt_path = res.get("txt_path") if res.get("done") else None return resp
summary = res.get("summary") if res.get("done") else None
total_cards = res.get("total_cards")
added_total = res.get("added_total")
sess["last_step"] = 5 sess["last_step"] = 5
# Build locked cards list with ownership and in-deck presence # Build locked cards list with ownership and in-deck presence
locked_cards = [] locked_cards = []
try: try:
ctx = sess.get("build_ctx") or {} ctx = sess.get("build_ctx") or {}
b = ctx.get("builder") if isinstance(ctx, dict) else None b = ctx.get("builder") if isinstance(ctx, dict) else None
present: set[str] = set() present: set[str] = builder_present_names(b) if b is not None else set()
def _add_names(x):
try:
if not x:
return
if isinstance(x, dict):
for k, v in x.items():
if isinstance(k, str) and k.strip():
present.add(k.strip().lower())
elif isinstance(v, dict) and v.get('name'):
present.add(str(v.get('name')).strip().lower())
elif isinstance(x, (list, tuple, set)):
for item in x:
if isinstance(item, str):
present.add(item.strip().lower())
elif isinstance(item, dict) and item.get('name'):
present.add(str(item.get('name')).strip().lower())
else:
try:
nm = getattr(item, 'name', None)
if isinstance(nm, str) and nm.strip():
present.add(nm.strip().lower())
except Exception:
pass
except Exception:
pass
if b is not None:
for attr in (
'current_deck', 'deck', 'final_deck', 'final_cards',
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
):
_add_names(getattr(b, attr, None))
for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(b, attr, None)
if isinstance(val, (list, tuple, set)):
for n in val:
if isinstance(n, str) and n.strip():
present.add(n.strip().lower())
# Display-map via combined df when available # Display-map via combined df when available
display_map: dict[str, str] = {} lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
try: display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {}
if b is not None: owned_lower = owned_set_helper()
df = getattr(b, "_combined_cards_df", None)
if df is not None and not df.empty:
lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
sub = df[df["name"].astype(str).str.lower().isin(lock_lower)]
for _idx, row in sub.iterrows():
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
except Exception:
display_map = {}
owned_lower = {str(n).strip().lower() for n in owned_store.get_names()}
for nm in (sess.get("locks", []) or []): for nm in (sess.get("locks", []) or []):
key = str(nm).strip().lower() key = str(nm).strip().lower()
disp = display_map.get(key, nm) disp = display_map.get(key, nm)
@ -1563,39 +1292,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
}) })
except Exception: except Exception:
locked_cards = [] locked_cards = []
resp = templates.TemplateResponse( ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
"build/_step5.html", ctx3["locked_cards"] = locked_cards
{ resp = templates.TemplateResponse("build/_step5.html", ctx3)
"request": request,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
"added_cards": added_cards,
"i": i,
"n": n,
"csv_path": csv_path,
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"total_cards": total_cards,
"added_total": added_total,
"mc_adjustments": res.get("mc_adjustments"),
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
"locked_cards": locked_cards,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -1617,33 +1316,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
return resp return resp
try: try:
# Initialize step-by-step build context and run first stage # Initialize step-by-step build context and run first stage
opts = orch.bracket_options() sess["build_ctx"] = start_ctx_from_session(sess)
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=commander,
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
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 show_skipped = False
try: try:
form = await request.form() form = await request.form()
@ -1652,78 +1325,29 @@ async def build_step5_start(request: Request) -> HTMLResponse:
pass pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Stage complete" if not res.get("done") else "Build complete" status = "Stage complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
added_cards = res.get("added_cards", [])
# If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue
try: try:
if stage_label == "Multi-Copy Package" and sess.get("multi_copy"): if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"):
mc = sess.get("multi_copy") mc = sess.get("multi_copy")
sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception: except Exception:
pass pass
i = res.get("idx")
n = res.get("total")
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
sess["last_step"] = 5 sess["last_step"] = 5
resp = templates.TemplateResponse( ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
"build/_step5.html", resp = templates.TemplateResponse("build/_step5.html", ctx)
{
"request": request,
"commander": commander,
"name": sess.get("custom_export_base"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
"added_cards": added_cards,
"i": i,
"n": n,
"csv_path": csv_path,
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"mc_adjustments": res.get("mc_adjustments"),
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"locks": list(sess.get("locks", [])),
"replace_mode": bool(sess.get("replace_mode", True)),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
except Exception as e: except Exception as e:
# Surface a friendly error on the step 5 screen # Surface a friendly error on the step 5 screen with normalized context
resp = templates.TemplateResponse( err_ctx = step5_error_ctx(
"build/_step5.html", request,
{ sess,
"request": request, f"Failed to start build: {e}",
"commander": commander, include_name=False,
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": "Error",
"stage_label": None,
"log": f"Failed to start build: {e}",
"added_cards": [],
"i": None,
"n": None,
"csv_path": None,
"txt_path": None,
"summary": None,
"game_changers": bc.GAME_CHANGERS,
},
) )
# Ensure commander stays visible if set
err_ctx["commander"] = commander
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -1783,35 +1407,12 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse:
except Exception: except Exception:
return await build_step5_get(request) return await build_step5_get(request)
# Re-render step 5 with cleared added list # Re-render step 5 with cleared added list
resp = templates.TemplateResponse( base = step5_empty_ctx(request, sess, extras={
"build/_step5.html", "status": "Stage reset",
{ "i": ctx.get("idx"),
"request": request, "n": len(ctx.get("stages", [])),
"commander": sess.get("commander"), })
"tags": sess.get("tags", []), resp = templates.TemplateResponse("build/_step5.html", base)
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": "Stage reset",
"stage_label": None,
"log": None,
"added_cards": [],
"i": ctx.get("idx"),
"n": len(ctx.get("stages", [])),
"game_changers": bc.GAME_CHANGERS,
"show_skipped": False,
"total_cards": None,
"added_total": 0,
"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") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -1887,13 +1488,11 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
ctx = sess.get("build_ctx") or {} ctx = sess.get("build_ctx") or {}
b = ctx.get("builder") if isinstance(ctx, dict) else None b = ctx.get("builder") if isinstance(ctx, dict) else None
# Owned library # Owned library
owned_set = {str(n).strip().lower() for n in owned_store.get_names()} owned_set = owned_set_helper()
require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only")) require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only"))
# If builder context missing, show a guidance message # If builder context missing, show a guidance message
if not b: if not b:
html = ( html = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
'<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
)
return HTMLResponse(html) return HTMLResponse(html)
try: try:
name_l = str(name).strip().lower() name_l = str(name).strip().lower()
@ -1911,52 +1510,10 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
lib = getattr(b, "card_library", {}) or {} lib = getattr(b, "card_library", {}) or {}
lib_entry = lib.get(name) or lib.get(name_l) lib_entry = lib.get(name) or lib.get(name_l)
# Best-effort set of names currently in the deck to avoid duplicates # Best-effort set of names currently in the deck to avoid duplicates
in_deck: set[str] = set() in_deck: set[str] = builder_present_names(b)
try:
def _add_names(x):
try:
if not x:
return
if isinstance(x, dict):
for k, v in x.items():
# dict of name->count or name->obj
if isinstance(k, str) and k.strip():
in_deck.add(k.strip().lower())
elif isinstance(v, dict) and v.get('name'):
in_deck.add(str(v.get('name')).strip().lower())
elif isinstance(x, (list, tuple, set)):
for item in x:
if isinstance(item, str):
in_deck.add(item.strip().lower())
elif isinstance(item, dict) and item.get('name'):
in_deck.add(str(item.get('name')).strip().lower())
else:
try:
nm = getattr(item, 'name', None)
if isinstance(nm, str) and nm.strip():
in_deck.add(nm.strip().lower())
except Exception:
pass
except Exception:
pass
# Probe a few likely attributes; ignore if missing
for attr in (
'current_deck', 'deck', 'final_deck', 'final_cards',
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
):
_add_names(getattr(b, attr, None))
# Some builders may expose a flat set of names
for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(b, attr, None)
if isinstance(val, (list, tuple, set)):
for n in val:
if isinstance(n, str) and n.strip():
in_deck.add(n.strip().lower())
except Exception:
in_deck = set()
# Build candidate pool from tags overlap # Build candidate pool from tags overlap
all_names = set(tags_idx.keys()) all_names = set(tags_idx.keys())
candidates: list[tuple[str,int]] = [] # (name, score) candidates: list[tuple[str, int]] = [] # (name, score)
for nm in all_names: for nm in all_names:
if nm == name_l: if nm == name_l:
continue continue
@ -1992,63 +1549,32 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
return nm in owned_set return nm in owned_set
candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0]))
# Map back to display names using combined DF when possible for proper casing # Map back to display names using combined DF when possible for proper casing
display_map: dict[str, str] = {} pool_lower = {nm for (nm, _s) in candidates}
try: display_map: dict[str, str] = builder_display_map(b, pool_lower)
df = getattr(b, "_combined_cards_df", None) # Build structured items for the partial
if df is not None and not df.empty: items: list[dict] = []
# Build lower->original map limited to candidate pool for speed
pool_lower = {nm for (nm, _s) in candidates}
sub = df[df["name"].astype(str).str.lower().isin(pool_lower)]
for _idx, row in sub.iterrows():
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
except Exception:
display_map = {}
# Apply owned filter and cap list
items_html: list[str] = []
seen = set() seen = set()
count = 0
for nm, score in candidates: for nm, score in candidates:
if nm in seen: if nm in seen:
continue continue
seen.add(nm) seen.add(nm)
disp = display_map.get(nm, nm)
is_owned = (nm in owned_set) is_owned = (nm in owned_set)
if require_owned and not is_owned: if require_owned and not is_owned:
continue continue
badge = "" if is_owned else "" disp = display_map.get(nm, nm)
title = "Owned" if is_owned else "Not owned" items.append({
# Replace button posts to /build/replace; we'll update locks and prompt rerun "name": disp,
# Provide hover-preview metadata so moving the mouse over the alternative shows that card "name_lower": nm,
cand_tags = tags_idx.get(nm) or [] "owned": is_owned,
data_tags = ", ".join([str(t) for t in cand_tags]) "tags": list(tags_idx.get(nm) or []),
items_html.append( })
f'<li><span class="owned-badge" title="{title}">{badge}</span> ' if len(items) >= 10:
f'<button class="btn" data-card-name="{_esc(disp)}" data-tags="{_esc(data_tags)}" hx-post="/build/replace" '
f'hx-vals=' + "'" + f'{{"old":"{name}", "new":"{disp}"}}' + "'" + ' '
f'hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">Replace with {_esc(disp)}</button></li>'
)
count += 1
if count >= 10:
break break
# Build HTML # Render partial via Jinja template and cache it
if not items_html: ctx2 = {"request": request, "name": name, "require_owned": require_owned, "items": items}
owned_msg = " (owned only)" if require_owned else "" html_str = templates.get_template("build/_alternatives.html").render(ctx2)
html = f'<div class="alts"><div class="muted">No alternatives found{owned_msg}.</div></div>' _alts_set_cached(cache_key, html_str)
else: return HTMLResponse(html_str)
toggle_q = "0" if require_owned else "1"
toggle_label = ("Owned only: On" if require_owned else "Owned only: Off")
html = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;"><strong>Alternatives</strong>'
f'<button class="btn" hx-get="/build/alternatives?name={name}&owned_only={toggle_q}" hx-target="closest .alts" hx-swap="outerHTML">{toggle_label}</button></div>'
'<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">'
+ "".join(items_html) +
'</ul>'
'</div>'
)
# Save to cache and return
_alts_set_cached(cache_key, html)
return HTMLResponse(html)
except Exception as e: except Exception as e:
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>') return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')

View file

@ -6,11 +6,9 @@ from pathlib import Path
import os import os
import json import json
from ..app import templates from ..app import templates
from ..services import owned_store from ..services.build_utils import owned_set as owned_set_helper, owned_names as owned_names_helper
from ..services.summary_utils import summary_ctx
from ..services import orchestrator as orch 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
router = APIRouter(prefix="/configs") router = APIRouter(prefix="/configs")
@ -143,7 +141,7 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s
if use_owned_only is not None: if use_owned_only is not None:
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on") owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
owned_names = owned_store.get_names() if owned_flag else None owned_names = owned_names_helper() if owned_flag else None
# Optional combos preferences # Optional combos preferences
prefer_combos = False prefer_combos = False
@ -198,43 +196,24 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s
"commander": commander, "commander": commander,
"tag_mode": tag_mode, "tag_mode": tag_mode,
"use_owned_only": owned_flag, "use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()}, "owned_set": owned_set_helper(),
}, },
) )
return templates.TemplateResponse( ctx = {
"configs/run_result.html", "request": request,
{ "ok": True,
"request": request, "log": res.get("log", ""),
"ok": True, "csv_path": res.get("csv_path"),
"log": res.get("log", ""), "txt_path": res.get("txt_path"),
"csv_path": res.get("csv_path"), "summary": res.get("summary"),
"txt_path": res.get("txt_path"), "cfg_name": p.name,
"summary": res.get("summary"), "commander": commander,
"cfg_name": p.name, "tag_mode": tag_mode,
"commander": commander, "use_owned_only": owned_flag,
"tag_mode": tag_mode, }
"use_owned_only": owned_flag, ctx.update(summary_ctx(summary=res.get("summary"), commander=commander, tags=tags))
"owned_set": {n.lower() for n in owned_store.get_names()}, return templates.TemplateResponse("configs/run_result.html", ctx)
"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"))
},
)
@router.post("/upload", response_class=HTMLResponse) @router.post("/upload", response_class=HTMLResponse)

View file

@ -8,10 +8,8 @@ import os
from typing import Dict, List, Tuple, Optional from typing import Dict, List, Tuple, Optional
from ..app import templates from ..app import templates
from ..services import owned_store # from ..services import owned_store
from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies from ..services.summary_utils import summary_ctx
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
router = APIRouter(prefix="/decks") router = APIRouter(prefix="/decks")
@ -294,61 +292,6 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
parts = stem.split('_') parts = stem.split('_')
commander_name = parts[0] if parts else '' 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 = { ctx = {
"request": request, "request": request,
"name": p.name, "name": p.name,
@ -358,12 +301,8 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"commander": commander_name, "commander": commander_name,
"tags": tags, "tags": tags,
"display_name": display_name, "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,
} }
ctx.update(summary_ctx(summary=summary, commander=commander_name, tags=tags))
return templates.TemplateResponse("decks/view.html", ctx) return templates.TemplateResponse("decks/view.html", ctx)

View file

@ -1,11 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from ..app import templates
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse("home.html", {"request": request})

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Dict, Tuple
import time as _t
# Lightweight in-memory TTL cache for alternatives fragments
_ALTS_CACHE: Dict[Tuple[str, str, bool], Tuple[float, str]] = {}
_ALTS_TTL_SECONDS = 60.0
def get_cached(key: tuple[str, str, bool]) -> str | None:
try:
ts, html = _ALTS_CACHE.get(key, (0.0, ""))
if ts and (_t.time() - ts) < _ALTS_TTL_SECONDS:
return html
except Exception:
return None
return None
def set_cached(key: tuple[str, str, bool], html: str) -> None:
try:
_ALTS_CACHE[key] = (_t.time(), html)
except Exception:
pass

View file

@ -0,0 +1,265 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import Request
from ..services import owned_store
from . import orchestrator as orch
from deck_builder import builder_constants as bc
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
"""Assemble the common Step 5 template context from session.
Includes commander/tags/bracket/values, ownership flags, owned_set, locks, replace_mode,
combo preferences, and static game_changers. Caller can layer run-specific results.
"""
ctx: Dict[str, Any] = {
"request": request,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": owned_set(),
"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")),
}
if include_name:
ctx["name"] = sess.get("custom_export_base")
if include_locks:
ctx["locks"] = list(sess.get("locks", []))
return ctx
def owned_set() -> set[str]:
"""Return lowercase owned card names with trimming for robust matching."""
try:
return {str(n).strip().lower() for n in owned_store.get_names()}
except Exception:
return set()
def owned_names() -> list[str]:
"""Return raw owned card names from the store (original casing)."""
try:
return list(owned_store.get_names())
except Exception:
return []
def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[str, Any]:
"""Create a staged build context from the current session selections.
Pulls commander, tags, bracket, ideals, tag_mode, ownership flags, locks, custom name,
multi-copy selection, and combo preferences from the session and starts a build context.
"""
opts = orch.bracket_options()
default_bracket = (opts[0]["level"] if opts else 1)
bracket_val = sess.get("bracket")
try:
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names_list = owned_names() if (use_owned or prefer) else None
ctx = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names_list,
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")),
)
if set_on_session:
sess["build_ctx"] = ctx
return ctx
def step5_ctx_from_result(
request: Request,
sess: dict,
res: dict,
*,
status_text: Optional[str] = None,
show_skipped: bool = False,
include_name: bool = True,
include_locks: bool = True,
extras: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build a Step 5 context by merging base session data with a build stage result dict.
res is expected to be the dict returned from orchestrator.run_stage or similar with keys like
label, log_delta, added_cards, idx, total, csv_path, txt_path, summary, etc.
"""
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
done = bool(res.get("done"))
ctx: Dict[str, Any] = {
**base,
"status": status_text,
"stage_label": res.get("label"),
"log": res.get("log_delta", ""),
"added_cards": res.get("added_cards", []),
"i": res.get("idx"),
"n": res.get("total"),
"csv_path": res.get("csv_path") if done else None,
"txt_path": res.get("txt_path") if done else None,
"summary": res.get("summary") if done else None,
"show_skipped": bool(show_skipped),
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
"mc_adjustments": res.get("mc_adjustments"),
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")),
}
if extras:
ctx.update(extras)
return ctx
def step5_error_ctx(
request: Request,
sess: dict,
message: str,
*,
include_name: bool = True,
include_locks: bool = True,
status_text: str = "Error",
extras: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Return a normalized Step 5 context for error states.
Provides all keys expected by the _step5.html template so the UI stays consistent
even when a build can't start or a stage fails. The error message is placed in `log`.
"""
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
ctx: Dict[str, Any] = {
**base,
"status": status_text,
"stage_label": None,
"log": str(message),
"added_cards": [],
"i": None,
"n": None,
"csv_path": None,
"txt_path": None,
"summary": None,
"show_skipped": False,
"total_cards": None,
"added_total": 0,
"skipped": False,
}
if extras:
ctx.update(extras)
return ctx
def step5_empty_ctx(
request: Request,
sess: dict,
*,
include_name: bool = True,
include_locks: bool = True,
extras: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Return a baseline Step 5 context with empty stage data.
Used for GET /step5 and reset-stage flows to render the screen before any stage is run.
"""
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
ctx: Dict[str, Any] = {
**base,
"status": None,
"stage_label": None,
"log": None,
"added_cards": [],
"i": None,
"n": None,
"total_cards": None,
"added_total": 0,
"show_skipped": False,
"skipped": False,
}
if extras:
ctx.update(extras)
return ctx
def builder_present_names(builder: Any) -> set[str]:
"""Return a lowercase set of names currently present in the builder/deck structures.
Safely probes a variety of attributes used across different builder implementations.
"""
present: set[str] = set()
def _add_names(x: Any) -> None:
try:
if not x:
return
if isinstance(x, dict):
for k, v in x.items():
if isinstance(k, str) and k.strip():
present.add(k.strip().lower())
elif isinstance(v, dict) and v.get('name'):
present.add(str(v.get('name')).strip().lower())
elif isinstance(x, (list, tuple, set)):
for item in x:
if isinstance(item, str) and item.strip():
present.add(item.strip().lower())
elif isinstance(item, dict) and item.get('name'):
present.add(str(item.get('name')).strip().lower())
else:
try:
nm = getattr(item, 'name', None)
if isinstance(nm, str) and nm.strip():
present.add(nm.strip().lower())
except Exception:
pass
except Exception:
pass
try:
if builder is None:
return present
for attr in (
'current_deck', 'deck', 'final_deck', 'final_cards',
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
):
_add_names(getattr(builder, attr, None))
for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(builder, attr, None)
if isinstance(val, (list, tuple, set)):
for n in val:
if isinstance(n, str) and n.strip():
present.add(n.strip().lower())
except Exception:
pass
return present
def builder_display_map(builder: Any, pool_lower: set[str]) -> Dict[str, str]:
"""Map lowercased names in pool_lower to display names using the combined DataFrame, if present."""
display_map: Dict[str, str] = {}
try:
if builder is None or not pool_lower:
return display_map
df = getattr(builder, "_combined_cards_df", None)
if df is not None and not df.empty:
sub = df[df["name"].astype(str).str.lower().isin(pool_lower)]
for _idx, row in sub.iterrows():
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
except Exception:
display_map = {}
return display_map

View file

@ -0,0 +1,98 @@
from __future__ import annotations
from typing import Dict, List
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,
)
DEFAULT_COMBOS_PATH = "config/card_lists/combos.json"
DEFAULT_SYNERGIES_PATH = "config/card_lists/synergies.json"
def detect_all(
names: List[str],
*,
combos_path: str = DEFAULT_COMBOS_PATH,
synergies_path: str = DEFAULT_SYNERGIES_PATH,
) -> Dict[str, object]:
"""Detect combos/synergies for a list of card names and return results with versions.
Returns a dict with keys: combos, synergies, versions, combos_model, synergies_model.
Models may be None if loading fails.
"""
try:
combos_model = _load_combos(combos_path)
except Exception:
combos_model = None
try:
synergies_model = _load_synergies(synergies_path)
except Exception:
synergies_model = None
try:
combos = _detect_combos(names, combos_path=combos_path)
except Exception:
combos = []
try:
synergies = _detect_synergies(names, synergies_path=synergies_path)
except Exception:
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,
}
return {
"combos": combos,
"synergies": synergies,
"versions": versions,
"combos_model": combos_model,
"synergies_model": synergies_model,
}
def _names_from_summary(summary: Dict[str, object]) -> List[str]:
"""Extract a best-effort set of card names from a build summary dict."""
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
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
return sorted(names_set)
def detect_for_summary(
summary: Dict[str, object] | None,
commander_name: str | None = None,
*,
combos_path: str = DEFAULT_COMBOS_PATH,
synergies_path: str = DEFAULT_SYNERGIES_PATH,
) -> Dict[str, object]:
"""Convenience helper: compute names from summary (+commander) and run detect_all."""
names = _names_from_summary(summary or {})
if commander_name:
names = sorted(set(names) | {str(commander_name)})
return detect_all(names, combos_path=combos_path, synergies_path=synergies_path)

View file

@ -484,6 +484,91 @@ def ideal_labels() -> Dict[str, str]:
} }
def _is_truthy_env(name: str, default: str = '1') -> bool:
try:
val = os.getenv(name, default)
return str(val).strip().lower() in {"1", "true", "yes", "on"}
except Exception:
return default in {"1", "true", "yes", "on"}
def is_setup_ready() -> bool:
"""Fast readiness check: required files present and tagging completed.
We consider the system ready if csv_files/cards.csv exists and the
.tagging_complete.json flag exists. Freshness (mtime) is enforced only
during auto-refresh inside _ensure_setup_ready, not here.
"""
try:
cards_path = os.path.join('csv_files', 'cards.csv')
flag_path = os.path.join('csv_files', '.tagging_complete.json')
return os.path.exists(cards_path) and os.path.exists(flag_path)
except Exception:
return False
def is_setup_stale() -> bool:
"""Return True if cards.csv exists but is older than the auto-refresh threshold.
This does not imply not-ready; it is a hint for the UI to recommend a refresh.
"""
try:
# Refresh threshold (treat <=0 as "never stale")
try:
days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
except Exception:
days = 7
if days <= 0:
return False
refresh_age_seconds = max(0, days) * 24 * 60 * 60
# If setup is currently running, avoid prompting a refresh loop
try:
status_path = os.path.join('csv_files', '.setup_status.json')
if os.path.exists(status_path):
with open(status_path, 'r', encoding='utf-8') as f:
st = json.load(f) or {}
if bool(st.get('running')):
return False
# If we recently finished, honor finished_at (or updated) as a freshness signal
ts_str = st.get('finished_at') or st.get('updated') or st.get('started_at')
if isinstance(ts_str, str) and ts_str.strip():
try:
ts = _dt.fromisoformat(ts_str.strip())
if (time.time() - ts.timestamp()) <= refresh_age_seconds:
return False
except Exception:
pass
except Exception:
pass
# If tagging completed recently, treat as fresh regardless of cards.csv mtime
try:
tag_flag = os.path.join('csv_files', '.tagging_complete.json')
if os.path.exists(tag_flag):
with open(tag_flag, 'r', encoding='utf-8') as f:
tf = json.load(f) or {}
tstr = tf.get('tagged_at')
if isinstance(tstr, str) and tstr.strip():
try:
tdt = _dt.fromisoformat(tstr.strip())
if (time.time() - tdt.timestamp()) <= refresh_age_seconds:
return False
except Exception:
pass
except Exception:
pass
# Fallback: compare cards.csv mtime
cards_path = os.path.join('csv_files', 'cards.csv')
if not os.path.exists(cards_path):
return False
age_seconds = time.time() - os.path.getmtime(cards_path)
return age_seconds > refresh_age_seconds
except Exception:
return False
def _ensure_setup_ready(out, force: bool = False) -> None: def _ensure_setup_ready(out, force: bool = False) -> None:
"""Ensure card CSVs exist and tagging has completed; bootstrap if needed. """Ensure card CSVs exist and tagging has completed; bootstrap if needed.
@ -515,6 +600,13 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
try: try:
cards_path = os.path.join('csv_files', 'cards.csv') cards_path = os.path.join('csv_files', 'cards.csv')
flag_path = os.path.join('csv_files', '.tagging_complete.json') flag_path = os.path.join('csv_files', '.tagging_complete.json')
auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1')
# Allow tuning of time-based refresh; default 7 days
try:
days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7'))
refresh_age_seconds = max(0, days) * 24 * 60 * 60
except Exception:
refresh_age_seconds = 7 * 24 * 60 * 60
refresh_needed = bool(force) refresh_needed = bool(force)
if force: if force:
_write_status({"running": True, "phase": "setup", "message": "Forcing full setup and tagging...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0}) _write_status({"running": True, "phase": "setup", "message": "Forcing full setup and tagging...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
@ -526,7 +618,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
else: else:
try: try:
age_seconds = time.time() - os.path.getmtime(cards_path) age_seconds = time.time() - os.path.getmtime(cards_path)
if age_seconds > 7 * 24 * 60 * 60 and not force: if age_seconds > refresh_age_seconds and not force:
out("cards.csv is older than 7 days. Refreshing data (setup + tagging)...") out("cards.csv is older than 7 days. Refreshing data (setup + tagging)...")
_write_status({"running": True, "phase": "setup", "message": "Refreshing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0}) _write_status({"running": True, "phase": "setup", "message": "Refreshing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0})
refresh_needed = True refresh_needed = True
@ -540,6 +632,10 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
refresh_needed = True refresh_needed = True
if refresh_needed: if refresh_needed:
if not auto_setup_enabled and not force:
out("Setup/tagging required, but WEB_AUTO_SETUP=0. Please run Setup from the UI.")
_write_status({"running": False, "phase": "requires_setup", "message": "Setup required (auto disabled)."})
return
try: try:
from file_setup.setup import initial_setup # type: ignore from file_setup.setup import initial_setup # type: ignore
# Always run initial_setup when forced or when cards are missing/stale # Always run initial_setup when forced or when cards are missing/stale
@ -1082,8 +1178,13 @@ def start_build_ctx(
# Provide a no-op input function so staged web builds never block on input # Provide a no-op input function so staged web builds never block on input
b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True) b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True)
# Ensure setup/tagging present before staged build # Ensure setup/tagging present before staged build, but respect WEB_AUTO_SETUP
_ensure_setup_ready(out) if not is_setup_ready():
if _is_truthy_env('WEB_AUTO_SETUP', '1'):
_ensure_setup_ready(out)
else:
out("Setup/tagging not ready. Please run Setup first (WEB_AUTO_SETUP=0).")
raise RuntimeError("Setup required (WEB_AUTO_SETUP disabled)")
# Commander selection # Commander selection
df = b.load_commander_data() df = b.load_commander_data()
row = df[df["name"].astype(str) == str(commander)] row = df[df["name"].astype(str) == str(commander)]

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any, Dict
from deck_builder import builder_constants as bc
from .build_utils import owned_set as owned_set_helper
from .combo_utils import detect_for_summary as _detect_for_summary
def summary_ctx(
*,
summary: dict | None,
commander: str | None = None,
tags: list[str] | None = None,
include_versions: bool = True,
) -> Dict[str, Any]:
"""Build a unified context payload for deck summary panels.
Provides owned_set, game_changers, combos/synergies, and detector versions.
"""
det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}}
combos = det.get("combos", [])
synergies = det.get("synergies", [])
versions = det.get("versions", {} if include_versions else None)
return {
"owned_set": owned_set_helper(),
"game_changers": bc.GAME_CHANGERS,
"combos": combos,
"synergies": synergies,
"versions": versions,
"commander": commander,
"tags": tags or [],
}

View file

@ -0,0 +1,34 @@
{# Alternatives panel partial.
Expects: name (seed display), require_owned (bool), items = [
{ 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] }
]
#}
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;">
<strong>Alternatives</strong>
{% set toggle_q = '0' if require_owned else '1' %}
{% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
<button class="btn" hx-get="/build/alternatives?name={{ name|urlencode }}&owned_only={{ toggle_q }}"
hx-target="closest .alts" hx-swap="outerHTML">{{ toggle_label }}</button>
</div>
{% if not items or items|length == 0 %}
<div class="muted">No alternatives found{{ ' (owned only)' if require_owned else '' }}.</div>
{% else %}
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
{% for it in items %}
{% set badge = '✔' if it.owned else '✖' %}
{% set title = 'Owned' if it.owned else 'Not owned' %}
{% set tags = (it.tags or []) %}
<li>
<span class="owned-badge" title="{{ title }}">{{ badge }}</span>
<button class="btn" data-card-name="{{ it.name }}"
data-tags="{{ tags|join(', ') }}" hx-post="/build/replace"
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}"}'
hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">
Replace with {{ it.name }}
</button>
</li>
{% endfor %}
</ul>
{% endif %}
</div>

View file

@ -0,0 +1,21 @@
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="setupPromptTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
<div class="modal-content" style="position:relative; max-width:560px; width:clamp(320px, 90vw, 560px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
<div class="modal-header">
<h3 id="setupPromptTitle">{{ title or 'Setup required' }}</h3>
</div>
<div class="modal-body">
<p>{{ message or 'The card database and tags need to be prepared before building a deck.' }}</p>
</div>
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<a class="btn-continue" href="{{ action_url }}" hx-boost="true" hx-target="body" hx-swap="innerHTML">{{ action_label or 'Run Setup' }}</a>
</div>
</div>
</div>
<script>
(function(){
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); try{ var m=document.querySelector('.modal'); if(m){ m.remove(); document.removeEventListener('keydown', onKey); } }catch(_){ } } }
document.addEventListener('keydown', onKey);
})();
</script>

View file

@ -85,6 +85,7 @@
{% endif %} {% endif %}
{% if locked_cards is defined and locked_cards %} {% if locked_cards is defined and locked_cards %}
{% from 'partials/_macros.html' import lock_button %}
<details id="locked-section" style="margin-top:.5rem;"> <details id="locked-section" style="margin-top:.5rem;">
<summary>Locked cards (always kept)</summary> <summary>Locked cards (always kept)</summary>
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;"> <ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
@ -93,12 +94,9 @@
<span class="chip"><span class="dot"></span> {{ lk.name }}</span> <span class="chip"><span class="dot"></span> {{ lk.name }}</span>
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span> <span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %} {% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;"> <div class="lock-box" style="display:inline; margin-left:auto;">
<input type="hidden" name="name" value="{{ lk.name }}" /> {{ lock_button(lk.name, True, from_list=True, target_selector='closest li') }}
<input type="hidden" name="locked" value="0" /> </div>
<input type="hidden" name="from_list" value="1" />
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
</form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -236,10 +234,9 @@
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div> <div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div> <div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;"> <div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}" {% from 'partials/_macros.html' import lock_button %}
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML" {{ lock_button(c.name, is_locked) }}
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button> </div>
</div>
{% if c.reason %} {% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;"> <div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button> <button type="button" class="btn-why" aria-expanded="false">Why?</button>
@ -274,10 +271,9 @@
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div> <div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div> <div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;"> <div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}" {% from 'partials/_macros.html' import lock_button %}
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML" {{ lock_button(c.name, is_locked) }}
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button> </div>
</div>
{% if c.reason %} {% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;"> <div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button> <button type="button" class="btn-why" aria-expanded="false">Why?</button>

View file

@ -0,0 +1,13 @@
{# Reusable Jinja macros for UI elements #}
{% macro lock_button(name, locked=False, from_list=False, target_selector='closest .lock-box') -%}
{# Emits a lock/unlock button with correct hx-vals and aria state. #}
<button type="button" class="btn-lock"
title="{{ 'Unlock this card (kept across reruns)' if locked else 'Lock this card (keep across reruns)' }}"
aria-pressed="{{ 'true' if locked else 'false' }}"
data-locked="{{ '1' if locked else '0' }}"
hx-post="/build/lock" hx-target="{{ target_selector }}" hx-swap="innerHTML"
hx-vals='{"name": "{{ name }}", "locked": "{{ '0' if locked else '1' }}"{% if from_list %}, "from_list": "1"{% endif %}}'>
{{ '🔒 Unlock' if locked else '🔓 Lock' }}
</button>
{%- endmacro %}

View file

@ -1 +1,182 @@
combos.json {
"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"] }
]
}