mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
web: DRY Step 5 and alternatives (partial+macro), centralize start_ctx/owned_set, adopt builder_*
This commit is contained in:
parent
fe9aabbce9
commit
014bcc37b7
24 changed files with 1200 additions and 766 deletions
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Normalize line endings and enforce LF for shell scripts
|
||||
* text=auto eol=lf
|
||||
|
||||
# Scripts
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows-friendly: keep .bat with CRLF
|
||||
*.bat text eol=crlf
|
12
.github/workflows/dockerhub-publish.yml
vendored
12
.github/workflows/dockerhub-publish.yml
vendored
|
@ -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
|
||||
|
|
12
.github/workflows/github-release.yml
vendored
12
.github/workflows/github-release.yml
vendored
|
@ -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 }}
|
||||
|
|
43
CHANGELOG.md
43
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
|
||||
|
|
|
@ -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
|
||||
|
|
60
code/tests/test_build_utils_ctx.py
Normal file
60
code/tests/test_build_utils_ctx.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from code.web.services.build_utils import start_ctx_from_session, owned_set, owned_names
|
||||
|
||||
|
||||
def _fake_session(**kw):
|
||||
# Provide minimal session keys used by start_ctx_from_session
|
||||
base = {
|
||||
"commander": "Cmdr",
|
||||
"tags": ["Aggro", "Spells"],
|
||||
"bracket": 3,
|
||||
"ideals": {"creatures": 25},
|
||||
"tag_mode": "AND",
|
||||
"use_owned_only": False,
|
||||
"prefer_owned": False,
|
||||
"locks": [],
|
||||
"custom_export_base": "TestDeck",
|
||||
"multi_copy": None,
|
||||
"prefer_combos": False,
|
||||
"combo_target_count": 2,
|
||||
"combo_balance": "mix",
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_owned_helpers_do_not_crash():
|
||||
# These reflect over the owned store; they should be resilient
|
||||
s = owned_set()
|
||||
assert isinstance(s, set)
|
||||
n = owned_names()
|
||||
assert isinstance(n, list)
|
||||
|
||||
|
||||
def test_start_ctx_from_session_minimal(monkeypatch):
|
||||
# Avoid integration dependency by faking orchestrator.start_build_ctx
|
||||
calls = {}
|
||||
def _fake_start_build_ctx(**kwargs):
|
||||
calls.update(kwargs)
|
||||
return {"builder": object(), "stages": [], "idx": 0, "last_visible_idx": 0}
|
||||
import code.web.services.build_utils as bu
|
||||
monkeypatch.setattr(bu.orch, "start_build_ctx", _fake_start_build_ctx)
|
||||
|
||||
sess = _fake_session()
|
||||
ctx = start_ctx_from_session(sess, set_on_session=False)
|
||||
assert isinstance(ctx, dict)
|
||||
assert "builder" in ctx
|
||||
assert "stages" in ctx
|
||||
assert "idx" in ctx
|
||||
|
||||
|
||||
def test_start_ctx_from_session_sets_on_session(monkeypatch):
|
||||
def _fake_start_build_ctx(**kwargs):
|
||||
return {"builder": object(), "stages": [], "idx": 0}
|
||||
import code.web.services.build_utils as bu
|
||||
monkeypatch.setattr(bu.orch, "start_build_ctx", _fake_start_build_ctx)
|
||||
|
||||
sess = _fake_session()
|
||||
ctx = start_ctx_from_session(sess, set_on_session=True)
|
||||
assert sess.get("build_ctx") == ctx
|
19
code/tests/test_orchestrator_staleness.py
Normal file
19
code/tests/test_orchestrator_staleness.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from code.web.services.orchestrator import is_setup_ready, is_setup_stale
|
||||
|
||||
|
||||
def test_is_setup_ready_false_when_missing():
|
||||
# On a clean checkout without csv_files, this should be False
|
||||
assert is_setup_ready() in (False, True) # Function exists and returns a bool
|
||||
|
||||
|
||||
def test_is_setup_stale_never_when_disabled_env(monkeypatch):
|
||||
monkeypatch.setenv("WEB_AUTO_REFRESH_DAYS", "0")
|
||||
assert is_setup_stale() is False
|
||||
|
||||
|
||||
def test_is_setup_stale_is_bool():
|
||||
# We don't assert specific timing behavior in unit tests; just type/robustness
|
||||
res = is_setup_stale()
|
||||
assert res in (False, True)
|
76
code/tests/test_step5_error_ctx.py
Normal file
76
code/tests/test_step5_error_ctx.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from code.web.services.build_utils import step5_error_ctx
|
||||
|
||||
|
||||
class _Req(SimpleNamespace):
|
||||
# minimal object to satisfy template context needs
|
||||
pass
|
||||
|
||||
|
||||
def test_step5_error_ctx_shape():
|
||||
req = _Req()
|
||||
sess = {
|
||||
"commander": "Atraxa, Praetors' Voice",
|
||||
"tags": ["+1/+1 Counters"],
|
||||
"bracket": 3,
|
||||
"ideals": {"lands": 36},
|
||||
"use_owned_only": False,
|
||||
"prefer_owned": False,
|
||||
"replace_mode": True,
|
||||
"locks": ["sol ring"],
|
||||
}
|
||||
ctx = step5_error_ctx(req, sess, "Boom")
|
||||
# Ensure required keys for _step5.html are present with safe defaults
|
||||
for k in (
|
||||
"request",
|
||||
"commander",
|
||||
"tags",
|
||||
"bracket",
|
||||
"values",
|
||||
"owned_only",
|
||||
"prefer_owned",
|
||||
"owned_set",
|
||||
"game_changers",
|
||||
"replace_mode",
|
||||
"prefer_combos",
|
||||
"combo_target_count",
|
||||
"combo_balance",
|
||||
"status",
|
||||
"stage_label",
|
||||
"log",
|
||||
"added_cards",
|
||||
"i",
|
||||
"n",
|
||||
"csv_path",
|
||||
"txt_path",
|
||||
"summary",
|
||||
"show_skipped",
|
||||
"total_cards",
|
||||
"added_total",
|
||||
"skipped",
|
||||
):
|
||||
assert k in ctx
|
||||
assert ctx["status"] == "Error"
|
||||
assert isinstance(ctx["added_cards"], list)
|
||||
assert ctx["show_skipped"] is False
|
||||
|
||||
|
||||
def test_step5_error_ctx_respects_flags():
|
||||
req = _Req()
|
||||
sess = {
|
||||
"use_owned_only": True,
|
||||
"prefer_owned": True,
|
||||
"combo_target_count": 3,
|
||||
"combo_balance": "early",
|
||||
}
|
||||
ctx = step5_error_ctx(req, sess, "Oops", include_name=False, include_locks=False)
|
||||
assert "name" not in ctx
|
||||
assert "locks" not in ctx
|
||||
# Flags should flow through
|
||||
assert ctx["owned_only"] is True
|
||||
assert ctx["prefer_owned"] is True
|
||||
assert ctx["combo_target_count"] == 3
|
||||
assert ctx["combo_balance"] == "early"
|
31
code/tests/test_summary_utils.py
Normal file
31
code/tests/test_summary_utils.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from code.web.services.summary_utils import summary_ctx
|
||||
|
||||
|
||||
def test_summary_ctx_empty_summary():
|
||||
ctx = summary_ctx(summary=None, commander="Test Commander", tags=["Aggro"])
|
||||
assert isinstance(ctx, dict)
|
||||
assert ctx.get("owned_set") is not None
|
||||
assert isinstance(ctx.get("combos"), list)
|
||||
assert isinstance(ctx.get("synergies"), list)
|
||||
assert ctx.get("versions") == {}
|
||||
assert ctx.get("commander") == "Test Commander"
|
||||
assert ctx.get("tags") == ["Aggro"]
|
||||
|
||||
|
||||
def test_summary_ctx_with_summary_basic():
|
||||
# Minimal fake summary structure sufficient for detect_for_summary to accept
|
||||
summary = {
|
||||
"type_breakdown": {"counts": {}, "order": [], "cards": {}, "total": 0},
|
||||
"pip_distribution": {"counts": {}, "weights": {}},
|
||||
"mana_generation": {},
|
||||
"mana_curve": {"total_spells": 0},
|
||||
"colors": [],
|
||||
}
|
||||
ctx = summary_ctx(summary=summary, commander="Cmdr", tags=["Spells"])
|
||||
assert "owned_set" in ctx and isinstance(ctx["owned_set"], set)
|
||||
assert "game_changers" in ctx
|
||||
assert "combos" in ctx and isinstance(ctx["combos"], list)
|
||||
assert "synergies" in ctx and isinstance(ctx["synergies"], list)
|
||||
assert "versions" in ctx and isinstance(ctx["versions"], dict)
|
|
@ -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],
|
||||
}
|
||||
|
|
|
@ -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 = (
|
||||
'<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
|
||||
)
|
||||
html = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
|
||||
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'<li><span class="owned-badge" title="{title}">{badge}</span> '
|
||||
f'<button class="btn" data-card-name="{_esc(disp)}" data-tags="{_esc(data_tags)}" hx-post="/build/replace" '
|
||||
f'hx-vals=' + "'" + f'{{"old":"{name}", "new":"{disp}"}}' + "'" + ' '
|
||||
f'hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">Replace with {_esc(disp)}</button></li>'
|
||||
)
|
||||
count += 1
|
||||
if count >= 10:
|
||||
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'<div class="alts"><div class="muted">No alternatives found{owned_msg}.</div></div>'
|
||||
else:
|
||||
toggle_q = "0" if require_owned else "1"
|
||||
toggle_label = ("Owned only: On" if require_owned else "Owned only: Off")
|
||||
html = (
|
||||
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
|
||||
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;"><strong>Alternatives</strong>'
|
||||
f'<button class="btn" hx-get="/build/alternatives?name={name}&owned_only={toggle_q}" hx-target="closest .alts" hx-swap="outerHTML">{toggle_label}</button></div>'
|
||||
'<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">'
|
||||
+ "".join(items_html) +
|
||||
'</ul>'
|
||||
'</div>'
|
||||
)
|
||||
# Save to cache and return
|
||||
_alts_set_cached(cache_key, html)
|
||||
return HTMLResponse(html)
|
||||
# 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'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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})
|
25
code/web/services/alts_utils.py
Normal file
25
code/web/services/alts_utils.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Tuple
|
||||
import time as _t
|
||||
|
||||
# Lightweight in-memory TTL cache for alternatives fragments
|
||||
_ALTS_CACHE: Dict[Tuple[str, str, bool], Tuple[float, str]] = {}
|
||||
_ALTS_TTL_SECONDS = 60.0
|
||||
|
||||
|
||||
def get_cached(key: tuple[str, str, bool]) -> str | None:
|
||||
try:
|
||||
ts, html = _ALTS_CACHE.get(key, (0.0, ""))
|
||||
if ts and (_t.time() - ts) < _ALTS_TTL_SECONDS:
|
||||
return html
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set_cached(key: tuple[str, str, bool], html: str) -> None:
|
||||
try:
|
||||
_ALTS_CACHE[key] = (_t.time(), html)
|
||||
except Exception:
|
||||
pass
|
265
code/web/services/build_utils.py
Normal file
265
code/web/services/build_utils.py
Normal file
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from fastapi import Request
|
||||
from ..services import owned_store
|
||||
from . import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]:
|
||||
"""Assemble the common Step 5 template context from session.
|
||||
|
||||
Includes commander/tags/bracket/values, ownership flags, owned_set, locks, replace_mode,
|
||||
combo preferences, and static game_changers. Caller can layer run-specific results.
|
||||
"""
|
||||
ctx: Dict[str, Any] = {
|
||||
"request": request,
|
||||
"commander": sess.get("commander"),
|
||||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"owned_set": owned_set(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"replace_mode": bool(sess.get("replace_mode", True)),
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_target_count": int(sess.get("combo_target_count", 2)),
|
||||
"combo_balance": str(sess.get("combo_balance", "mix")),
|
||||
}
|
||||
if include_name:
|
||||
ctx["name"] = sess.get("custom_export_base")
|
||||
if include_locks:
|
||||
ctx["locks"] = list(sess.get("locks", []))
|
||||
return ctx
|
||||
|
||||
|
||||
def owned_set() -> set[str]:
|
||||
"""Return lowercase owned card names with trimming for robust matching."""
|
||||
try:
|
||||
return {str(n).strip().lower() for n in owned_store.get_names()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def owned_names() -> list[str]:
|
||||
"""Return raw owned card names from the store (original casing)."""
|
||||
try:
|
||||
return list(owned_store.get_names())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[str, Any]:
|
||||
"""Create a staged build context from the current session selections.
|
||||
|
||||
Pulls commander, tags, bracket, ideals, tag_mode, ownership flags, locks, custom name,
|
||||
multi-copy selection, and combo preferences from the session and starts a build context.
|
||||
"""
|
||||
opts = orch.bracket_options()
|
||||
default_bracket = (opts[0]["level"] if opts else 1)
|
||||
bracket_val = sess.get("bracket")
|
||||
try:
|
||||
safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket)
|
||||
except Exception:
|
||||
safe_bracket = int(default_bracket)
|
||||
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names_list = owned_names() if (use_owned or prefer) else None
|
||||
ctx = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names_list,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
)
|
||||
if set_on_session:
|
||||
sess["build_ctx"] = ctx
|
||||
return ctx
|
||||
|
||||
|
||||
def step5_ctx_from_result(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
res: dict,
|
||||
*,
|
||||
status_text: Optional[str] = None,
|
||||
show_skipped: bool = False,
|
||||
include_name: bool = True,
|
||||
include_locks: bool = True,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a Step 5 context by merging base session data with a build stage result dict.
|
||||
|
||||
res is expected to be the dict returned from orchestrator.run_stage or similar with keys like
|
||||
label, log_delta, added_cards, idx, total, csv_path, txt_path, summary, etc.
|
||||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
done = bool(res.get("done"))
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": status_text,
|
||||
"stage_label": res.get("label"),
|
||||
"log": res.get("log_delta", ""),
|
||||
"added_cards": res.get("added_cards", []),
|
||||
"i": res.get("idx"),
|
||||
"n": res.get("total"),
|
||||
"csv_path": res.get("csv_path") if done else None,
|
||||
"txt_path": res.get("txt_path") if done else None,
|
||||
"summary": res.get("summary") if done else None,
|
||||
"show_skipped": bool(show_skipped),
|
||||
"total_cards": res.get("total_cards"),
|
||||
"added_total": res.get("added_total"),
|
||||
"mc_adjustments": res.get("mc_adjustments"),
|
||||
"clamped_overflow": res.get("clamped_overflow"),
|
||||
"mc_summary": res.get("mc_summary"),
|
||||
"skipped": bool(res.get("skipped")),
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
||||
|
||||
def step5_error_ctx(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
message: str,
|
||||
*,
|
||||
include_name: bool = True,
|
||||
include_locks: bool = True,
|
||||
status_text: str = "Error",
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a normalized Step 5 context for error states.
|
||||
|
||||
Provides all keys expected by the _step5.html template so the UI stays consistent
|
||||
even when a build can't start or a stage fails. The error message is placed in `log`.
|
||||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": status_text,
|
||||
"stage_label": None,
|
||||
"log": str(message),
|
||||
"added_cards": [],
|
||||
"i": None,
|
||||
"n": None,
|
||||
"csv_path": None,
|
||||
"txt_path": None,
|
||||
"summary": None,
|
||||
"show_skipped": False,
|
||||
"total_cards": None,
|
||||
"added_total": 0,
|
||||
"skipped": False,
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
||||
|
||||
def step5_empty_ctx(
|
||||
request: Request,
|
||||
sess: dict,
|
||||
*,
|
||||
include_name: bool = True,
|
||||
include_locks: bool = True,
|
||||
extras: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a baseline Step 5 context with empty stage data.
|
||||
|
||||
Used for GET /step5 and reset-stage flows to render the screen before any stage is run.
|
||||
"""
|
||||
base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks)
|
||||
ctx: Dict[str, Any] = {
|
||||
**base,
|
||||
"status": None,
|
||||
"stage_label": None,
|
||||
"log": None,
|
||||
"added_cards": [],
|
||||
"i": None,
|
||||
"n": None,
|
||||
"total_cards": None,
|
||||
"added_total": 0,
|
||||
"show_skipped": False,
|
||||
"skipped": False,
|
||||
}
|
||||
if extras:
|
||||
ctx.update(extras)
|
||||
return ctx
|
||||
|
||||
|
||||
def builder_present_names(builder: Any) -> set[str]:
|
||||
"""Return a lowercase set of names currently present in the builder/deck structures.
|
||||
|
||||
Safely probes a variety of attributes used across different builder implementations.
|
||||
"""
|
||||
present: set[str] = set()
|
||||
def _add_names(x: Any) -> None:
|
||||
try:
|
||||
if not x:
|
||||
return
|
||||
if isinstance(x, dict):
|
||||
for k, v in x.items():
|
||||
if isinstance(k, str) and k.strip():
|
||||
present.add(k.strip().lower())
|
||||
elif isinstance(v, dict) and v.get('name'):
|
||||
present.add(str(v.get('name')).strip().lower())
|
||||
elif isinstance(x, (list, tuple, set)):
|
||||
for item in x:
|
||||
if isinstance(item, str) and item.strip():
|
||||
present.add(item.strip().lower())
|
||||
elif isinstance(item, dict) and item.get('name'):
|
||||
present.add(str(item.get('name')).strip().lower())
|
||||
else:
|
||||
try:
|
||||
nm = getattr(item, 'name', None)
|
||||
if isinstance(nm, str) and nm.strip():
|
||||
present.add(nm.strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if builder is None:
|
||||
return present
|
||||
for attr in (
|
||||
'current_deck', 'deck', 'final_deck', 'final_cards',
|
||||
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
|
||||
):
|
||||
_add_names(getattr(builder, attr, None))
|
||||
for attr in ('current_names', 'deck_names', 'final_names'):
|
||||
val = getattr(builder, attr, None)
|
||||
if isinstance(val, (list, tuple, set)):
|
||||
for n in val:
|
||||
if isinstance(n, str) and n.strip():
|
||||
present.add(n.strip().lower())
|
||||
except Exception:
|
||||
pass
|
||||
return present
|
||||
|
||||
|
||||
def builder_display_map(builder: Any, pool_lower: set[str]) -> Dict[str, str]:
|
||||
"""Map lowercased names in pool_lower to display names using the combined DataFrame, if present."""
|
||||
display_map: Dict[str, str] = {}
|
||||
try:
|
||||
if builder is None or not pool_lower:
|
||||
return display_map
|
||||
df = getattr(builder, "_combined_cards_df", None)
|
||||
if df is not None and not df.empty:
|
||||
sub = df[df["name"].astype(str).str.lower().isin(pool_lower)]
|
||||
for _idx, row in sub.iterrows():
|
||||
display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip()
|
||||
except Exception:
|
||||
display_map = {}
|
||||
return display_map
|
98
code/web/services/combo_utils.py
Normal file
98
code/web/services/combo_utils.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from deck_builder.combos import (
|
||||
detect_combos as _detect_combos,
|
||||
detect_synergies as _detect_synergies,
|
||||
)
|
||||
from tagging.combo_schema import (
|
||||
load_and_validate_combos as _load_combos,
|
||||
load_and_validate_synergies as _load_synergies,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_COMBOS_PATH = "config/card_lists/combos.json"
|
||||
DEFAULT_SYNERGIES_PATH = "config/card_lists/synergies.json"
|
||||
|
||||
|
||||
def detect_all(
|
||||
names: List[str],
|
||||
*,
|
||||
combos_path: str = DEFAULT_COMBOS_PATH,
|
||||
synergies_path: str = DEFAULT_SYNERGIES_PATH,
|
||||
) -> Dict[str, object]:
|
||||
"""Detect combos/synergies for a list of card names and return results with versions.
|
||||
|
||||
Returns a dict with keys: combos, synergies, versions, combos_model, synergies_model.
|
||||
Models may be None if loading fails.
|
||||
"""
|
||||
try:
|
||||
combos_model = _load_combos(combos_path)
|
||||
except Exception:
|
||||
combos_model = None
|
||||
try:
|
||||
synergies_model = _load_synergies(synergies_path)
|
||||
except Exception:
|
||||
synergies_model = None
|
||||
|
||||
try:
|
||||
combos = _detect_combos(names, combos_path=combos_path)
|
||||
except Exception:
|
||||
combos = []
|
||||
try:
|
||||
synergies = _detect_synergies(names, synergies_path=synergies_path)
|
||||
except Exception:
|
||||
synergies = []
|
||||
|
||||
versions = {
|
||||
"combos": getattr(combos_model, "list_version", None) if combos_model else None,
|
||||
"synergies": getattr(synergies_model, "list_version", None) if synergies_model else None,
|
||||
}
|
||||
return {
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": versions,
|
||||
"combos_model": combos_model,
|
||||
"synergies_model": synergies_model,
|
||||
}
|
||||
|
||||
|
||||
def _names_from_summary(summary: Dict[str, object]) -> List[str]:
|
||||
"""Extract a best-effort set of card names from a build summary dict."""
|
||||
names_set: set[str] = set()
|
||||
try:
|
||||
tb = (summary or {}).get("type_breakdown", {})
|
||||
cards_by_type = tb.get("cards", {}) if isinstance(tb, dict) else {}
|
||||
for _typ, clist in (cards_by_type.items() if isinstance(cards_by_type, dict) else []):
|
||||
for c in (clist or []):
|
||||
n = str(c.get("name") if isinstance(c, dict) else getattr(c, "name", ""))
|
||||
if n:
|
||||
names_set.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mc = (summary or {}).get("mana_curve", {})
|
||||
curve_cards = mc.get("cards", {}) if isinstance(mc, dict) else {}
|
||||
for _bucket, clist in (curve_cards.items() if isinstance(curve_cards, dict) else []):
|
||||
for c in (clist or []):
|
||||
n = str(c.get("name") if isinstance(c, dict) else getattr(c, "name", ""))
|
||||
if n:
|
||||
names_set.add(n)
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(names_set)
|
||||
|
||||
|
||||
def detect_for_summary(
|
||||
summary: Dict[str, object] | None,
|
||||
commander_name: str | None = None,
|
||||
*,
|
||||
combos_path: str = DEFAULT_COMBOS_PATH,
|
||||
synergies_path: str = DEFAULT_SYNERGIES_PATH,
|
||||
) -> Dict[str, object]:
|
||||
"""Convenience helper: compute names from summary (+commander) and run detect_all."""
|
||||
names = _names_from_summary(summary or {})
|
||||
if commander_name:
|
||||
names = sorted(set(names) | {str(commander_name)})
|
||||
return detect_all(names, combos_path=combos_path, synergies_path=synergies_path)
|
|
@ -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)]
|
||||
|
|
32
code/web/services/summary_utils.py
Normal file
32
code/web/services/summary_utils.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from deck_builder import builder_constants as bc
|
||||
from .build_utils import owned_set as owned_set_helper
|
||||
from .combo_utils import detect_for_summary as _detect_for_summary
|
||||
|
||||
|
||||
def summary_ctx(
|
||||
*,
|
||||
summary: dict | None,
|
||||
commander: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
include_versions: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a unified context payload for deck summary panels.
|
||||
|
||||
Provides owned_set, game_changers, combos/synergies, and detector versions.
|
||||
"""
|
||||
det = _detect_for_summary(summary, commander_name=commander or "") if summary else {"combos": [], "synergies": [], "versions": {}}
|
||||
combos = det.get("combos", [])
|
||||
synergies = det.get("synergies", [])
|
||||
versions = det.get("versions", {} if include_versions else None)
|
||||
return {
|
||||
"owned_set": owned_set_helper(),
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"combos": combos,
|
||||
"synergies": synergies,
|
||||
"versions": versions,
|
||||
"commander": commander,
|
||||
"tags": tags or [],
|
||||
}
|
34
code/web/templates/build/_alternatives.html
Normal file
34
code/web/templates/build/_alternatives.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{# Alternatives panel partial.
|
||||
Expects: name (seed display), require_owned (bool), items = [
|
||||
{ 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] }
|
||||
]
|
||||
#}
|
||||
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem;">
|
||||
<strong>Alternatives</strong>
|
||||
{% set toggle_q = '0' if require_owned else '1' %}
|
||||
{% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
|
||||
<button class="btn" hx-get="/build/alternatives?name={{ name|urlencode }}&owned_only={{ toggle_q }}"
|
||||
hx-target="closest .alts" hx-swap="outerHTML">{{ toggle_label }}</button>
|
||||
</div>
|
||||
{% if not items or items|length == 0 %}
|
||||
<div class="muted">No alternatives found{{ ' (owned only)' if require_owned else '' }}.</div>
|
||||
{% else %}
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
|
||||
{% for it in items %}
|
||||
{% set badge = '✔' if it.owned else '✖' %}
|
||||
{% set title = 'Owned' if it.owned else 'Not owned' %}
|
||||
{% set tags = (it.tags or []) %}
|
||||
<li>
|
||||
<span class="owned-badge" title="{{ title }}">{{ badge }}</span>
|
||||
<button class="btn" data-card-name="{{ it.name }}"
|
||||
data-tags="{{ tags|join(', ') }}" hx-post="/build/replace"
|
||||
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}"}'
|
||||
hx-target="closest .alts" hx-swap="outerHTML" title="Lock this alternative and unlock the current pick">
|
||||
Replace with {{ it.name }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
21
code/web/templates/build/_setup_prompt_modal.html
Normal file
21
code/web/templates/build/_setup_prompt_modal.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="setupPromptTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
|
||||
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
|
||||
<div class="modal-content" style="position:relative; max-width:560px; width:clamp(320px, 90vw, 560px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
|
||||
<div class="modal-header">
|
||||
<h3 id="setupPromptTitle">{{ title or 'Setup required' }}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{ message or 'The card database and tags need to be prepared before building a deck.' }}</p>
|
||||
</div>
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<a class="btn-continue" href="{{ action_url }}" hx-boost="true" hx-target="body" hx-swap="innerHTML">{{ action_label or 'Run Setup' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); try{ var m=document.querySelector('.modal'); if(m){ m.remove(); document.removeEventListener('keydown', onKey); } }catch(_){ } } }
|
||||
document.addEventListener('keydown', onKey);
|
||||
})();
|
||||
</script>
|
|
@ -85,6 +85,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
<details id="locked-section" style="margin-top:.5rem;">
|
||||
<summary>Locked cards (always kept)</summary>
|
||||
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
|
@ -93,12 +94,9 @@
|
|||
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
|
||||
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
|
||||
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
|
||||
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;">
|
||||
<input type="hidden" name="name" value="{{ lk.name }}" />
|
||||
<input type="hidden" name="locked" value="0" />
|
||||
<input type="hidden" name="from_list" value="1" />
|
||||
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
|
||||
</form>
|
||||
<div class="lock-box" style="display:inline; margin-left:auto;">
|
||||
{{ lock_button(lk.name, True, from_list=True, target_selector='closest li') }}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -236,10 +234,9 @@
|
|||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
{{ lock_button(c.name, is_locked) }}
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
|
@ -274,10 +271,9 @@
|
|||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
{{ lock_button(c.name, is_locked) }}
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
|
|
13
code/web/templates/partials/_macros.html
Normal file
13
code/web/templates/partials/_macros.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{# Reusable Jinja macros for UI elements #}
|
||||
|
||||
{% macro lock_button(name, locked=False, from_list=False, target_selector='closest .lock-box') -%}
|
||||
{# Emits a lock/unlock button with correct hx-vals and aria state. #}
|
||||
<button type="button" class="btn-lock"
|
||||
title="{{ 'Unlock this card (kept across reruns)' if locked else 'Lock this card (keep across reruns)' }}"
|
||||
aria-pressed="{{ 'true' if locked else 'false' }}"
|
||||
data-locked="{{ '1' if locked else '0' }}"
|
||||
hx-post="/build/lock" hx-target="{{ target_selector }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ name }}", "locked": "{{ '0' if locked else '1' }}"{% if from_list %}, "from_list": "1"{% endif %}}'>
|
||||
{{ '🔒 Unlock' if locked else '🔓 Lock' }}
|
||||
</button>
|
||||
{%- endmacro %}
|
|
@ -1 +1,182 @@
|
|||
combos.json
|
||||
{
|
||||
"list_version": "0.3.0",
|
||||
"generated_at": null,
|
||||
"pairs": [
|
||||
{ "a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
|
||||
{ "a": "Thassa's Oracle", "b": "Tainted Pact", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
|
||||
{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Zealous Conscripts", "cheap_early": true, "setup_dependent": false, "tags": ["infinite"] },
|
||||
{ "a": "Devoted Druid", "b": "Vizier of Remedies", "cheap_early": true, "setup_dependent": false, "tags": ["infinite"] },
|
||||
{ "a": "Heliod, Sun-Crowned", "b": "Walking Ballista", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
|
||||
{ "a": "Isochron Scepter", "b": "Dramatic Reversal", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Underworld Breach", "b": "Brain Freeze", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "storm"] },
|
||||
{ "a": "Auriok Salvagers", "b": "Lion's Eye Diamond", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Worldgorger Dragon", "b": "Animate Dead", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Exquisite Blood", "b": "Sanguine Bond", "cheap_early": false, "setup_dependent": false, "tags": ["wincon"] },
|
||||
{ "a": "Exquisite Blood", "b": "Vito, Thorn of the Dusk Rose", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
|
||||
{ "a": "Exquisite Blood", "b": "Marauding Blight-Priest", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
|
||||
{ "a": "Exquisite Blood", "b": "Vizkopa Guildmage", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
|
||||
{ "a": "Exquisite Blood", "b": "Cliffhaven Vampire", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
|
||||
{ "a": "Exquisite Blood", "b": "Enduring Tenacity", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "life"] },
|
||||
{ "a": "Mikaeus, the Unhallowed", "b": "Triskelion", "cheap_early": false, "setup_dependent": false, "tags": ["wincon", "infinite"] },
|
||||
{ "a": "Basalt Monolith", "b": "Rings of Brighthearth", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Basalt Monolith", "b": "Forsaken Monument", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Basalt Monolith", "b": "Forensic Gadgeteer", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Basalt Monolith", "b": "Nyxbloom Ancient", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Power Artifact", "b": "Grim Monolith", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Painter's Servant", "b": "Grindstone", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
|
||||
{ "a": "Rest in Peace", "b": "Helm of Obedience", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
|
||||
{ "a": "Thopter Foundry", "b": "Sword of the Meek", "cheap_early": true, "setup_dependent": false, "tags": ["engine"] },
|
||||
{ "a": "Karmic Guide", "b": "Reveillark", "cheap_early": false, "setup_dependent": true, "tags": ["loop", "infinite"] },
|
||||
{ "a": "Food Chain", "b": "Misthollow Griffin", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Food Chain", "b": "Eternal Scourge", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Food Chain", "b": "Squee, the Immortal", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] },
|
||||
{ "a": "Deadeye Navigator", "b": "Peregrine Drake", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Godo, Bandit Warlord", "b": "Helm of the Host", "cheap_early": false, "setup_dependent": false, "tags": ["wincon"] }
|
||||
,{ "a": "Aurelia, the Warleader", "b": "Helm of the Host", "cheap_early": false, "setup_dependent": false, "tags": ["wincon", "combat"] }
|
||||
,{ "a": "Combat Celebrant", "b": "Helm of the Host", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Narset, Parter of Veils", "b": "Windfall", "cheap_early": true, "setup_dependent": false, "tags": ["lock"] }
|
||||
,{ "a": "Knowledge Pool", "b": "Teferi, Mage of Zhalfir", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
|
||||
,{ "a": "Knowledge Pool", "b": "Teferi, Time Raveler", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
|
||||
,{ "a": "Possibility Storm", "b": "Rule of Law", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
|
||||
,{ "a": "Possibility Storm", "b": "Eidolon of Rhetoric", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
|
||||
,{ "a": "Grand Architect", "b": "Pili-Pala", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Umbral Mantle", "b": "Priest of Titania", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Umbral Mantle", "b": "Elvish Archdruid", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Umbral Mantle", "b": "Marwyn, the Nurturer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Umbral Mantle", "b": "Circle of Dreams Druid", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Staff of Domination", "b": "Priest of Titania", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Staff of Domination", "b": "Elvish Archdruid", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Staff of Domination", "b": "Marwyn, the Nurturer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Staff of Domination", "b": "Circle of Dreams Druid", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Staff of Domination", "b": "Selvala, Heart of the Wilds", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Freed from the Real", "b": "Bloom Tender", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Freed from the Real", "b": "Faeburrow Elder", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Kinnan, Bonder Prodigy", "b": "Basalt Monolith", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Melira, Sylvok Outcast", "b": "Kitchen Finks", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "life"] }
|
||||
,{ "a": "Vizier of Remedies", "b": "Kitchen Finks", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "life"] }
|
||||
,{ "a": "Devoted Druid", "b": "Quillspike", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "power"] }
|
||||
,{ "a": "Devoted Druid", "b": "Swift Reconfiguration", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mana"] }
|
||||
,{ "a": "Heliod, Sun-Crowned", "b": "Spike Feeder", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "life"] }
|
||||
,{ "a": "Mind Over Matter", "b": "Temple Bell", "cheap_early": false, "setup_dependent": false, "tags": ["infinite", "draw"] }
|
||||
,{ "a": "Saheeli Rai", "b": "Felidar Guardian", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Felidar Guardian", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Felidar Guardian", "b": "Restoration Angel", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Restoration Angel", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Niv-Mizzet, Parun", "b": "Curiosity", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
|
||||
,{ "a": "Niv-Mizzet, the Firemind", "b": "Curiosity", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
|
||||
,{ "a": "Niv-Mizzet, Parun", "b": "Ophidian Eye", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
|
||||
,{ "a": "Niv-Mizzet, the Firemind", "b": "Ophidian Eye", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
|
||||
,{ "a": "Niv-Mizzet, Parun", "b": "Tandem Lookout", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
|
||||
,{ "a": "Niv-Mizzet, the Firemind", "b": "Tandem Lookout", "cheap_early": true, "setup_dependent": false, "tags": ["loop", "wincon"] }
|
||||
,{ "a": "Bloodchief Ascension", "b": "Mindcrank", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Gravecrawler", "b": "Phyrexian Altar", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "death"] }
|
||||
,{ "a": "Goblin Sharpshooter", "b": "Basilisk Collar", "cheap_early": true, "setup_dependent": true, "tags": ["lock", "removal"] }
|
||||
,{ "a": "Malcolm, Keen-Eyed Navigator", "b": "Glint-Horn Buccaneer", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "damage"] }
|
||||
,{ "a": "Professor Onyx", "b": "Chain of Smog", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }
|
||||
,{ "a": "Witherbloom Apprentice", "b": "Chain of Smog", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }
|
||||
,{ "a": "Solphim, Mayhem Dominus", "b": "Heartless Hidetsugu", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "damage"] }
|
||||
,{ "a": "Karn, the Great Creator", "b": "Mycosynth Lattice", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
|
||||
,{ "a": "Mycosynth Lattice", "b": "Vandalblast", "cheap_early": false, "setup_dependent": false, "tags": ["lock", "stax"] }
|
||||
,{ "a": "Animate Dead", "b": "Abdel Adrian, Gorion's Ward", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Ratadrabik of Urborg", "b": "Boromir, Warden of the Tower", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "death"] }
|
||||
,{ "a": "Tivit, Seller of Secrets", "b": "Time Sieve", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "turns"] }
|
||||
,{ "a": "Blasphemous Act", "b": "Repercussion", "cheap_early": true, "setup_dependent": true, "tags": ["damage", "boardwipe"] }
|
||||
,{ "a": "Toralf, God of Fury", "b": "Blasphemous Act", "cheap_early": true, "setup_dependent": true, "tags": ["damage", "boardwipe"] }
|
||||
,{ "a": "Aggravated Assault", "b": "Sword of Feast and Famine", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Aggravated Assault", "b": "Savage Ventmaw", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Aggravated Assault", "b": "Neheb, the Eternal", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Aggravated Assault", "b": "The Reaver Cleaver", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Aggravated Assault", "b": "Selvala, Heart of the Wilds", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Ashaya, Soul of the Wild", "b": "Quirion Ranger", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Scurry Oak", "b": "Ivy Lane Denizen", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Rosie Cotton of South Lane", "b": "Scurry Oak", "cheap_early": true, "setup_dependent": false, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Basking Broodscale", "b": "Rosie Cotton of South Lane", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "The Gitrog Monster", "b": "Dakmor Salvage", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "mill"] }
|
||||
,{ "a": "Maddening Cacophony", "b": "Bruvac the Grandiloquent", "cheap_early": true, "setup_dependent": false, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Traumatize", "b": "Bruvac the Grandiloquent", "cheap_early": false, "setup_dependent": false, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Cut Your Losses", "b": "Bruvac the Grandiloquent", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Cut Your Losses", "b": "Fraying Sanity", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Terisian Mindbreaker", "b": "Bruvac the Grandiloquent", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Terisian Mindbreaker", "b": "Fraying Sanity", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "mill"] }
|
||||
,{ "a": "Dualcaster Mage", "b": "Heat Shimmer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Dualcaster Mage", "b": "Molten Duplication", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Dualcaster Mage", "b": "Saw in Half", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Dualcaster Mage", "b": "Ghostly Flicker", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Naru Meha, Master Wizard", "b": "Ghostly Flicker", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "etb"] }
|
||||
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Village Bell-Ringer", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Kiki-Jiki, Mirror Breaker", "b": "Combat Celebrant", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Demonic Consultation", "b": "Laboratory Maniac", "cheap_early": true, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Peregrin Took", "b": "Experimental Confectioner", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "draw"] }
|
||||
,{ "a": "Peregrin Took", "b": "Nuka-Cola Vending Machine", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "treasure"] }
|
||||
,{ "a": "Aggravated Assault", "b": "Bear Umbra", "cheap_early": false, "setup_dependent": true, "tags": ["infinite", "combat"] }
|
||||
,{ "a": "Nest of Scarabs", "b": "Blowfly Infestation", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "death"] }
|
||||
,{ "a": "Ondu Spiritdancer", "b": "Secret Arcade // Dusty Parlor", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "tokens"] }
|
||||
,{ "a": "Storm-Kiln Artist", "b": "Haze of Rage", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "storm"] }
|
||||
,{ "a": "Bloodthirsty Conqueror", "b": "Vito, Thorn of the Dusk Rose", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Bloodthirsty Conqueror", "b": "Sanguine Bond", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Bloodthirsty Conqueror", "b": "Enduring Tenacity", "cheap_early": true, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Glint-Horn Buccaneer", "b": "Curiosity", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "draw"] }
|
||||
,{ "a": "Sheoldred, the Apocalypse", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "draw"] }
|
||||
,{ "a": "Underworld Dreams", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Psychosis Crawler", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "draw"] }
|
||||
,{ "a": "Orcish Bowmasters", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["damage"] }
|
||||
,{ "a": "Bloodletter of Aclazotz", "b": "Peer into the Abyss", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Jeska's Will", "b": "Reiterate", "cheap_early": false, "setup_dependent": true, "tags": ["mana", "storm"] }
|
||||
,{ "a": "Mana Geyser", "b": "Reiterate", "cheap_early": false, "setup_dependent": true, "tags": ["mana", "storm"] }
|
||||
,{ "a": "Approach of the Second Sun", "b": "Scroll Rack", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Approach of the Second Sun", "b": "Narset's Reversal", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Approach of the Second Sun", "b": "Reprieve", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Teferi, Temporal Archmage", "b": "The Chain Veil", "cheap_early": false, "setup_dependent": true, "tags": ["planeswalker", "engine"] }
|
||||
,{ "a": "Old Gnawbone", "b": "Hellkite Charger", "cheap_early": false, "setup_dependent": true, "tags": ["combat", "mana"] }
|
||||
,{ "a": "Aggravated Assault", "b": "Old Gnawbone", "cheap_early": false, "setup_dependent": true, "tags": ["combat", "mana"] }
|
||||
,{ "a": "The World Tree", "b": "Maskwood Nexus", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
|
||||
,{ "a": "The World Tree", "b": "Arcane Adaptation", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
|
||||
,{ "a": "Solemnity", "b": "Decree of Silence", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
|
||||
,{ "a": "Gisela, Blade of Goldnight", "b": "Heartless Hidetsugu", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "damage"] }
|
||||
,{ "a": "Avacyn, Angel of Hope", "b": "Worldslayer", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
|
||||
,{ "a": "Mindslaver", "b": "Academy Ruins", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
|
||||
,{ "a": "Brine Elemental", "b": "Vesuvan Shapeshifter", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
|
||||
,{ "a": "Havoc Festival", "b": "Wound Reflection", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Maze's End", "b": "Scapeshift", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "lands"] }
|
||||
,{ "a": "Twinning Staff", "b": "Dramatic Reversal", "cheap_early": false, "setup_dependent": true, "tags": ["storm", "mana"] }
|
||||
,{ "a": "Terror of the Peaks", "b": "Rite of Replication", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
|
||||
,{ "a": "Zedruu the Greathearted", "b": "Transcendence", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Tivit, Seller of Secrets", "b": "Deadeye Navigator", "cheap_early": false, "setup_dependent": true, "tags": ["etb", "engine"] }
|
||||
,{ "a": "Brass's Bounty", "b": "Revel in Riches", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
|
||||
,{ "a": "Bootleggers' Stash", "b": "Revel in Riches", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
|
||||
,{ "a": "Brass's Bounty", "b": "Mechanized Production", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
|
||||
,{ "a": "Bootleggers' Stash", "b": "Mechanized Production", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "treasure"] }
|
||||
,{ "a": "Approach of the Second Sun", "b": "Mystical Tutor", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Approach of the Second Sun", "b": "Vampiric Tutor", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "Approach of the Second Sun", "b": "Demonic Tutor", "cheap_early": false, "setup_dependent": true, "tags": ["wincon"] }
|
||||
,{ "a": "The World Tree", "b": "Purphoros, God of the Forge", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
|
||||
,{ "a": "The World Tree", "b": "Rukarumel, Biologist", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
|
||||
,{ "a": "Realmbreaker, the Invasion Tree", "b": "Maskwood Nexus", "cheap_early": false, "setup_dependent": true, "tags": ["tribal", "tutor"] }
|
||||
,{ "a": "Beacon of Immortality", "b": "Sanguine Bond", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Vizkopa Guildmage", "b": "Beacon of Immortality", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Drogskol Reaver", "b": "Queza, Augur of Agonies", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "life"] }
|
||||
,{ "a": "Drogskol Reaver", "b": "Shabraz, the Skyshark", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "life"] }
|
||||
,{ "a": "Drogskol Reaver", "b": "Sheoldred, the Apocalypse", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "life"] }
|
||||
,{ "a": "Astral Dragon", "b": "Cursed Mirror", "cheap_early": false, "setup_dependent": true, "tags": ["tokens", "etb"] }
|
||||
,{ "a": "Kudo, King Among Bears", "b": "Elesh Norn, Grand Cenobite", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
|
||||
,{ "a": "Shard of the Nightbringer", "b": "Sanguine Bond", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Vito, Thorn of the Dusk Rose", "b": "Shard of the Nightbringer", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Bloodletter of Aclazotz", "b": "Shard of the Nightbringer", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Fraying Omnipotence", "b": "Wound Reflection", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "life"] }
|
||||
,{ "a": "Body of Knowledge", "b": "Niv-Mizzet, the Firemind", "cheap_early": false, "setup_dependent": true, "tags": ["draw"] }
|
||||
,{ "a": "Emry, Lurker of the Loch", "b": "Mindslaver", "cheap_early": false, "setup_dependent": true, "tags": ["lock"] }
|
||||
,{ "a": "Ad Nauseam", "b": "Teferi's Protection", "cheap_early": false, "setup_dependent": true, "tags": ["draw"] }
|
||||
,{ "a": "Wanderwine Prophets", "b": "Deeproot Pilgrimage", "cheap_early": false, "setup_dependent": true, "tags": ["turns"] }
|
||||
,{ "a": "Orthion, Hero of Lavabrink", "b": "Terror of the Peaks", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
|
||||
,{ "a": "Orthion, Hero of Lavabrink", "b": "Fanatic of Mogis", "cheap_early": false, "setup_dependent": true, "tags": ["damage"] }
|
||||
,{ "a": "Maze's End", "b": "Reshape the Earth", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "lands"] }
|
||||
,{ "a": "Avacyn, Angel of Hope", "b": "Nevinyrral's Disk", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
|
||||
,{ "a": "Toxrill, the Corrosive", "b": "Maha, Its Feathers Night", "cheap_early": false, "setup_dependent": true, "tags": ["lock", "boardwipe"] }
|
||||
,{ "a": "Niv-Mizzet, Visionary", "b": "Niv-Mizzet, Parun", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "damage"] }
|
||||
,{ "a": "Niv-Mizzet, Visionary", "b": "Niv-Mizzet, the Firemind", "cheap_early": false, "setup_dependent": true, "tags": ["draw", "damage"] }
|
||||
,{ "a": "Dragon Tempest", "b": "Ancient Gold Dragon", "cheap_early": false, "setup_dependent": true, "tags": ["damage", "tokens"] }
|
||||
,{ "a": "Vraska, Betrayal's Sting", "b": "Vorinclex, Monstrous Raider", "cheap_early": false, "setup_dependent": true, "tags": ["wincon", "planeswalker"] }
|
||||
,{ "a": "Polyraptor", "b": "Marauding Raptor", "cheap_early": false, "setup_dependent": true, "tags": ["tokens"] }
|
||||
,{ "a": "Tivit, Seller of Secrets", "b": "Time Sieve", "cheap_early": true, "setup_dependent": true, "tags": ["infinite", "turns"] }
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue