mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
Finalize MDFC follow-ups, docs, and diagnostics tooling
document deck summary DFC badges, exporter annotations, and per-face metadata across README/DOCKER/release notes record completion of all MDFC roadmap follow-ups and add the authoring guide for multi-face CSV entries wire in optional DFC_PER_FACE_SNAPSHOT env support, exporter regression tests, and diagnostics updates noted in the changelog
This commit is contained in:
parent
6fefda714e
commit
88cf832bf2
46 changed files with 3292 additions and 86 deletions
|
|
@ -15,6 +15,9 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from typing import Any, Optional, Dict, Iterable, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from code.deck_builder.summary_telemetry import get_mdfc_metrics
|
||||
from tagging.multi_face_merger import load_merge_summary
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
from .services.tasks import get_session, new_sid, set_session_value # type: ignore
|
||||
|
|
@ -873,6 +876,17 @@ async def status_random_theme_stats():
|
|||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/status/dfc_metrics")
|
||||
async def status_dfc_metrics():
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
try:
|
||||
return JSONResponse({"ok": True, "metrics": get_mdfc_metrics()})
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logging.getLogger("web").warning("Failed to fetch MDFC metrics: %s", exc, exc_info=True)
|
||||
return JSONResponse({"ok": False, "error": "internal_error"}, status_code=500)
|
||||
|
||||
|
||||
def random_modes_enabled() -> bool:
|
||||
"""Dynamic check so tests that set env after import still work.
|
||||
|
||||
|
|
@ -2352,7 +2366,13 @@ async def trigger_error(kind: str = Query("http")):
|
|||
async def diagnostics_home(request: Request) -> HTMLResponse:
|
||||
if not SHOW_DIAGNOSTICS:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return templates.TemplateResponse("diagnostics/index.html", {"request": request})
|
||||
return templates.TemplateResponse(
|
||||
"diagnostics/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"merge_summary": load_merge_summary(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/diagnostics/perf", response_class=HTMLResponse)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from path_util import csv_dir as _csv_dir
|
|||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
from ..services.telemetry import log_commander_create_deck
|
||||
from urllib.parse import urlparse
|
||||
from commander_exclusions import lookup_commander_detail
|
||||
|
||||
# Cache for available card names used by validation endpoints
|
||||
_AVAILABLE_CARDS_CACHE: set[str] | None = None
|
||||
|
|
@ -150,6 +151,7 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
|||
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")),
|
||||
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
||||
)
|
||||
except Exception:
|
||||
# If rebuild fails (e.g., commander not found in test), fall back to injecting
|
||||
|
|
@ -415,12 +417,22 @@ async def multicopy_save(
|
|||
async def build_new_modal(request: Request) -> HTMLResponse:
|
||||
"""Return the New Deck modal content (for an overlay)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"form": {
|
||||
"prefer_combos": bool(sess.get("prefer_combos")),
|
||||
"combo_count": sess.get("combo_target_count"),
|
||||
"combo_balance": sess.get("combo_balance"),
|
||||
"enable_multicopy": bool(sess.get("multi_copy")),
|
||||
"use_owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -432,7 +444,38 @@ async def build_new_candidates(request: Request, commander: str = Query("")) ->
|
|||
"""Return a small list of commander candidates for the modal live search."""
|
||||
q = (commander or "").strip()
|
||||
items = orch.commander_candidates(q, limit=8) if q else []
|
||||
ctx = {"request": request, "query": q, "candidates": items}
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for name, score, colors in items:
|
||||
detail = lookup_commander_detail(name)
|
||||
preferred = name
|
||||
warning = None
|
||||
if detail:
|
||||
eligible_raw = detail.get("eligible_faces")
|
||||
eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
|
||||
norm_name = str(name).strip().casefold()
|
||||
eligible_norms = [face.casefold() for face in eligible]
|
||||
if eligible and norm_name not in eligible_norms:
|
||||
preferred = eligible[0]
|
||||
primary = str(detail.get("primary_face") or detail.get("name") or name).strip()
|
||||
if len(eligible) == 1:
|
||||
warning = (
|
||||
f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck."
|
||||
)
|
||||
else:
|
||||
faces = ", ".join(f"'{face}'" for face in eligible)
|
||||
warning = (
|
||||
f"This commander only works from specific faces: {faces}."
|
||||
)
|
||||
candidates.append(
|
||||
{
|
||||
"display": name,
|
||||
"value": preferred,
|
||||
"score": score,
|
||||
"colors": colors,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
ctx = {"request": request, "query": q, "candidates": candidates}
|
||||
return templates.TemplateResponse("build/_new_deck_candidates.html", ctx)
|
||||
|
||||
|
||||
|
|
@ -445,6 +488,7 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
tags = orch.tags_for_commander(info["name"]) or []
|
||||
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
|
||||
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
|
||||
exclusion_detail = lookup_commander_detail(info["name"])
|
||||
# Render tags slot content and OOB commander preview simultaneously
|
||||
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
|
||||
is_gc = False
|
||||
|
|
@ -454,7 +498,7 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
|
|||
is_gc = False
|
||||
ctx = {
|
||||
"request": request,
|
||||
"commander": {"name": info["name"]},
|
||||
"commander": {"name": info["name"], "exclusion": exclusion_detail},
|
||||
"tags": tags,
|
||||
"recommended": recommended,
|
||||
"recommended_reasons": recommended_reasons,
|
||||
|
|
@ -553,6 +597,9 @@ async def build_new_submit(
|
|||
combo_count: int | None = Form(None),
|
||||
combo_balance: str | None = Form(None),
|
||||
enable_multicopy: bool = Form(False),
|
||||
use_owned_only: bool = Form(False),
|
||||
prefer_owned: bool = Form(False),
|
||||
swap_mdfc_basics: bool = Form(False),
|
||||
# Integrated Multi-Copy (optional)
|
||||
multi_choice_id: str | None = Form(None),
|
||||
multi_count: int | None = Form(None),
|
||||
|
|
@ -567,6 +614,57 @@ async def build_new_submit(
|
|||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
|
||||
def _form_state(commander_value: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"commander": commander_value,
|
||||
"primary_tag": primary_tag or "",
|
||||
"secondary_tag": secondary_tag or "",
|
||||
"tertiary_tag": tertiary_tag or "",
|
||||
"tag_mode": tag_mode or "AND",
|
||||
"bracket": bracket,
|
||||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"enable_multicopy": bool(enable_multicopy),
|
||||
"use_owned_only": bool(use_owned_only),
|
||||
"prefer_owned": bool(prefer_owned),
|
||||
"swap_mdfc_basics": bool(swap_mdfc_basics),
|
||||
"include_cards": include_cards or "",
|
||||
"exclude_cards": exclude_cards or "",
|
||||
"enforcement_mode": enforcement_mode or "warn",
|
||||
"allow_illegal": bool(allow_illegal),
|
||||
"fuzzy_matching": bool(fuzzy_matching),
|
||||
}
|
||||
|
||||
commander_detail = lookup_commander_detail(commander)
|
||||
if commander_detail:
|
||||
eligible_raw = commander_detail.get("eligible_faces")
|
||||
eligible_faces = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else []
|
||||
if eligible_faces:
|
||||
norm_input = str(commander).strip().casefold()
|
||||
eligible_norms = [face.casefold() for face in eligible_faces]
|
||||
if norm_input not in eligible_norms:
|
||||
suggested = eligible_faces[0]
|
||||
primary_face = str(commander_detail.get("primary_face") or commander_detail.get("name") or commander).strip()
|
||||
faces_str = ", ".join(f"'{face}'" for face in eligible_faces)
|
||||
error_msg = (
|
||||
f"'{primary_face or commander}' can't lead a deck. Use {faces_str} as the commander instead. "
|
||||
"We've updated the commander field for you."
|
||||
)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"error": error_msg,
|
||||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"form": _form_state(suggested),
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
# Normalize and validate commander selection (best-effort via orchestrator)
|
||||
sel = orch.commander_select(commander)
|
||||
if not sel.get("ok"):
|
||||
|
|
@ -578,23 +676,7 @@ async def build_new_submit(
|
|||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"form": {
|
||||
"name": name,
|
||||
"commander": commander,
|
||||
"primary_tag": primary_tag or "",
|
||||
"secondary_tag": secondary_tag or "",
|
||||
"tertiary_tag": tertiary_tag or "",
|
||||
"tag_mode": tag_mode or "AND",
|
||||
"bracket": bracket,
|
||||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"include_cards": include_cards or "",
|
||||
"exclude_cards": exclude_cards or "",
|
||||
"enforcement_mode": enforcement_mode or "warn",
|
||||
"allow_illegal": bool(allow_illegal),
|
||||
"fuzzy_matching": bool(fuzzy_matching),
|
||||
}
|
||||
"form": _form_state(commander),
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -654,6 +736,18 @@ async def build_new_submit(
|
|||
sess["prefer_combos"] = bool(prefer_combos)
|
||||
except Exception:
|
||||
sess["prefer_combos"] = False
|
||||
try:
|
||||
sess["use_owned_only"] = bool(use_owned_only)
|
||||
except Exception:
|
||||
sess["use_owned_only"] = False
|
||||
try:
|
||||
sess["prefer_owned"] = bool(prefer_owned)
|
||||
except Exception:
|
||||
sess["prefer_owned"] = False
|
||||
try:
|
||||
sess["swap_mdfc_basics"] = bool(swap_mdfc_basics)
|
||||
except Exception:
|
||||
sess["swap_mdfc_basics"] = False
|
||||
# Combos config from modal
|
||||
try:
|
||||
if combo_count is not None:
|
||||
|
|
@ -1267,6 +1361,9 @@ async def build_step3_submit(
|
|||
"labels": labels,
|
||||
"values": submitted,
|
||||
"commander": sess.get("commander"),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -1313,6 +1410,7 @@ async def build_step4_get(request: Request) -> HTMLResponse:
|
|||
"commander": commander,
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -1485,6 +1583,7 @@ async def build_toggle_owned_review(
|
|||
request: Request,
|
||||
use_owned_only: str | None = Form(None),
|
||||
prefer_owned: str | None = Form(None),
|
||||
swap_mdfc_basics: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -1492,8 +1591,10 @@ async def build_toggle_owned_review(
|
|||
sess["last_step"] = 4
|
||||
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
|
||||
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
|
||||
swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False
|
||||
sess["use_owned_only"] = only_val
|
||||
sess["prefer_owned"] = pref_val
|
||||
sess["swap_mdfc_basics"] = swap_val
|
||||
# Do not touch build_ctx here; user hasn't started the build yet from review
|
||||
labels = orch.ideal_labels()
|
||||
values = sess.get("ideals") or orch.ideal_defaults()
|
||||
|
|
@ -1507,6 +1608,7 @@ async def build_toggle_owned_review(
|
|||
"commander": commander,
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -2888,6 +2990,7 @@ async def build_permalink(request: Request):
|
|||
"flags": {
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
},
|
||||
"locks": list(sess.get("locks", [])),
|
||||
}
|
||||
|
|
@ -2974,6 +3077,7 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
flags = data.get("flags") or {}
|
||||
sess["use_owned_only"] = bool(flags.get("owned_only"))
|
||||
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
|
||||
sess["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics"))
|
||||
sess["locks"] = list(data.get("locks", []))
|
||||
# Optional random build rehydration
|
||||
try:
|
||||
|
|
@ -3037,6 +3141,7 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
"commander": sess.get("commander"),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
"locks_restored": locks_restored,
|
||||
})
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
|
|||
|
|
@ -528,3 +528,13 @@ async def commanders_index(
|
|||
except Exception:
|
||||
pass
|
||||
return templates.TemplateResponse(template_name, context)
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def commanders_index_alias(
|
||||
request: Request,
|
||||
q: str | None = Query(default=None, alias="q"),
|
||||
theme: str | None = Query(default=None, alias="theme"),
|
||||
color: str | None = Query(default=None, alias="color"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
return await commanders_index(request, q=q, theme=theme, color=color, page=page)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
|
|||
"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")),
|
||||
"swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")),
|
||||
}
|
||||
if include_name:
|
||||
ctx["name"] = sess.get("custom_export_base")
|
||||
|
|
@ -85,6 +86,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
include_cards=sess.get("include_cards"),
|
||||
exclude_cards=sess.get("exclude_cards"),
|
||||
swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")),
|
||||
)
|
||||
if set_on_session:
|
||||
sess["build_ctx"] = ctx
|
||||
|
|
|
|||
|
|
@ -1847,6 +1847,7 @@ def start_build_ctx(
|
|||
combo_balance: str | None = None,
|
||||
include_cards: List[str] | None = None,
|
||||
exclude_cards: List[str] | None = None,
|
||||
swap_mdfc_basics: bool | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
|
@ -1914,6 +1915,11 @@ def start_build_ctx(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
b.swap_mdfc_basics = bool(swap_mdfc_basics)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Data load
|
||||
b.determine_color_identity()
|
||||
b.setup_dataframes()
|
||||
|
|
@ -1980,6 +1986,7 @@ def start_build_ctx(
|
|||
"history": [], # list of {i, key, label, snapshot}
|
||||
"locks": {str(n).strip().lower() for n in (locks or []) if str(n).strip()},
|
||||
"custom_export_base": str(custom_export_base).strip() if isinstance(custom_export_base, str) and custom_export_base.strip() else None,
|
||||
"swap_mdfc_basics": bool(swap_mdfc_basics),
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
|
|
|||
|
|
@ -662,7 +662,7 @@
|
|||
window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); };
|
||||
window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; };
|
||||
function scan(){
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').forEach(ensureButton);
|
||||
}
|
||||
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
|
|
@ -1206,9 +1206,9 @@
|
|||
if(!el) return null;
|
||||
// If inside flip button
|
||||
var btn = el.closest && el.closest('.dfc-toggle');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(container) return container;
|
||||
// Image-based detection (any card image carrying data-card-name)
|
||||
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
|
||||
|
|
@ -1264,12 +1264,12 @@
|
|||
window.hoverShowByName = function(name){
|
||||
try {
|
||||
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
} catch(_) {}
|
||||
};
|
||||
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
|
||||
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
|
||||
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell'); if(!next) hide(); });
|
||||
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
|
||||
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); if(!next) hide(); });
|
||||
document.addEventListener('keydown', function(e){ if(e.key==='Escape') hide(); });
|
||||
// Compact mode event listener
|
||||
document.addEventListener('mtg:hoverCompactToggle', function(){ panel.classList.toggle('compact-img', !!window.__hoverCompactMode); });
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
{% if candidates and candidates|length %}
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0; display:grid; gap:.25rem;" role="listbox" aria-label="Commander suggestions" tabindex="-1">
|
||||
{% for name, score, colors in candidates %}
|
||||
{% for cand in candidates %}
|
||||
<li>
|
||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ name|e }}"
|
||||
hx-get="/build/new/inspect?name={{ name|urlencode }}"
|
||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ cand.value|e }}" data-display="{{ cand.display|e }}"
|
||||
hx-get="/build/new/inspect?name={{ cand.display|urlencode }}"
|
||||
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var n=this.getAttribute('data-name')||''; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=n; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=n; } }catch(_){ } }).call(this)">
|
||||
{{ name }}
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var preferred=this.getAttribute('data-name')||''; var displayed=this.getAttribute('data-display')||preferred; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=preferred; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} try{ ci.dispatchEvent(new Event('input', { bubbles: true })); }catch(_){ } } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=displayed; } }catch(_){ } }).call(this)">
|
||||
{{ cand.display }}
|
||||
{% if cand.warning %}
|
||||
<span aria-hidden="true" style="margin-left:.35rem; font-size:11px; color:#facc15;">⚠</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% if cand.warning %}
|
||||
<div class="muted" style="font-size:11px; margin:.25rem 0 0 .5rem; color:#facc15;" role="note">⚠ {{ cand.warning }}</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@
|
|||
<fieldset>
|
||||
<legend>Preferences</legend>
|
||||
<div style="text-align: left;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.5rem; margin: 0;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" style="margin: 0;" />
|
||||
<div style="margin-bottom: 1rem; display:flex; flex-direction:column; gap:0.75rem;">
|
||||
<label for="pref-combos-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_combos %}checked{% endif %} />
|
||||
<span>Prioritize combos</span>
|
||||
</label>
|
||||
<div id="pref-combos-config" style="margin-top: 0.5rem; margin-left: 1.5rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: 8px; display: none;">
|
||||
|
|
@ -80,12 +80,24 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.5rem; margin: 0;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" style="margin: 0;" />
|
||||
<label for="pref-mc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.enable_multicopy %}checked{% endif %} />
|
||||
<span>Enable Multi-Copy package</span>
|
||||
</label>
|
||||
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.75rem;">
|
||||
<label for="use-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Limit the pool to cards you already own. Cards outside your owned library will be skipped.">
|
||||
<input type="checkbox" name="use_owned_only" id="use-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.use_owned_only %}checked{% endif %} />
|
||||
<span>Use only owned cards</span>
|
||||
</label>
|
||||
<label for="prefer-owned-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="Still allow unowned cards, but rank owned cards higher when choosing picks.">
|
||||
<input type="checkbox" name="prefer_owned" id="prefer-owned-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.prefer_owned %}checked{% endif %} />
|
||||
<span>Prefer owned cards (allow unowned fallback)</span>
|
||||
</label>
|
||||
<label for="swap-mdfc-chk" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; margin:0; width:100%; cursor:pointer; text-align:left;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
|
||||
<input type="checkbox" name="swap_mdfc_basics" id="swap-mdfc-chk" value="1" style="margin:0; cursor:pointer;" {% if form and form.swap_mdfc_basics %}checked{% endif %} />
|
||||
<span>Swap basics for MDFC lands</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@
|
|||
</script>
|
||||
</div>
|
||||
|
||||
{% set exclusion = commander.exclusion if commander is defined and commander.exclusion is defined else None %}
|
||||
{% if exclusion %}
|
||||
{% set eligible_raw = exclusion.eligible_faces if exclusion.eligible_faces is defined else [] %}
|
||||
{% set eligible_list = eligible_raw if eligible_raw is iterable else [] %}
|
||||
{% set eligible_lower = eligible_list | map('lower') | list %}
|
||||
{% set current_lower = commander.name|lower %}
|
||||
{% if eligible_list and (current_lower not in eligible_lower or exclusion.reason == 'secondary_face_only') %}
|
||||
<div class="muted" style="font-size:12px; margin-top:.35rem; color:#facc15;" role="note">
|
||||
{% if eligible_list|length == 1 %}
|
||||
⚠ This commander only works from '{{ eligible_list[0] }}'.
|
||||
{% if exclusion.primary_face and exclusion.primary_face|lower != eligible_list[0]|lower %}
|
||||
Front face '{{ exclusion.primary_face }}' can't lead a deck.
|
||||
{% endif %}
|
||||
We'll build using the supported face automatically.
|
||||
{% else %}
|
||||
⚠ This commander only works from these faces: {{ eligible_list | join(', ') }}. We'll build using the supported faces automatically.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
{% if tags and tags|length %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@
|
|||
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Prefer owned cards (allow unowned fallback)
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem;" title="When enabled, modal DFC lands will replace a matching basic land as they are added so land counts stay level without manual trims.">
|
||||
<input type="checkbox" name="swap_mdfc_basics" value="1" {% if swap_mdfc_basics %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Swap basics for MDFC lands
|
||||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>
|
||||
|
|
|
|||
|
|
@ -74,9 +74,10 @@
|
|||
<p>Tags: {{ deck_theme_tags|default([])|join(', ') }}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||||
<div>MDFC swap: <strong>{{ 'On' if swap_mdfc_basics else 'Off' }}</strong></div>
|
||||
</div>
|
||||
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
.commander-list { display:flex; flex-direction:column; gap:1rem; margin-top:.5rem; }
|
||||
|
||||
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; position:relative; }
|
||||
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
|
||||
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
|
||||
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{# Commander row partial fed by CommanderView entries #}
|
||||
{% from "partials/_macros.html" import color_identity %}
|
||||
{% set record = entry.record %}
|
||||
{% set display_label = record.name if '//' in record.name else record.display_name %}
|
||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||
<div class="commander-thumb">
|
||||
{% set small = record.image_small_url or record.image_normal_url %}
|
||||
|
|
@ -12,12 +13,13 @@
|
|||
loading="lazy"
|
||||
decoding="async"
|
||||
data-card-name="{{ record.display_name }}"
|
||||
data-original-name="{{ record.name }}"
|
||||
data-hover-simple="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="commander-main">
|
||||
<div class="commander-header">
|
||||
<h3 class="commander-name">{{ record.display_name }}</h3>
|
||||
<h3 class="commander-name">{{ display_label }}</h3>
|
||||
{{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }}
|
||||
</div>
|
||||
<p class="commander-context muted">{{ record.type_line or 'Legendary Creature' }}</p>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,62 @@
|
|||
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
|
||||
{% set colors = merge_summary.get('colors') if merge_summary else {} %}
|
||||
{% if colors %}
|
||||
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:13px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--border); text-align:left;">
|
||||
<th style="padding:.35rem .25rem;">Color</th>
|
||||
<th style="padding:.35rem .25rem;">Groups merged</th>
|
||||
<th style="padding:.35rem .25rem;">Faces dropped</th>
|
||||
<th style="padding:.35rem .25rem;">Multi-face rows</th>
|
||||
<th style="padding:.35rem .25rem;">Latest entries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for color, payload in colors.items()|dictsort %}
|
||||
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
|
||||
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
|
||||
<td style="padding:.35rem .25rem;">
|
||||
{% set entries = payload.entries or [] %}
|
||||
{% if entries %}
|
||||
<details>
|
||||
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
|
||||
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
|
||||
{% for entry in entries %}
|
||||
{% if loop.index0 < 5 %}
|
||||
<li style="margin-bottom:.25rem;">
|
||||
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
|
||||
</li>
|
||||
{% elif loop.index0 == 5 %}
|
||||
<li style="font-size:11px; opacity:.75;">… {{ entries|length - 5 }} more entries</li>
|
||||
{% break %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% else %}
|
||||
<span class="muted">No groups recorded</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
|
||||
{% endif %}
|
||||
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
|
||||
</div>
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Performance (local)</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
|
||||
|
|
@ -193,6 +249,71 @@
|
|||
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
|
||||
}
|
||||
loadTokenStats();
|
||||
var dfcMetricsEl = document.getElementById('dfcMetrics');
|
||||
function renderDfcMetrics(payload){
|
||||
if (!dfcMetricsEl) return;
|
||||
try {
|
||||
if (!payload || payload.ok !== true) {
|
||||
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
|
||||
return;
|
||||
}
|
||||
var metrics = payload.metrics || {};
|
||||
var html = '';
|
||||
html += '<div><strong>Deck summaries observed:</strong> ' + String(metrics.total_builds || 0) + '</div>';
|
||||
var withDfc = Number(metrics.builds_with_mdfc || 0);
|
||||
var share = metrics.build_share != null ? Number(metrics.build_share) : null;
|
||||
if (!Number.isNaN(share) && share !== null) {
|
||||
share = (share * 100).toFixed(1);
|
||||
} else {
|
||||
share = null;
|
||||
}
|
||||
html += '<div><strong>With MDFCs:</strong> ' + String(withDfc);
|
||||
if (share !== null) {
|
||||
html += ' (' + share + '%)';
|
||||
}
|
||||
html += '</div>';
|
||||
var totalLands = Number(metrics.total_mdfc_lands || 0);
|
||||
var avg = metrics.avg_mdfc_lands != null ? Number(metrics.avg_mdfc_lands) : null;
|
||||
html += '<div><strong>Total MDFC lands:</strong> ' + String(totalLands);
|
||||
if (avg !== null && !Number.isNaN(avg)) {
|
||||
html += ' (avg ' + avg.toFixed(2) + ')';
|
||||
}
|
||||
html += '</div>';
|
||||
var top = metrics.top_cards || {};
|
||||
var topKeys = Object.keys(top);
|
||||
if (topKeys.length) {
|
||||
var items = topKeys.slice(0, 5).map(function(name){
|
||||
return name + ' (' + String(top[name]) + ')';
|
||||
});
|
||||
html += '<div style="font-size:11px;">Top MDFC sources: ' + items.join(', ') + '</div>';
|
||||
}
|
||||
var last = metrics.last_summary || {};
|
||||
if (typeof last.dfc_lands !== 'undefined') {
|
||||
html += '<div style="font-size:11px; margin-top:0.25rem;">Last summary: ' + String(last.dfc_lands || 0) + ' MDFC lands · total with MDFCs ' + String(last.with_dfc || 0) + '</div>';
|
||||
}
|
||||
if (metrics.last_updated) {
|
||||
html += '<div style="font-size:11px;">Updated: ' + String(metrics.last_updated) + '</div>';
|
||||
}
|
||||
dfcMetricsEl.innerHTML = html;
|
||||
} catch (_){
|
||||
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
|
||||
}
|
||||
}
|
||||
function loadDfcMetrics(){
|
||||
if (!dfcMetricsEl) return;
|
||||
dfcMetricsEl.textContent = 'Loading MDFC metrics…';
|
||||
fetch('/status/dfc_metrics', { cache: 'no-store' })
|
||||
.then(function(resp){
|
||||
if (resp.status === 404) {
|
||||
dfcMetricsEl.textContent = 'Diagnostics disabled (metrics unavailable)';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function(data){ if (data) renderDfcMetrics(data); })
|
||||
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
|
||||
}
|
||||
loadDfcMetrics();
|
||||
// Theme status and reset
|
||||
try{
|
||||
var tEl = document.getElementById('themeSummary');
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
|
||||
.dfc-thumb-badge { position:absolute; bottom:8px; left:6px; background:rgba(15,23,42,.92); border:1px solid #34d399; color:#bbf7d0; border-radius:12px; font-size:11px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.dfc-thumb-badge.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
|
|
@ -47,8 +49,11 @@
|
|||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.list-row .flip-slot { min-width:2.4em; display:flex; justify-content:center; align-items:center; }
|
||||
.list-row .flip-slot { min-width:2.4em; display:flex; justify-content:flex-start; align-items:center; }
|
||||
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
|
||||
.dfc-land-chip { display:inline-flex; align-items:center; gap:.25rem; padding:2px 6px; border-radius:999px; font-size:11px; font-weight:600; background:#0f172a; border:1px solid #334155; color:#e5e7eb; line-height:1; }
|
||||
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
|
||||
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
|
||||
</style>
|
||||
<div class="list-grid">
|
||||
{% for c in clist %}
|
||||
|
|
@ -69,7 +74,11 @@
|
|||
<span class="count">{{ cnt }}</span>
|
||||
<span class="times">x</span>
|
||||
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
|
||||
<span class="flip-slot" aria-hidden="true"></span>
|
||||
<span class="flip-slot" aria-hidden="true">
|
||||
{% if c.dfc_land %}
|
||||
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
@ -106,6 +115,9 @@
|
|||
sizes="(max-width: 1200px) 160px, 240px" />
|
||||
<div class="count-badge">{{ cnt }}x</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
{% if c.dfc_land %}
|
||||
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -122,6 +134,60 @@
|
|||
|
||||
<!-- Deck Summary initializer script moved below markup for proper element availability -->
|
||||
|
||||
<!-- Land Summary -->
|
||||
{% set land = summary.land_summary if summary else None %}
|
||||
{% if land %}
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Land Summary</h5>
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.35rem;">
|
||||
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-start;">
|
||||
<div class="muted">Traditional land slots: <strong>{{ land.traditional or 0 }}</strong></div>
|
||||
<div class="muted">MDFC land additions: <strong>{{ land.dfc_lands or 0 }}</strong></div>
|
||||
<div class="muted">Total with MDFCs: <strong>{{ land.with_dfc or land.traditional or 0 }}</strong></div>
|
||||
</div>
|
||||
{% if land.dfc_cards %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>MDFC mana sources ({{ land.dfc_cards|length }})</summary>
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
{% for card in land.dfc_cards %}
|
||||
{% set extra = card.adds_extra_land or card.counts_as_extra %}
|
||||
{% set colors = card.colors or [] %}
|
||||
<li class="muted" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:flex-start;">
|
||||
<span class="chip"><span class="dot" style="background:#10b981;"></span> {{ card.name }} ×{{ card.count or 1 }}</span>
|
||||
<span>Colors: {{ colors|join(', ') if colors else '–' }}</span>
|
||||
{% if extra %}
|
||||
<span class="chip" style="background:#0f172a; border-color:#34d399; color:#a7f3d0;">{{ card.note or 'Adds extra land slot' }}</span>
|
||||
{% else %}
|
||||
<span class="chip" style="background:#111827; border-color:#60a5fa; color:#bfdbfe;">{{ card.note or 'Counts as land slot' }}</span>
|
||||
{% endif %}
|
||||
{% if card.faces %}
|
||||
<ul style="list-style:none; padding:0; margin:.2rem 0 0; display:grid; gap:.15rem; flex:1 0 100%;">
|
||||
{% for face in card.faces %}
|
||||
{% set face_name = face.get('face') or face.get('faceName') or 'Face' %}
|
||||
{% set face_type = face.get('type') or '–' %}
|
||||
{% set mana_cost = face.get('mana_cost') %}
|
||||
{% set mana_value = face.get('mana_value') %}
|
||||
{% set produces = face.get('produces_mana') %}
|
||||
<li style="font-size:0.85rem; color:#e5e7eb; opacity:.85;">
|
||||
<span>{{ face_name }}</span>
|
||||
<span>— {{ face_type }}</span>
|
||||
{% if mana_cost %}<span>• Mana Cost: {{ mana_cost }}</span>{% endif %}
|
||||
{% if mana_value is not none %}<span>• MV: {{ mana_value }}</span>{% endif %}
|
||||
{% if produces %}<span>• Produces mana</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Overview</h5>
|
||||
|
|
@ -144,7 +210,11 @@
|
|||
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in c_cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %}
|
||||
{% if c.dfc %}
|
||||
{% set label = label ~ ' (DFC)' %}
|
||||
{% endif %}
|
||||
{% set _ = parts.append(label) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue