From 014bcc37b75e3df1aa4d599cc81c5caed4ff08ec Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Tue, 2 Sep 2025 11:39:14 -0700 Subject: [PATCH] web: DRY Step 5 and alternatives (partial+macro), centralize start_ctx/owned_set, adopt builder_* --- .gitattributes | 8 + .github/workflows/dockerhub-publish.yml | 12 +- .github/workflows/github-release.yml | 12 +- CHANGELOG.md | 43 + Dockerfile | 4 +- code/tests/test_build_utils_ctx.py | 60 ++ code/tests/test_orchestrator_staleness.py | 19 + code/tests/test_step5_error_ctx.py | 76 ++ code/tests/test_summary_utils.py | 31 + code/web/app.py | 20 +- code/web/routes/build.py | 742 ++++-------------- code/web/routes/configs.py | 59 +- code/web/routes/decks.py | 67 +- code/web/routes/home.py | 11 - code/web/services/alts_utils.py | 25 + code/web/services/build_utils.py | 265 +++++++ code/web/services/combo_utils.py | 98 +++ code/web/services/orchestrator.py | 107 ++- code/web/services/summary_utils.py | 32 + code/web/templates/build/_alternatives.html | 34 + .../templates/build/_setup_prompt_modal.html | 21 + code/web/templates/build/_step5.html | 24 +- code/web/templates/partials/_macros.html | 13 + config/card_lists/combo.json | 183 ++++- 24 files changed, 1200 insertions(+), 766 deletions(-) create mode 100644 .gitattributes create mode 100644 code/tests/test_build_utils_ctx.py create mode 100644 code/tests/test_orchestrator_staleness.py create mode 100644 code/tests/test_step5_error_ctx.py create mode 100644 code/tests/test_summary_utils.py delete mode 100644 code/web/routes/home.py create mode 100644 code/web/services/alts_utils.py create mode 100644 code/web/services/build_utils.py create mode 100644 code/web/services/combo_utils.py create mode 100644 code/web/services/summary_utils.py create mode 100644 code/web/templates/build/_alternatives.html create mode 100644 code/web/templates/build/_setup_prompt_modal.html create mode 100644 code/web/templates/partials/_macros.html diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e9a771b --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/dockerhub-publish.yml b/.github/workflows/dockerhub-publish.yml index cc5b5a4..fcce114 100644 --- a/.github/workflows/dockerhub-publish.yml +++ b/.github/workflows/dockerhub-publish.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.0 - name: Prepare release notes from template id: notes @@ -35,10 +35,10 @@ jobs: echo "version=$VERSION_REF" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v3.6.0 - 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) shell: bash @@ -61,14 +61,14 @@ jobs: docker rm -f mtg-smoke >/dev/null 2>&1 || true - name: Docker Hub login - uses: docker/login-action@v3 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v5.8.0 with: images: | mwisnowski/mtg-python-deckbuilder @@ -82,7 +82,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v6.18.0 with: context: . file: ./Dockerfile diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index faf3e9a..ebcc77e 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -12,10 +12,10 @@ jobs: runs-on: windows-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v5.6.0 with: 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' } - name: Upload artifact (Windows EXE) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4.6.2 with: name: mtg-deckbuilder-windows path: dist/mtg-deckbuilder.exe @@ -50,7 +50,7 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5.0.0 - name: Prepare release notes id: notes @@ -70,13 +70,13 @@ jobs: echo "notes_file=RELEASE_NOTES.md" >> $GITHUB_OUTPUT - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5.0.0 with: name: mtg-deckbuilder-windows path: artifacts - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.3.2 with: tag_name: ${{ steps.notes.outputs.version }} name: ${{ steps.notes.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c7afba4..11d20e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,49 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [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 ### Fixes - Bug causing basic lands to no longer be added due to combined dataframe not including basics diff --git a/Dockerfile b/Dockerfile index 9e80259..7dbfb62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,9 @@ WORKDIR /app/code # Add a tiny entrypoint to select Web UI (default) or CLI 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"] # Expose web port for the optional Web UI diff --git a/code/tests/test_build_utils_ctx.py b/code/tests/test_build_utils_ctx.py new file mode 100644 index 0000000..b61e6ab --- /dev/null +++ b/code/tests/test_build_utils_ctx.py @@ -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 diff --git a/code/tests/test_orchestrator_staleness.py b/code/tests/test_orchestrator_staleness.py new file mode 100644 index 0000000..163825a --- /dev/null +++ b/code/tests/test_orchestrator_staleness.py @@ -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) diff --git a/code/tests/test_step5_error_ctx.py b/code/tests/test_step5_error_ctx.py new file mode 100644 index 0000000..e963289 --- /dev/null +++ b/code/tests/test_step5_error_ctx.py @@ -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" diff --git a/code/tests/test_summary_utils.py b/code/tests/test_summary_utils.py new file mode 100644 index 0000000..af66cc3 --- /dev/null +++ b/code/tests/test_summary_utils.py @@ -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) diff --git a/code/web/app.py b/code/web/app.py index a335de6..3ff0c6d 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -2,11 +2,6 @@ from __future__ import annotations from fastapi import FastAPI, Request, HTTPException, Query from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response -from deck_builder.combos import ( - detect_combos as _detect_combos, - detect_synergies as _detect_synergies, -) -from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from pathlib import Path @@ -17,7 +12,8 @@ import uuid import logging from starlette.exceptions import HTTPException as StarletteHTTPException 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 _THIS_DIR = Path(__file__).resolve().parent @@ -76,7 +72,7 @@ templates.env.globals.update({ }) # --- 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 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" synergies_path = payload.get("synergies_path") or "config/card_lists/synergies.json" - combos_model = _load_combos(combos_path) - synergies_model = _load_synergies(synergies_path) - combos = _detect_combos(names, combos_path=combos_path) - synergies = _detect_synergies(names, synergies_path=synergies_path) + det = _detect_all(names, combos_path=combos_path, synergies_path=synergies_path) + combos = det.get("combos", []) + synergies = det.get("synergies", []) + versions = det.get("versions", {"combos": None, "synergies": None}) def as_dict_combo(c): return { @@ -435,7 +431,7 @@ async def diagnostics_combos(request: Request) -> JSONResponse: return JSONResponse( { "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], "synergies": [as_dict_syn(s) for s in synergies], } diff --git a/code/web/routes/build.py b/code/web/routes/build.py index aa58d01..d48eb9d 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -2,37 +2,30 @@ from __future__ import annotations from fastapi import APIRouter, Request, Form, Query 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 deck_builder import builder_constants as bc 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 html import escape as _esc from deck_builder.builder import DeckBuilder from deck_builder import builder_utils as bu -from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies -from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies +from ..services.combo_utils import detect_all as _detect_all +from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached router = APIRouter(prefix="/build") -# --- lightweight in-memory TTL cache for alternatives (Phase 9 planned item) --- -_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 +# Alternatives cache moved to services/alts_utils 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) bracket_val = sess.get("bracket") 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: 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 + owned_names = owned_names_helper() if (use_owned or prefer) else None locks = list(sess.get("locks", [])) sess["build_ctx"] = orch.start_build_ctx( commander=sess.get("commander"), @@ -470,75 +463,43 @@ async def build_new_submit( del sess["custom_export_base"] except Exception: 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 if "replace_mode" not in sess: sess["replace_mode"] = True - 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 = 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")), - ) + # Centralized staged context creation + sess["build_ctx"] = start_ctx_from_session(sess) res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) status = "Build complete" if res.get("done") else "Stage complete" sess["last_step"] = 5 - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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")), - }, - ) + ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) + resp = templates.TemplateResponse("build/_step5.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") 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 except Exception: # As a fallback, restart ctx and run forward until target - 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 = 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")), - ) + sess["build_ctx"] = start_ctx_from_session(sess) ctx = sess["build_ctx"] # Run forward until reaching target while True: @@ -757,42 +692,16 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo if res.get("done"): break # 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) - status = "Stage (rewound)" if not res.get("done") else "Build complete" - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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)), + try: + res = orch.run_stage(ctx, rerun=False, show_skipped=True) + status = "Stage (rewound)" if not res.get("done") else "Build complete" + ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={ "history": ctx.get("history", []), - "prefer_combos": bool(sess.get("prefer_combos")), - "combo_target_count": int(sess.get("combo_target_count", 2)), - "combo_balance": str(sess.get("combo_balance", "mix")), - }, - ) + }) + except Exception as e: + 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") return resp @@ -1066,22 +975,11 @@ async def build_combos_panel(request: Request) -> HTMLResponse: target = 0 # Load lists and run detection - try: - combos_model = _load_combos("config/card_lists/combos.json") - except Exception: - combos_model = None - try: - combos = _detect_combos(names, combos_path="config/card_lists/combos.json") - except Exception: - combos = [] - try: - synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json") - except Exception: - synergies = [] - try: - synergies_model = _load_synergies("config/card_lists/synergies.json") - except Exception: - synergies_model = None + _det = _detect_all(names) + combos = _det.get("combos", []) + synergies = _det.get("synergies", []) + combos_model = _det.get("combos_model") + synergies_model = _det.get("synergies_model") # Suggestions suggestions: list[dict] = [] @@ -1182,10 +1080,7 @@ async def build_combos_panel(request: Request) -> HTMLResponse: "target": target, "combos": combos, "synergies": synergies, - "versions": { - "combos": getattr(combos_model, "list_version", None) if combos_model else None, - "synergies": getattr(synergies_model, "list_version", None) if synergies_model else None, - }, + "versions": _det.get("versions", {}), "suggestions": suggestions, } 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 if "replace_mode" not in sess: sess["replace_mode"] = True - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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")), - }, - ) + base = step5_empty_ctx(request, sess) + resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -1297,34 +1164,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse: return resp # Ensure build context exists; if not, start it first if not sess.get("build_ctx"): - 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() - # 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")), - ) + sess["build_ctx"] = start_ctx_from_session(sess) else: # If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet try: @@ -1371,11 +1211,16 @@ async def build_step5_continue(request: Request) -> HTMLResponse: show_skipped = True except Exception: pass - res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) - status = "Build complete" if res.get("done") else "Stage complete" + try: + 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") - 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 try: 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}" except Exception: 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 - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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")), - }, - ) + ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) + resp = templates.TemplateResponse("build/_step5.html", ctx2) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -1442,33 +1246,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: return resp # Rerun requires an existing context; if missing, create it and run first stage as rerun if not sess.get("build_ctx"): - 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 = 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")), - ) + sess["build_ctx"] = start_ctx_from_session(sess) else: # Ensure latest locks are reflected in the existing context 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 not bool(sess.get("replace_mode", True)): show_skipped = True - res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) - status = "Stage rerun 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", []) - 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") + try: + res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) + status = "Stage rerun complete" if not res.get("done") else "Build complete" + except Exception as e: + sess["last_step"] = 5 + err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp sess["last_step"] = 5 # Build locked cards list with ownership and in-deck presence locked_cards = [] try: ctx = sess.get("build_ctx") or {} b = ctx.get("builder") if isinstance(ctx, dict) else None - present: set[str] = 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()) + present: set[str] = builder_present_names(b) if b is not None else set() # Display-map via combined df when available - display_map: dict[str, str] = {} - try: - if b is not None: - 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()} + lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {} + owned_lower = owned_set_helper() for nm in (sess.get("locks", []) or []): key = str(nm).strip().lower() disp = display_map.get(key, nm) @@ -1563,39 +1292,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: }) except Exception: locked_cards = [] - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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, - }, - ) + ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) + ctx3["locked_cards"] = locked_cards + resp = templates.TemplateResponse("build/_step5.html", ctx3) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -1617,33 +1316,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: return resp try: # Initialize step-by-step build context and run first stage - 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 = 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")), - ) + sess["build_ctx"] = start_ctx_from_session(sess) show_skipped = False try: form = await request.form() @@ -1652,78 +1325,29 @@ async def build_step5_start(request: Request) -> HTMLResponse: pass res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) 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 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") sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" except Exception: 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 - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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)), - }, - ) + ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) + resp = templates.TemplateResponse("build/_step5.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp except Exception as e: - # Surface a friendly error on the step 5 screen - resp = templates.TemplateResponse( - "build/_step5.html", - { - "request": request, - "commander": commander, - "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, - }, + # Surface a friendly error on the step 5 screen with normalized context + err_ctx = step5_error_ctx( + request, + sess, + f"Failed to start build: {e}", + include_name=False, ) + # 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") return resp @@ -1783,35 +1407,12 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse: except Exception: return await build_step5_get(request) # Re-render step 5 with cleared added list - resp = templates.TemplateResponse( - "build/_step5.html", - { - "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": "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")), - }, - ) + base = step5_empty_ctx(request, sess, extras={ + "status": "Stage reset", + "i": ctx.get("idx"), + "n": len(ctx.get("stages", [])), + }) + resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -1887,13 +1488,11 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No ctx = sess.get("build_ctx") or {} b = ctx.get("builder") if isinstance(ctx, dict) else None # 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")) # If builder context missing, show a guidance message if not b: - html = ( - '
Start the build to see alternatives.
' - ) + html = '
Start the build to see alternatives.
' return HTMLResponse(html) try: 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_entry = lib.get(name) or lib.get(name_l) # Best-effort set of names currently in the deck to avoid duplicates - in_deck: set[str] = set() - 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() + in_deck: set[str] = builder_present_names(b) # Build candidate pool from tags overlap 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: if nm == name_l: continue @@ -1992,63 +1549,32 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No return nm in owned_set 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 - display_map: dict[str, str] = {} - try: - df = getattr(b, "_combined_cards_df", None) - if df is not None and not df.empty: - # 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] = [] + pool_lower = {nm for (nm, _s) in candidates} + display_map: dict[str, str] = builder_display_map(b, pool_lower) + # Build structured items for the partial + items: list[dict] = [] seen = set() - count = 0 for nm, score in candidates: if nm in seen: continue seen.add(nm) - disp = display_map.get(nm, nm) is_owned = (nm in owned_set) if require_owned and not is_owned: continue - badge = "✔" if is_owned else "✖" - title = "Owned" if is_owned else "Not owned" - # Replace button posts to /build/replace; we'll update locks and prompt rerun - # Provide hover-preview metadata so moving the mouse over the alternative shows that card - cand_tags = tags_idx.get(nm) or [] - data_tags = ", ".join([str(t) for t in cand_tags]) - items_html.append( - f'
  • {badge} ' - f'
  • ' - ) - count += 1 - if count >= 10: + disp = display_map.get(nm, nm) + items.append({ + "name": disp, + "name_lower": nm, + "owned": is_owned, + "tags": list(tags_idx.get(nm) or []), + }) + if len(items) >= 10: break - # Build HTML - if not items_html: - owned_msg = " (owned only)" if require_owned else "" - html = f'
    No alternatives found{owned_msg}.
    ' - else: - toggle_q = "0" if require_owned else "1" - toggle_label = ("Owned only: On" if require_owned else "Owned only: Off") - html = ( - '
    ' - f'
    Alternatives' - f'
    ' - '' - '
    ' - ) - # Save to cache and return - _alts_set_cached(cache_key, html) - return HTMLResponse(html) + # Render partial via Jinja template and cache it + ctx2 = {"request": request, "name": name, "require_owned": require_owned, "items": items} + html_str = templates.get_template("build/_alternatives.html").render(ctx2) + _alts_set_cached(cache_key, html_str) + return HTMLResponse(html_str) except Exception as e: return HTMLResponse(f'
    No alternatives: {e}
    ') diff --git a/code/web/routes/configs.py b/code/web/routes/configs.py index 2487c9c..243fec2 100644 --- a/code/web/routes/configs.py +++ b/code/web/routes/configs.py @@ -6,11 +6,9 @@ from pathlib import Path import os import json 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 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") @@ -143,7 +141,7 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s if use_owned_only is not None: 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 prefer_combos = False @@ -198,43 +196,24 @@ async def configs_run(request: Request, name: str = Form(...), use_owned_only: s "commander": commander, "tag_mode": tag_mode, "use_owned_only": owned_flag, - "owned_set": {n.lower() for n in owned_store.get_names()}, + "owned_set": owned_set_helper(), }, ) - return templates.TemplateResponse( - "configs/run_result.html", - { - "request": request, - "ok": True, - "log": res.get("log", ""), - "csv_path": res.get("csv_path"), - "txt_path": res.get("txt_path"), - "summary": res.get("summary"), - "cfg_name": p.name, - "commander": commander, - "tag_mode": tag_mode, - "use_owned_only": owned_flag, - "owned_set": {n.lower() for n in owned_store.get_names()}, - "game_changers": bc.GAME_CHANGERS, - # Combos & Synergies for summary panel - **(lambda _sum: (lambda names: (lambda _cm,_sm: { - "combos": (_detect_combos(names, combos_path="config/card_lists/combos.json") if names else []), - "synergies": (_detect_synergies(names, synergies_path="config/card_lists/synergies.json") if names else []), - "versions": { - "combos": getattr(_cm, 'list_version', None) if _cm else None, - "synergies": getattr(_sm, 'list_version', None) if _sm else None, - } - })( - (lambda: (_load_combos("config/card_lists/combos.json")))(), - (lambda: (_load_synergies("config/card_lists/synergies.json")))(), - ))( - (lambda s, cmd: (lambda names_set: sorted(names_set | ({cmd} if cmd else set())))( - set([str((c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))) for _t, cl in (((s or {}).get('type_breakdown', {}) or {}).get('cards', {}).items()) for c in (cl or []) if (c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))]) - | set([str((c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))) for _b, cl in ((((s or {}).get('mana_curve', {}) or {}).get('cards', {}) or {}).items()) for c in (cl or []) if (c.get('name') if isinstance(c, dict) else getattr(c, 'name', ''))]) - ))(_sum, commander) - ))(res.get("summary")) - }, - ) + ctx = { + "request": request, + "ok": True, + "log": res.get("log", ""), + "csv_path": res.get("csv_path"), + "txt_path": res.get("txt_path"), + "summary": res.get("summary"), + "cfg_name": p.name, + "commander": commander, + "tag_mode": tag_mode, + "use_owned_only": owned_flag, + } + ctx.update(summary_ctx(summary=res.get("summary"), commander=commander, tags=tags)) + return templates.TemplateResponse("configs/run_result.html", ctx) + @router.post("/upload", response_class=HTMLResponse) diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 4fb67b1..8b8300c 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -8,10 +8,8 @@ import os from typing import Dict, List, Tuple, Optional from ..app import templates -from ..services import owned_store -from deck_builder.combos import detect_combos as _detect_combos, detect_synergies as _detect_synergies -from tagging.combo_schema import load_and_validate_combos as _load_combos, load_and_validate_synergies as _load_synergies -from deck_builder import builder_constants as bc +# from ..services import owned_store +from ..services.summary_utils import summary_ctx router = APIRouter(prefix="/decks") @@ -294,61 +292,6 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: parts = stem.split('_') commander_name = parts[0] if parts else '' - # Prepare combos/synergies detections for summary panel - combos = [] - synergies = [] - versions = {"combos": None, "synergies": None} - try: - # Collect deck card names from summary (types + curve) and include commander - names_set: set[str] = set() - try: - tb = (summary or {}).get('type_breakdown', {}) - cards_by_type = tb.get('cards', {}) if isinstance(tb, dict) else {} - for _typ, clist in (cards_by_type.items() if isinstance(cards_by_type, dict) else []): - for c in (clist or []): - n = str(c.get('name') if isinstance(c, dict) else getattr(c, 'name', '')) - if n: - names_set.add(n) - except Exception: - pass - # Also pull from mana curve cards for robustness - try: - mc = (summary or {}).get('mana_curve', {}) - curve_cards = mc.get('cards', {}) if isinstance(mc, dict) else {} - for _bucket, clist in (curve_cards.items() if isinstance(curve_cards, dict) else []): - for c in (clist or []): - n = str(c.get('name') if isinstance(c, dict) else getattr(c, 'name', '')) - if n: - names_set.add(n) - except Exception: - pass - # Ensure commander is included - if commander_name: - names_set.add(str(commander_name)) - - names = sorted(names_set) - if names: - try: - combos = _detect_combos(names, combos_path="config/card_lists/combos.json") - except Exception: - combos = [] - try: - synergies = _detect_synergies(names, synergies_path="config/card_lists/synergies.json") - except Exception: - synergies = [] - try: - cm = _load_combos("config/card_lists/combos.json") - versions["combos"] = getattr(cm, 'list_version', None) - except Exception: - pass - try: - sm = _load_synergies("config/card_lists/synergies.json") - versions["synergies"] = getattr(sm, 'list_version', None) - except Exception: - pass - except Exception: - pass - ctx = { "request": request, "name": p.name, @@ -358,12 +301,8 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: "commander": commander_name, "tags": tags, "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) diff --git a/code/web/routes/home.py b/code/web/routes/home.py deleted file mode 100644 index e988807..0000000 --- a/code/web/routes/home.py +++ /dev/null @@ -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}) diff --git a/code/web/services/alts_utils.py b/code/web/services/alts_utils.py new file mode 100644 index 0000000..431fd9e --- /dev/null +++ b/code/web/services/alts_utils.py @@ -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 diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py new file mode 100644 index 0000000..68c4d44 --- /dev/null +++ b/code/web/services/build_utils.py @@ -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 diff --git a/code/web/services/combo_utils.py b/code/web/services/combo_utils.py new file mode 100644 index 0000000..0e5d800 --- /dev/null +++ b/code/web/services/combo_utils.py @@ -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) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 97c7fc1..32bcef2 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -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: """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: cards_path = os.path.join('csv_files', 'cards.csv') 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) if force: _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: try: 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)...") _write_status({"running": True, "phase": "setup", "message": "Refreshing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0}) refresh_needed = True @@ -540,6 +632,10 @@ def _ensure_setup_ready(out, force: bool = False) -> None: refresh_needed = True 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: from file_setup.setup import initial_setup # type: ignore # 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 b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True) - # Ensure setup/tagging present before staged build - _ensure_setup_ready(out) + # Ensure setup/tagging present before staged build, but respect WEB_AUTO_SETUP + 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 df = b.load_commander_data() row = df[df["name"].astype(str) == str(commander)] diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py new file mode 100644 index 0000000..b52c67c --- /dev/null +++ b/code/web/services/summary_utils.py @@ -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 [], + } diff --git a/code/web/templates/build/_alternatives.html b/code/web/templates/build/_alternatives.html new file mode 100644 index 0000000..ec16038 --- /dev/null +++ b/code/web/templates/build/_alternatives.html @@ -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] } + ] +#} +
    +
    + Alternatives + {% set toggle_q = '0' if require_owned else '1' %} + {% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %} + +
    + {% if not items or items|length == 0 %} +
    No alternatives found{{ ' (owned only)' if require_owned else '' }}.
    + {% else %} + + {% endif %} +
    diff --git a/code/web/templates/build/_setup_prompt_modal.html b/code/web/templates/build/_setup_prompt_modal.html new file mode 100644 index 0000000..ae03ee3 --- /dev/null +++ b/code/web/templates/build/_setup_prompt_modal.html @@ -0,0 +1,21 @@ + + diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 7d6f636..273096f 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -85,6 +85,7 @@ {% endif %} {% if locked_cards is defined and locked_cards %} + {% from 'partials/_macros.html' import lock_button %}
    Locked cards (always kept) @@ -236,10 +234,9 @@
    {% if owned %}✔{% else %}✖{% endif %}
    {{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}
    - -
    + {% from 'partials/_macros.html' import lock_button %} + {{ lock_button(c.name, is_locked) }} + {% if c.reason %}
    @@ -274,10 +271,9 @@
    {% if owned %}✔{% else %}✖{% endif %}
    {{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}
    - -
    + {% from 'partials/_macros.html' import lock_button %} + {{ lock_button(c.name, is_locked) }} +
    {% if c.reason %}
    diff --git a/code/web/templates/partials/_macros.html b/code/web/templates/partials/_macros.html new file mode 100644 index 0000000..bc2382c --- /dev/null +++ b/code/web/templates/partials/_macros.html @@ -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. #} + +{%- endmacro %} diff --git a/config/card_lists/combo.json b/config/card_lists/combo.json index 5dd994f..785c352 120000 --- a/config/card_lists/combo.json +++ b/config/card_lists/combo.json @@ -1 +1,182 @@ -combos.json \ No newline at end of file +{ + "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"] } + ] +}