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'
'
- '
'
- + "".join(items_html) +
- '
'
- '
'
- )
- # 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 %}
+
+ {% for it in items %}
+ {% set badge = '✔' if it.owned else '✖' %}
+ {% set title = 'Owned' if it.owned else 'Not owned' %}
+ {% set tags = (it.tags or []) %}
+
+ {{ badge }}
+
+
+ {% endfor %}
+
+ {% 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 @@
+
+
+
+
+
{{ title or 'Setup required' }}
+
+
+
{{ message or 'The card database and tags need to be prepared before building a deck.' }}
+
+
+
+
+
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)
@@ -93,12 +94,9 @@
{{ lk.name }}{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}
{% if lk.in_deck %}• In deck{% else %}• Will be included on rerun{% endif %}
-