Bracket enforcement + inline gating; global pool prune; compliance JSON artifacts; UI combos gating; compose envs consolidated; fix YAML; bump version to 2.2.5

This commit is contained in:
mwisnowski 2025-09-03 18:00:06 -07:00
parent 42c8fc9f9e
commit 4e03997923
32 changed files with 2819 additions and 125 deletions

View file

@ -133,7 +133,11 @@ async def build_index(request: Request) -> HTMLResponse:
return resp
# --- Multi-copy archetype suggestion modal (Web-first flow) ---
# Support /build without trailing slash
@router.get("", response_class=HTMLResponse)
async def build_index_alias(request: Request) -> HTMLResponse:
return await build_index(request)
@router.get("/multicopy/check", response_class=HTMLResponse)
async def multicopy_check(request: Request) -> HTMLResponse:
@ -322,12 +326,20 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else []
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {}
# 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
try:
is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
ctx = {
"request": request,
"commander": {"name": info["name"]},
"tags": tags,
"recommended": recommended,
"recommended_reasons": recommended_reasons,
"gc_commander": is_gc,
"brackets": orch.bracket_options(),
}
return templates.TemplateResponse("build/_new_deck_tags.html", ctx)
@ -455,6 +467,17 @@ async def build_new_submit(
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Enforce GC bracket restriction before saving session (silently coerce to 3)
try:
is_gc = bool((sel.get("name") or commander) in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
if is_gc:
try:
if int(bracket) < 3:
bracket = 3
except Exception:
bracket = 3
# Save to session
sess["commander"] = sel.get("name") or commander
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
@ -654,6 +677,8 @@ async def build_step1_search(
"recommended": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(),
"gc_commander": (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])),
"selected_bracket": (3 if (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) else None),
"clear_persisted": True,
},
)
@ -712,6 +737,12 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
except Exception:
pass
sess["last_step"] = 2
# Determine if commander is a Game Changer to drive bracket UI hiding
is_gc = False
try:
is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
resp = templates.TemplateResponse(
"build/_step2.html",
{
@ -721,6 +752,8 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
"recommended": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(),
"gc_commander": is_gc,
"selected_bracket": (3 if is_gc else None),
# Signal that this navigation came from a fresh commander confirmation,
# so the Step 2 UI should clear any localStorage theme persistence.
"clear_persisted": True,
@ -830,6 +863,20 @@ async def build_step2_get(request: Request) -> HTMLResponse:
return resp
tags = orch.tags_for_commander(commander)
selected = sess.get("tags", [])
# Determine if the selected commander is considered a Game Changer (affects bracket choices)
is_gc = False
try:
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
# Selected bracket: if GC commander and bracket < 3 or missing, default to 3
sel_br = sess.get("bracket")
try:
sel_br = int(sel_br) if sel_br is not None else None
except Exception:
sel_br = None
if is_gc and (sel_br is None or int(sel_br) < 3):
sel_br = 3
resp = templates.TemplateResponse(
"build/_step2.html",
{
@ -842,8 +889,9 @@ async def build_step2_get(request: Request) -> HTMLResponse:
"primary_tag": selected[0] if len(selected) > 0 else "",
"secondary_tag": selected[1] if len(selected) > 1 else "",
"tertiary_tag": selected[2] if len(selected) > 2 else "",
"selected_bracket": sess.get("bracket"),
"selected_bracket": sel_br,
"tag_mode": sess.get("tag_mode", "AND"),
"gc_commander": is_gc,
# If there are no server-side tags for this commander, let the client clear any persisted ones
# to avoid themes sticking between fresh runs.
"clear_persisted": False if selected else True,
@ -869,6 +917,18 @@ async def build_step2_submit(
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
# Compute GC flag to hide disallowed brackets on error
is_gc = False
try:
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
try:
sel_br = int(bracket) if bracket is not None else None
except Exception:
sel_br = None
if is_gc and (sel_br is None or sel_br < 3):
sel_br = 3
resp = templates.TemplateResponse(
"build/_step2.html",
{
@ -882,13 +942,26 @@ async def build_step2_submit(
"primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_tag or "",
"selected_bracket": int(bracket) if bracket is not None else None,
"selected_bracket": sel_br,
"tag_mode": (tag_mode or "AND"),
"gc_commander": is_gc,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed)
try:
is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', []))
except Exception:
is_gc = False
if is_gc:
try:
if int(bracket) < 3:
bracket = 3 # coerce silently
except Exception:
bracket = 3
# Save selection to session (basic MVP; real build will use this later)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
@ -1339,6 +1412,7 @@ 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
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx2)
@ -1443,6 +1517,7 @@ async def build_step5_start(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
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx)
@ -1590,9 +1665,17 @@ async def build_lock_toggle(request: Request, name: str = Form(...), locked: str
@router.get("/alternatives", response_class=HTMLResponse)
async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> HTMLResponse:
"""Suggest alternative cards for a given card name using tag overlap and availability.
"""Suggest alternative cards for a given card name, preferring role-specific pools.
Returns a small HTML snippet listing up to ~10 alternatives with Replace buttons.
Strategy:
1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint.
2) Build a candidate pool from the combined DataFrame using the same filters as the build phase
for that role (ramp/removal/wipes/card_advantage/protection).
3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort
by edhrecRank/manaValue. Apply owned-only filter if requested.
4) Fall back to tag-overlap similarity when role cannot be determined or data is missing.
Returns an HTML partial listing up to ~10 alternatives with Replace buttons.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
@ -1606,45 +1689,212 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
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()
name_disp = str(name).strip()
name_l = name_disp.lower()
commander_l = str((sess.get("commander") or "")).strip().lower()
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
# Check cache: key = (seed, commander, require_owned)
cache_key = (name_l, commander_l, require_owned)
# Exclusions from prior inline replacements
alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
alts_exclude_v = int(sess.get("alts_exclude_v") or 0)
# Resolve role from stage hint or current library entry
stage_hint = (stage or "").strip().lower()
stage_map = {
"ramp": "ramp",
"removal": "removal",
"wipes": "wipe",
"wipe": "wipe",
"board_wipe": "wipe",
"card_advantage": "card_advantage",
"draw": "card_advantage",
"protection": "protection",
# Additional mappings for creature stages
"creature": "creature",
"creatures": "creature",
"primary": "creature",
"secondary": "creature",
}
hinted_role = stage_map.get(stage_hint) if stage_hint else None
lib = getattr(b, "card_library", {}) or {}
# Case-insensitive lookup in deck library
lib_key = None
try:
if name_disp in lib:
lib_key = name_disp
else:
lm = {str(k).strip().lower(): k for k in lib.keys()}
lib_key = lm.get(name_l)
except Exception:
lib_key = None
entry = lib.get(lib_key) if lib_key else None
role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None)
if isinstance(role, str):
role = role.strip().lower()
# Build role-specific pool from combined DataFrame
items: list[dict] = []
used_role = role if isinstance(role, str) and role else None
df = getattr(b, "_combined_cards_df", None)
# Compute current deck fingerprint to avoid stale cached alternatives after stage changes
in_deck: set[str] = builder_present_names(b)
try:
import hashlib as _hl
deck_fp = _hl.md5(
("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8")
).hexdigest()[:8]
except Exception:
deck_fp = str(len(in_deck))
# Use a cache key that includes the exclusions version and deck fingerprint
cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp)
cached = _alts_get_cached(cache_key)
if cached is not None:
return HTMLResponse(cached)
# Tags index provides quick similarity candidates
def _render_and_cache(_items: list[dict]):
html_str = templates.get_template("build/_alternatives.html").render({
"request": request,
"name": name_disp,
"require_owned": require_owned,
"items": _items,
})
try:
_alts_set_cached(cache_key, html_str)
except Exception:
pass
return HTMLResponse(html_str)
# Helper: map display names
def _display_map_for(lower_pool: set[str]) -> dict[str, str]:
try:
return builder_display_map(b, lower_pool) # type: ignore[arg-type]
except Exception:
return {nm: nm for nm in lower_pool}
# Common exclusions
# in_deck already computed above
def _exclude(df0):
out = df0.copy()
if "name" in out.columns:
out["_lname"] = out["name"].astype(str).str.strip().str.lower()
mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set()))
out = out[mask]
return out
# If we have data and a recognized role, mirror the phase logic
if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature"}):
pool = df.copy()
try:
pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell)
except Exception:
# best-effort normalize
pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else [])
# Exclude lands for all these roles
if "type" in pool.columns:
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
# Exclude commander explicitly
if "name" in pool.columns and commander_l:
pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l]
# Role-specific filter
def _is_wipe(tags: list[str]) -> bool:
return any(("board wipe" in t) or ("mass removal" in t) for t in tags)
def _is_removal(tags: list[str]) -> bool:
return any(("removal" in t) or ("spot removal" in t) for t in tags)
def _is_draw(tags: list[str]) -> bool:
return any(("draw" in t) or ("card advantage" in t) for t in tags)
def _matches_selected(tags: list[str]) -> bool:
try:
sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()]
if not sel:
return True
st = set(sel)
return any(any(s in t for s in st) for t in tags)
except Exception:
return True
if used_role == "ramp":
pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))]
elif used_role == "removal":
pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)]
elif used_role == "wipe":
pool = pool[pool["_ltags"].apply(_is_wipe)]
elif used_role == "card_advantage":
pool = pool[pool["_ltags"].apply(_is_draw)]
elif used_role == "protection":
pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))]
elif used_role == "creature":
# Keep only creatures; bias toward selected theme tags when available
if "type" in pool.columns:
pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)]
try:
pool = pool[pool["_ltags"].apply(_matches_selected)]
except Exception:
pass
# Sort by priority like the builder
try:
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type]
except Exception:
pass
# Exclusions and ownership
pool = _exclude(pool)
# Prefer-owned bias: stable reorder to put owned first if user prefers owned
try:
if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None):
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())})
except Exception:
pass
# Build final items
lower_pool: list[str] = []
try:
lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist()
except Exception:
lower_pool = []
display_map = _display_map_for(set(lower_pool))
for nm_l in lower_pool:
is_owned = (nm_l in owned_set)
if require_owned and not is_owned:
continue
# Extra safety: exclude the seed card or anything already in deck
if nm_l == name_l or (in_deck and nm_l in in_deck):
continue
items.append({
"name": display_map.get(nm_l, nm_l),
"name_lower": nm_l,
"owned": is_owned,
"tags": [], # can be filled from index below if needed
})
if len(items) >= 10:
break
# If we collected role-aware items, render
if items:
return _render_and_cache(items)
# Fallback: tag-similarity suggestions (previous behavior)
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
seed_tags = set(tags_idx.get(name_l) or [])
# Fallback: use the card's role/sub-role from current library if available
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] = builder_present_names(b)
# Build candidate pool from tags overlap
all_names = set(tags_idx.keys())
candidates: list[tuple[str, int]] = [] # (name, score)
for nm in all_names:
if nm == name_l:
continue
# Exclude commander and any names we believe are already in the current deck
if commander_l and nm == commander_l:
continue
if in_deck and nm in in_deck:
continue
# Also exclude any card currently locked (these are intended to be kept)
if locked_set and nm in locked_set:
continue
if nm in alts_exclude:
continue
tgs = set(tags_idx.get(nm) or [])
score = len(seed_tags & tgs)
if score <= 0:
continue
candidates.append((nm, score))
# If no tag-based candidates, try using same trigger tag if present
if not candidates and isinstance(lib_entry, dict):
# If no tag-based candidates, try shared trigger tag from library entry
if not candidates and isinstance(entry, dict):
try:
trig = str(lib_entry.get("TriggerTag") or "").strip().lower()
trig = str(entry.get("TriggerTag") or "").strip().lower()
except Exception:
trig = ""
if trig:
@ -1655,15 +1905,11 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
continue
if trig in {str(t).strip().lower() for t in (tglist or [])}:
candidates.append((nm, 1))
# Sort by score desc, then owned-first, then name asc
def _owned(nm: str) -> bool:
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
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] = []
display_map = _display_map_for(pool_lower)
seen = set()
for nm, score in candidates:
if nm in seen:
@ -1672,36 +1918,34 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
is_owned = (nm in owned_set)
if require_owned and not is_owned:
continue
disp = display_map.get(nm, nm)
items.append({
"name": disp,
"name": display_map.get(nm, nm),
"name_lower": nm,
"owned": is_owned,
"tags": list(tags_idx.get(nm) or []),
})
if len(items) >= 10:
break
# 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)
return _render_and_cache(items)
except Exception as e:
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
@router.post("/replace", response_class=HTMLResponse)
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> HTMLResponse:
"""Update locks to prefer `new` over `old` and prompt the user to rerun the stage with Replace enabled.
"""Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives.
This does not immediately mutate the builder; users should click Rerun Stage (Replace: On) to apply.
Falls back to lock-and-rerun guidance if no active builder is present.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
o_disp = str(old).strip()
n_disp = str(new).strip()
o = o_disp.lower()
n = n_disp.lower()
# Maintain locks to bias future picks and enforcement
locks = set(sess.get("locks", []))
o = str(old).strip().lower()
n = str(new).strip().lower()
# Always ensure new is locked and old is unlocked
locks.discard(o)
locks.add(n)
sess["locks"] = list(locks)
@ -1710,36 +1954,213 @@ async def build_replace(request: Request, old: str = Form(...), new: str = Form(
sess["last_replace"] = {"old": o, "new": n}
except Exception:
pass
if sess.get("build_ctx"):
ctx = sess.get("build_ctx") or {}
try:
ctx["locks"] = {str(x) for x in locks}
except Exception:
pass
# Record preferred replacements
try:
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
if not isinstance(pref, dict):
pref = {}
ctx["preferred_replacements"] = pref
pref[o] = n
except Exception:
pass
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if b is not None:
try:
sess["build_ctx"]["locks"] = {str(x) for x in locks}
lib = getattr(b, "card_library", {}) or {}
# Find the exact key for `old` in a case-insensitive manner
old_key = None
if o_disp in lib:
old_key = o_disp
else:
for k in list(lib.keys()):
if str(k).strip().lower() == o:
old_key = k
break
if old_key is None:
raise KeyError("old card not in deck")
old_info = dict(lib.get(old_key) or {})
role = str(old_info.get("Role") or "").strip()
subrole = str(old_info.get("SubRole") or "").strip()
try:
count = int(old_info.get("Count", 1))
except Exception:
count = 1
# Remove old entry
try:
del lib[old_key]
except Exception:
pass
# Resolve canonical name and info for new
df = getattr(b, "_combined_cards_df", None)
new_key = n_disp
card_type = ""
mana_cost = ""
trigger_tag = str(old_info.get("TriggerTag") or "")
if df is not None:
try:
row = df[df["name"].astype(str).str.strip().str.lower() == n]
if not row.empty:
new_key = str(row.iloc[0]["name"]) or n_disp
card_type = str(row.iloc[0].get("type", row.iloc[0].get("type_line", "")) or "")
mana_cost = str(row.iloc[0].get("mana_cost", row.iloc[0].get("manaCost", "")) or "")
except Exception:
pass
lib[new_key] = {
"Count": count,
"Card Type": card_type,
"Mana Cost": mana_cost,
"Role": role,
"SubRole": subrole,
"AddedBy": "Replace",
"TriggerTag": trigger_tag,
}
# Mirror preferred replacements onto the builder for enforcement
try:
cur = getattr(b, "preferred_replacements", {}) or {}
cur[str(o)] = str(n)
setattr(b, "preferred_replacements", cur)
except Exception:
pass
# Update alternatives exclusion set and bump version to invalidate caches
try:
ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
ex.add(o)
sess["alts_exclude"] = list(ex)
sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1
except Exception:
pass
# Success panel and OOB updates (refresh compliance panel)
# Compute ownership of the new card for UI badge update
is_owned = (n in owned_set_helper())
html = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>Replaced <strong>{o_disp}</strong> with <strong>{new_key}</strong>.</div>'
'<div class="muted" style="margin-top:.35rem;">Compliance panel will refresh.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
'</div>'
)
# Inline mutate the nearest card tile to reflect the new card without a rerun
mutator = """
<script>
(function(){
try{
var panel = document.currentScript && document.currentScript.previousElementSibling && document.currentScript.previousElementSibling.classList && document.currentScript.previousElementSibling.classList.contains('alts') ? document.currentScript.previousElementSibling : null;
if(!panel){ return; }
var oldName = panel.getAttribute('data-old') || '';
var newName = panel.getAttribute('data-new') || '';
var isOwned = panel.getAttribute('data-owned') === '1';
var isLocked = panel.getAttribute('data-locked') === '1';
var tile = panel.closest('.card-tile');
if(!tile) return;
tile.setAttribute('data-card-name', newName);
var img = tile.querySelector('img.card-thumb');
if(img){
var base = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=';
img.src = base + 'normal';
img.setAttribute('srcset',
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=small 160w, ' +
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=normal 488w, ' +
'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(newName) + '&format=image&version=large 672w'
);
img.setAttribute('alt', newName + ' image');
img.setAttribute('data-card-name', newName);
}
var nameEl = tile.querySelector('.name');
if(nameEl){ nameEl.textContent = newName; }
var own = tile.querySelector('.owned-badge');
if(own){
own.textContent = isOwned ? '' : '';
own.title = isOwned ? 'Owned' : 'Not owned';
tile.setAttribute('data-owned', isOwned ? '1' : '0');
}
tile.classList.toggle('locked', isLocked);
var imgBtn = tile.querySelector('.img-btn');
if(imgBtn){
try{
var valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
var obj = JSON.parse(valsAttr.replace(/&quot;/g, '"'));
obj.name = newName;
imgBtn.setAttribute('hx-vals', JSON.stringify(obj));
}catch(e){}
}
var lockBtn = tile.querySelector('.lock-box .btn-lock');
if(lockBtn){
try{
var v = lockBtn.getAttribute('hx-vals') || '{}';
var o = JSON.parse(v.replace(/&quot;/g, '"'));
o.name = newName;
lockBtn.setAttribute('hx-vals', JSON.stringify(o));
}catch(e){}
}
}catch(_){}
})();
</script>
"""
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">Replaced <strong>{o_disp}</strong> → <strong>{new_key}</strong></span>'
f'</div>'
)
# OOB fetch to refresh compliance panel
refresher = (
'<div hx-get="/build/compliance" hx-target="#compliance-panel" hx-swap="outerHTML" '
'hx-trigger="load" hx-swap-oob="true"></div>'
)
# Include data attributes on the panel div for the mutator script
data_owned = '1' if is_owned else '0'
data_locked = '1' if (n in locks) else '0'
prefix = '<div class="alts"'
replacement = (
'<div class="alts" '
+ 'data-old="' + _esc(o_disp) + '" '
+ 'data-new="' + _esc(new_key) + '" '
+ 'data-owned="' + data_owned + '" '
+ 'data-locked="' + data_locked + '"'
)
html = html.replace(prefix, replacement, 1)
return HTMLResponse(html + mutator + chip + refresher)
except Exception:
# Fall back to rerun guidance if inline swap fails
pass
# Return a small confirmation with a shortcut to rerun
# Fallback: advise rerun
hint = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">'
f'<div>Locked <strong>{new}</strong> and unlocked <strong>{old}</strong>.</div>'
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div>'
'<div class="muted" style="margin-top:.35rem;">Now click <em>Rerun Stage</em> with Replace: On to apply this change.</div>'
'<div style="margin-top:.35rem; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">'
'<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
'<input type="hidden" name="show_skipped" value="1" />'
'<button type="submit" class="btn-rerun">Rerun stage</button>'
'</form>'
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">'
f'<input type="hidden" name="old" value="{old}" />'
f'<input type="hidden" name="new" value="{new}" />'
'<button type="submit" class="btn" title="Undo this replace">Undo</button>'
'</form>'
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">'
f'<input type="hidden" name="old" value="{old}" />'
f'<input type="hidden" name="new" value="{new}" />'
'<button type="submit" class="btn" title="Undo this replace">Undo</button>'
'</form>'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>'
'</div>'
)
# Also emit an OOB last-action chip
chip = (
f'<div id="last-action" hx-swap-oob="true">'
f'<span class="chip" title="Click to dismiss">Replaced <strong>{old}</strong> → <strong>{new}</strong></span>'
f'</div>'
)
# Also add old to exclusions and bump version for future alt calls
try:
ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])}
ex.add(o)
sess["alts_exclude"] = list(ex)
sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1
except Exception:
pass
return HTMLResponse(hint + chip)
@ -1804,6 +2225,288 @@ async def build_compare(runA: str, runB: str):
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []})
@router.get("/compliance", response_class=HTMLResponse)
async def build_compliance_panel(request: Request) -> HTMLResponse:
"""Render a live Bracket compliance panel with manual enforcement controls.
Computes compliance against the current builder state without exporting, attaches a non-destructive
enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial.
Returns empty content when no active build context exists.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx") or {}
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if not b:
return HTMLResponse("")
# Compute compliance snapshot in-memory and attach planning preview
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
except Exception:
comp = None
try:
if comp:
from ..services import orchestrator as orch
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
except Exception:
pass
if not comp:
return HTMLResponse("")
# Build flagged metadata (role, owned) for visual tiles and role-aware alternatives
# For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced.
flagged_meta: list[dict] = []
try:
cats = comp.get('categories') or {}
owned_lower = owned_set_helper()
lib = getattr(b, 'card_library', {}) or {}
commander_l = str((sess.get('commander') or '')).strip().lower()
# map category key -> display label
labels = {
'game_changers': 'Game Changers',
'extra_turns': 'Extra Turns',
'mass_land_denial': 'Mass Land Denial',
'tutors_nonland': 'Nonland Tutors',
'two_card_combos': 'Two-Card Combos',
}
seen_lower: set[str] = set()
for key, cat in cats.items():
try:
lim = cat.get('limit')
cnt = int(cat.get('count', 0) or 0)
if lim is None or cnt <= int(lim):
continue
# For two-card combos, split pairs into individual cards and skip commander
if key == 'two_card_combos':
# Prefer the structured combos list to ensure we only expand counted pairs
pairs = []
try:
for p in (comp.get('combos') or []):
if p.get('cheap_early'):
pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip()))
except Exception:
pairs = []
# Fallback to parsing flagged strings like "A + B"
if not pairs:
try:
for s in (cat.get('flagged') or []):
if not isinstance(s, str):
continue
parts = [x.strip() for x in s.split('+') if x and x.strip()]
if len(parts) == 2:
pairs.append((parts[0], parts[1]))
except Exception:
pass
for a, bname in pairs:
for nm in (a, bname):
if not nm:
continue
nm_l = nm.strip().lower()
if nm_l == commander_l:
# Don't prompt replacing the commander
continue
if nm_l in seen_lower:
continue
seen_lower.add(nm_l)
entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {}
role = entry.get('Role') or ''
flagged_meta.append({
'name': nm,
'category': labels.get(key, key.replace('_',' ').title()),
'role': role,
'owned': (nm_l in owned_lower),
})
continue
# Default handling for list/tag categories
names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)]
for nm in names:
nm_l = str(nm).strip().lower()
if nm_l in seen_lower:
continue
seen_lower.add(nm_l)
entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {}
role = entry.get('Role') or ''
flagged_meta.append({
'name': nm,
'category': labels.get(key, key.replace('_',' ').title()),
'role': role,
'owned': (nm_l in owned_lower),
})
except Exception:
continue
except Exception:
flagged_meta = []
# Render partial
ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta}
return templates.TemplateResponse("build/_compliance_panel.html", ctx2)
@router.post("/enforce/apply", response_class=HTMLResponse)
async def build_enforce_apply(request: Request) -> HTMLResponse:
"""Apply bracket enforcement now using current locks as user guidance.
This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5.
"""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Ensure build context exists
ctx = sess.get("build_ctx") or {}
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if not b:
# No active build: show Step 5 with an error
err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Ensure we have a CSV base stem for consistent re-exports
base_stem = None
try:
csv_path = ctx.get("csv_path")
if isinstance(csv_path, str) and csv_path:
import os as _os
base_stem = _os.path.splitext(_os.path.basename(csv_path))[0]
except Exception:
base_stem = None
# If missing, export once to establish base
if not base_stem:
try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
import os as _os
base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0]
# Also produce a text export for completeness
ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined]
except Exception:
base_stem = None
# Add lock placeholders into the library before enforcement so user choices are present
try:
locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
if locks:
df = getattr(b, "_combined_cards_df", None)
lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}
for lname in locks:
if lname in lib_l:
continue
target_name = None
card_type = ''
mana_cost = ''
try:
if df is not None and not df.empty:
row = df[df['name'].astype(str).str.lower() == lname]
if not row.empty:
target_name = str(row.iloc[0]['name'])
card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '')
mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '')
except Exception:
target_name = None
if target_name:
b.card_library[target_name] = {
'Count': 1,
'Card Type': card_type,
'Mana Cost': mana_cost,
'Role': 'Locked',
'SubRole': '',
'AddedBy': 'Lock',
'TriggerTag': '',
}
except Exception:
pass
# Thread preferred replacements from context onto builder so enforcement can honor them
try:
pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None
if isinstance(pref, dict):
setattr(b, 'preferred_replacements', dict(pref))
except Exception:
pass
# Run enforcement + re-exports (tops up to 100 internally)
try:
rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined]
except Exception as e:
err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}")
resp = templates.TemplateResponse("build/_step5.html", err_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Reload compliance JSON and summary
compliance = None
try:
if base_stem:
import os as _os
import json as _json
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
if _os.path.exists(comp_path):
with open(comp_path, 'r', encoding='utf-8') as _cf:
compliance = _json.load(_cf)
except Exception:
compliance = None
# Rebuild Step 5 context (done state)
# Ensure csv/txt paths on ctx reflect current base
try:
import os as _os
ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path")
ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path")
except Exception:
pass
# Compute total_cards
try:
total_cards = 0
for _n, _e in getattr(b, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = None
res = {
"done": True,
"label": "Complete",
"log_delta": "",
"idx": len(ctx.get("stages", []) or []),
"total": len(ctx.get("stages", []) or []),
"csv_path": ctx.get("csv_path"),
"txt_path": ctx.get("txt_path"),
"summary": getattr(b, 'build_deck_summary', lambda: None)(),
"total_cards": total_cards,
"added_total": 0,
"compliance": compliance or rep,
}
page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True)
resp = templates.TemplateResponse("build/_step5.html", page_ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/enforcement", response_class=HTMLResponse)
async def build_enforcement_fullpage(request: Request) -> HTMLResponse:
"""Full-page enforcement review: show compliance panel with swaps and controls."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
ctx = sess.get("build_ctx") or {}
b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None
if not b:
# No active build
base = step5_empty_ctx(request, sess)
resp = templates.TemplateResponse("build/_step5.html", base)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Compute compliance snapshot and attach planning preview
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
except Exception:
comp = None
try:
if comp:
from ..services import orchestrator as orch
comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined]
except Exception:
pass
ctx2 = {"request": request, "compliance": comp}
resp = templates.TemplateResponse("build/enforcement.html", ctx2)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/permalink")
async def build_permalink(request: Request):
"""Return a URL-safe JSON payload representing current run config (basic)."""

View file

@ -118,6 +118,7 @@ def step5_ctx_from_result(
"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,
"compliance": res.get("compliance") if done else None,
"show_skipped": bool(show_skipped),
"total_cards": res.get("total_cards"),
"added_total": res.get("added_total"),
@ -125,6 +126,7 @@ def step5_ctx_from_result(
"clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")),
"gated": bool(res.get("gated")),
}
if extras:
ctx.update(extras)
@ -238,6 +240,15 @@ def builder_present_names(builder: Any) -> set[str]:
'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
):
_add_names(getattr(builder, attr, None))
# Also include names present in the library itself, which is the authoritative deck source post-build
try:
lib = getattr(builder, 'card_library', None)
if isinstance(lib, dict) and lib:
for k in lib.keys():
if isinstance(k, str) and k.strip():
present.add(k.strip().lower())
except Exception:
pass
for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(builder, attr, None)
if isinstance(val, (list, tuple, set)):

View file

@ -14,6 +14,163 @@ import unicodedata
from glob import glob
def _global_prune_disallowed_pool(b: DeckBuilder) -> None:
"""Hard-prune disallowed categories from the working pool based on bracket limits.
This is a defensive, pool-level filter to ensure headless/JSON builds also
honor hard bans (e.g., no Game Changers in brackets 12). It complements
per-stage pre-filters and is safe to apply immediately after dataframes are
set up.
"""
try:
limits = getattr(b, 'bracket_limits', {}) or {}
def _prune_df(df):
try:
if df is None or getattr(df, 'empty', True):
return df
cols = getattr(df, 'columns', [])
name_col = 'name' if 'name' in cols else ('Card Name' if 'Card Name' in cols else None)
if name_col is None:
return df
work = df
# 1) Game Changers: filter by authoritative name list regardless of tag presence
try:
lim_gc = limits.get('game_changers')
if lim_gc is not None and int(lim_gc) == 0 and getattr(bc, 'GAME_CHANGERS', None):
gc_lower = {str(n).strip().lower() for n in getattr(bc, 'GAME_CHANGERS', [])}
work = work[~work[name_col].astype(str).str.lower().isin(gc_lower)]
except Exception:
pass
# 2) Additional categories rely on tags if present; skip if tag column missing
try:
if 'themeTags' in getattr(work, 'columns', []):
# Normalize a lowercase tag list column
from deck_builder import builder_utils as _bu
if '_ltags' not in work.columns:
work = work.copy()
work['_ltags'] = work['themeTags'].apply(_bu.normalize_tag_cell)
def _has_any(lst, needles):
try:
return any(any(nd in t for nd in needles) for t in (lst or []))
except Exception:
return False
# Build disallow map
disallow = {
'extra_turns': (limits.get('extra_turns') is not None and int(limits.get('extra_turns')) == 0),
'mass_land_denial': (limits.get('mass_land_denial') is not None and int(limits.get('mass_land_denial')) == 0),
'tutors_nonland': (limits.get('tutors_nonland') is not None and int(limits.get('tutors_nonland')) == 0),
}
syn = {
'extra_turns': {'bracket:extraturn', 'extra turn', 'extra turns', 'extraturn'},
'mass_land_denial': {'bracket:masslanddenial', 'mass land denial', 'mld', 'masslanddenial'},
'tutors_nonland': {'bracket:tutornonland', 'tutor', 'tutors', 'nonland tutor', 'non-land tutor'},
}
if any(disallow.values()):
mask_keep = [True] * len(work)
tags_series = work['_ltags']
for key, dis in disallow.items():
if not dis:
continue
needles = syn.get(key, set())
drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd))
mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())]
try:
import pandas as _pd # type: ignore
mask_keep = _pd.Series(mask_keep, index=work.index)
except Exception:
pass
work = work[mask_keep]
except Exception:
pass
return work
except Exception:
return df
# Apply to both pools used by phases
try:
b._combined_cards_df = _prune_df(getattr(b, '_combined_cards_df', None))
except Exception:
pass
try:
b._full_cards_df = _prune_df(getattr(b, '_full_cards_df', None))
except Exception:
pass
except Exception:
return
def _attach_enforcement_plan(b: DeckBuilder, comp: Dict[str, Any] | None) -> Dict[str, Any] | None:
"""When compliance FAILs, attach a non-destructive enforcement plan to show swaps in UI.
Builds a list of candidate removals per over-limit category (no mutations), then
attaches comp['enforcement'] with 'swaps' entries of the form
{removed: name, added: None, role: role} and summaries of removed names.
"""
try:
if not isinstance(comp, dict):
return comp
if str(comp.get('overall', 'PASS')).upper() != 'FAIL':
return comp
cats = comp.get('categories') or {}
lib = getattr(b, 'card_library', {}) or {}
# Case-insensitive lookup for library names
lib_lower_to_orig = {str(k).strip().lower(): k for k in lib.keys()}
# Scoring helper mirroring enforcement: worse (higher rank) trimmed first
df = getattr(b, '_combined_cards_df', None)
def _score(name: str) -> tuple[int, float, str]:
try:
if df is not None and not getattr(df, 'empty', True) and 'name' in getattr(df, 'columns', []):
r = df[df['name'].astype(str) == str(name)]
if not r.empty:
rank = int(r.iloc[0].get('edhrecRank') or 10**9)
mv = float(r.iloc[0].get('manaValue') or r.iloc[0].get('cmc') or 0.0)
return (rank, mv, str(name))
except Exception:
pass
return (10**9, 99.0, str(name))
to_remove: list[str] = []
for key in ('game_changers', 'extra_turns', 'mass_land_denial', 'tutors_nonland'):
cat = cats.get(key) or {}
lim = cat.get('limit')
cnt = int(cat.get('count', 0) or 0)
if lim is None or cnt <= int(lim):
continue
flagged = [n for n in (cat.get('flagged') or []) if isinstance(n, str)]
# Map flagged names to the canonical in-deck key (case-insensitive)
present_mapped: list[str] = []
for n in flagged:
n_key = str(n).strip()
if n_key in lib:
present_mapped.append(n_key)
continue
lk = n_key.lower()
if lk in lib_lower_to_orig:
present_mapped.append(lib_lower_to_orig[lk])
present = present_mapped
if not present:
continue
over = cnt - int(lim)
present_sorted = sorted(present, key=_score, reverse=True)
to_remove.extend(present_sorted[:over])
if not to_remove:
return comp
swaps = []
for nm in to_remove:
entry = lib.get(nm) or {}
swaps.append({"removed": nm, "added": None, "role": entry.get('Role')})
enf = comp.setdefault('enforcement', {})
enf['removed'] = list(dict.fromkeys(to_remove))
enf['added'] = []
enf['swaps'] = swaps
return comp
except Exception:
return comp
def commander_names() -> List[str]:
tmp = DeckBuilder()
df = tmp.load_commander_data()
@ -777,6 +934,12 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception:
pass
# Defaults for return payload
csv_path = None
txt_path = None
summary = None
compliance_obj = None
try:
# Provide a no-op input function so any leftover prompts auto-accept defaults
b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True)
@ -844,6 +1007,8 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
try:
b.determine_color_identity()
b.setup_dataframes()
# Global safety prune of disallowed categories (e.g., Game Changers) for headless builds
_global_prune_disallowed_pool(b)
except Exception as e:
out(f"Failed to load color identity/card pool: {e}")
@ -1001,9 +1166,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception as e:
out(f"Post-spell land adjust failed: {e}")
# Reporting/exports
csv_path = None
txt_path = None
# Reporting/exports
try:
if hasattr(b, 'run_reporting_phase'):
b.run_reporting_phase()
@ -1031,11 +1194,38 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
b._display_txt_contents(txt_path)
except Exception:
pass
# Compute bracket compliance and save JSON alongside exports
try:
if hasattr(b, 'compute_and_print_compliance'):
rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
# Attach planning preview (no mutation) and only auto-enforce if explicitly enabled
rep0 = _attach_enforcement_plan(b, rep0)
try:
import os as __os
_auto = str(__os.getenv('WEB_AUTO_ENFORCE', '0')).strip().lower() in {"1","true","yes","on"}
except Exception:
_auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined]
except Exception:
pass
# Load compliance JSON for UI consumption
try:
# Prefer the in-memory report (with enforcement plan) when available
if rep0 is not None:
compliance_obj = rep0
else:
import json as _json
comp_path = _os.path.join('deck_files', f"{base}_compliance.json")
if _os.path.exists(comp_path):
with open(comp_path, 'r', encoding='utf-8') as _cf:
compliance_obj = _json.load(_cf)
except Exception:
compliance_obj = None
except Exception as e:
out(f"Text export failed: {e}")
# Build structured summary for UI
summary = None
try:
if hasattr(b, 'build_deck_summary'):
summary = b.build_deck_summary() # type: ignore[attr-defined]
@ -1067,7 +1257,8 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
_json.dump(payload, f, ensure_ascii=False, indent=2)
except Exception:
pass
return {"ok": True, "log": "\n".join(logs), "csv_path": csv_path, "txt_path": txt_path, "summary": summary}
# Success return
return {"ok": True, "log": "\n".join(logs), "csv_path": csv_path, "txt_path": txt_path, "summary": summary, "compliance": compliance_obj}
except Exception as e:
logs.append(f"Build failed: {e}")
return {"ok": False, "error": str(e), "log": "\n".join(logs)}
@ -1131,7 +1322,15 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
prefer_c = bool(getattr(b, 'prefer_combos', False))
except Exception:
prefer_c = False
if prefer_c:
# Respect bracket limits: if two-card combos are disallowed (limit == 0), skip auto-combos stage
allow_combos = True
try:
lim = getattr(b, 'bracket_limits', {}).get('two_card_combos')
if lim is not None and int(lim) == 0:
allow_combos = False
except Exception:
allow_combos = True
if prefer_c and allow_combos:
stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"})
# Ensure we include the theme filler step to top up to 100 cards
if callable(getattr(b, 'fill_remaining_theme_spells', None)):
@ -1139,7 +1338,15 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
elif hasattr(b, 'add_spells_phase'):
# For monolithic spells, insert combos BEFORE the big spells stage so additions aren't clamped away
try:
if bool(getattr(b, 'prefer_combos', False)):
prefer_c = bool(getattr(b, 'prefer_combos', False))
allow_combos = True
try:
lim = getattr(b, 'bracket_limits', {}).get('two_card_combos')
if lim is not None and int(lim) == 0:
allow_combos = False
except Exception:
allow_combos = True
if prefer_c and allow_combos:
stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"})
except Exception:
pass
@ -1240,6 +1447,8 @@ def start_build_ctx(
# Data load
b.determine_color_identity()
b.setup_dataframes()
# Apply the same global pool pruning in interactive builds for consistency
_global_prune_disallowed_pool(b)
# Thread multi-copy selection onto builder for stage generation/runner
try:
b._web_multi_copy = (multi_copy or None)
@ -1339,7 +1548,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
setattr(b, 'custom_export_base', str(custom_base))
except Exception:
pass
if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'):
if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'):
try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
except Exception as e:
@ -1354,6 +1563,33 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
except Exception:
pass
# Compute bracket compliance and save JSON alongside exports
try:
if hasattr(b, 'compute_and_print_compliance'):
rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
rep0 = _attach_enforcement_plan(b, rep0)
try:
import os as __os
_auto = str(__os.getenv('WEB_AUTO_ENFORCE', '0')).strip().lower() in {"1","true","yes","on"}
except Exception:
_auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined]
except Exception:
pass
# Load compliance JSON for UI consumption
try:
# Prefer in-memory report if available
if rep0 is not None:
ctx["compliance"] = rep0
else:
import json as _json
comp_path = _os.path.join('deck_files', f"{base}_compliance.json")
if _os.path.exists(comp_path):
with open(comp_path, 'r', encoding='utf-8') as _cf:
ctx["compliance"] = _json.load(_cf)
except Exception:
ctx["compliance"] = None
except Exception as e:
logs.append(f"Text export failed: {e}")
# Final lock enforcement before finishing
@ -1428,6 +1664,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv_path": ctx.get("csv_path"),
"txt_path": ctx.get("txt_path"),
"summary": summary,
"compliance": ctx.get("compliance"),
}
# Determine which stage index to run (rerun last visible, else current)
@ -1436,6 +1673,52 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
else:
i = ctx["idx"]
# If compliance gating is active for the current stage, do not rerun the stage; block advancement until PASS
try:
gating = ctx.get('gating') or None
if gating and isinstance(gating, dict) and int(gating.get('stage_idx', -1)) == int(i):
# Recompute compliance snapshot
comp_now = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
except Exception:
comp_now = None
try:
if comp_now:
comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined]
except Exception:
pass
# If still FAIL, return the saved result without advancing or rerunning
try:
if comp_now and str(comp_now.get('overall', 'PASS')).upper() == 'FAIL':
# Update total_cards live before returning saved result
try:
total_cards = 0
for _n, _e in getattr(b, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = None
saved = gating.get('res') or {}
saved['total_cards'] = total_cards
saved['gated'] = True
return saved
except Exception:
pass
# Gating cleared: advance to the next stage without rerunning the gated one
try:
del ctx['gating']
except Exception:
ctx['gating'] = None
i = i + 1
ctx['idx'] = i
# continue into loop with advanced index
except Exception:
pass
# Iterate forward until we find a stage that adds cards, skipping no-ops
while i < len(stages):
stage = stages[i]
@ -1866,6 +2149,45 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception:
clamped_overflow = 0
# Compute compliance after this stage and apply gating when FAIL
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
except Exception:
comp = None
try:
if comp:
comp = _attach_enforcement_plan(b, comp)
except Exception:
pass
# If FAIL, do not advance; save gating state and return current stage results
try:
if comp and str(comp.get('overall', 'PASS')).upper() == 'FAIL':
# Save a snapshot of the response to reuse while gated
res_hold = {
"done": False,
"label": label,
"log_delta": delta_log,
"added_cards": added_cards,
"idx": i + 1,
"total": len(stages),
"total_cards": total_cards,
"added_total": sum(int(c.get('count', 0) or 0) for c in added_cards) if added_cards else 0,
"mc_adjustments": ctx.get('mc_adjustments'),
"clamped_overflow": clamped_overflow,
"mc_summary": ctx.get('mc_summary'),
"gated": True,
}
ctx['gating'] = {"stage_idx": i, "label": label, "res": res_hold}
# Keep current index (do not advance)
ctx["snapshot"] = snap_before
ctx["last_visible_idx"] = i + 1
return res_hold
except Exception:
pass
# If this stage added cards, present it and advance idx
if added_cards:
# Progress counts
@ -1911,6 +2233,38 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
# No cards added: either skip or surface as a 'skipped' stage
if show_skipped:
# Compute compliance even when skipped; gate progression if FAIL
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
except Exception:
comp = None
try:
if comp:
comp = _attach_enforcement_plan(b, comp)
except Exception:
pass
try:
if comp and str(comp.get('overall', 'PASS')).upper() == 'FAIL':
res_hold = {
"done": False,
"label": label,
"log_delta": delta_log,
"added_cards": [],
"skipped": True,
"idx": i + 1,
"total": len(stages),
"total_cards": total_cards,
"added_total": 0,
"gated": True,
}
ctx['gating'] = {"stage_idx": i, "label": label, "res": res_hold}
ctx["snapshot"] = snap_before
ctx["last_visible_idx"] = i + 1
return res_hold
except Exception:
pass
# Progress counts even when skipped
try:
total_cards = 0
@ -1945,7 +2299,39 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"added_total": 0,
}
# No cards added and not showing skipped: advance to next stage and continue loop
# No cards added and not showing skipped: advance to next stage unless compliance FAIL gates progression
try:
comp = None
try:
if hasattr(b, 'compute_and_print_compliance'):
comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined]
except Exception:
comp = None
try:
if comp:
comp = _attach_enforcement_plan(b, comp)
except Exception:
pass
if comp and str(comp.get('overall', 'PASS')).upper() == 'FAIL':
# Gate here with a skipped stage result
res_hold = {
"done": False,
"label": label,
"log_delta": delta_log,
"added_cards": [],
"skipped": True,
"idx": i + 1,
"total": len(stages),
"total_cards": total_cards,
"added_total": 0,
"gated": True,
}
ctx['gating'] = {"stage_idx": i, "label": label, "res": res_hold}
ctx["snapshot"] = snap_before
ctx["last_visible_idx"] = i + 1
return res_hold
except Exception:
pass
i += 1
# Continue loop to auto-advance
@ -1973,6 +2359,32 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
except Exception:
pass
# Compute bracket compliance and save JSON alongside exports
try:
if hasattr(b, 'compute_and_print_compliance'):
rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
rep0 = _attach_enforcement_plan(b, rep0)
try:
import os as __os
_auto = str(__os.getenv('WEB_AUTO_ENFORCE', '0')).strip().lower() in {"1","true","yes","on"}
except Exception:
_auto = False
if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'):
b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined]
except Exception:
pass
# Load compliance JSON for UI consumption
try:
if rep0 is not None:
ctx["compliance"] = rep0
else:
import json as _json
comp_path = _os.path.join('deck_files', f"{base}_compliance.json")
if _os.path.exists(comp_path):
with open(comp_path, 'r', encoding='utf-8') as _cf:
ctx["compliance"] = _json.load(_cf)
except Exception:
ctx["compliance"] = None
except Exception as e:
logs.append(f"Text export failed: {e}")
# Build structured summary for UI
@ -2029,4 +2441,5 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"summary": summary,
"total_cards": total_cards,
"added_total": 0,
"compliance": ctx.get("compliance"),
}

View file

@ -0,0 +1,46 @@
{% if compliance %}
<details id="compliance-panel" style="margin-top:.75rem;">
<summary>Bracket compliance</summary>
<div class="muted" style="margin:.35rem 0;">Overall: <strong>{{ compliance.overall }}</strong> (Bracket: {{ compliance.bracket|title }}{{ ' #' ~ compliance.level if compliance.level is defined }})</div>
{% if compliance.messages and compliance.messages|length > 0 %}
<ul style="margin:.25rem 0; padding-left:1.25rem;">
{% for m in compliance.messages %}
<li>{{ m }}</li>
{% endfor %}
</ul>
{% endif %}
{# Flagged tiles by category, in the same card grid style #}
{% if flagged_meta and flagged_meta|length > 0 %}
<h5 style="margin:.75rem 0 .35rem 0;">Flagged cards</h5>
<div class="card-grid">
{% for f in flagged_meta %}
<div class="card-tile" data-card-name="{{ f.name }}" data-role="{{ f.role or '' }}">
<a href="https://scryfall.com/search?q={{ f.name|urlencode }}" target="_blank" rel="noopener" class="img-btn" title="{{ f.name }}">
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal" alt="{{ f.name }} image" width="160" loading="lazy" decoding="async" data-lqip="1"
srcset="https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ f.name|urlencode }}&format=image&version=large 672w"
sizes="160px" />
</a>
<div class="owned-badge" title="{{ 'Owned' if f.owned else 'Not owned' }}" aria-label="{{ 'Owned' if f.owned else 'Not owned' }}">{% if f.owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ f.name }}</div>
<div class="muted" style="text-align:center; font-size:12px;">{{ f.category }}{% if f.role %} • {{ f.role }}{% endif %}</div>
<div style="display:flex; justify-content:center; margin-top:.25rem;">
{# Role-aware alternatives: pass the flagged name; server will infer role and exclude in-deck/locked #}
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ f.name }}"}' hx-target="#alts-flag-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest role-consistent replacements">Pick replacement…</button>
</div>
<div id="alts-flag-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
</div>
{% endfor %}
</div>
{% endif %}
{% if compliance.enforcement %}
<div style="margin-top:.75rem; display:flex; gap:1rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/enforce/apply" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
<button type="submit" class="btn-rerun">Apply enforcement now</button>
</form>
<div class="muted">Tip: pick replacements first; your choices are honored during enforcement.</div>
</div>
{% endif %}
</details>
{% endif %}

View file

@ -40,11 +40,13 @@
<input type="hidden" name="tag_mode" value="AND" />
</div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
<div style="margin-top:.5rem;">
<div style="margin-top:.5rem;" id="newdeck-bracket-slot">
<label>Bracket
<select name="bracket">
{% for b in brackets %}
<option value="{{ b.level }}" {% if (form and form.bracket and form.bracket == b.level) or (not form and b.level == 3) %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
{% if not gc_commander or b.level >= 3 %}
<option value="{{ b.level }}" {% if (form and form.bracket and form.bracket == b.level) or (not form and b.level == 3) %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
{% endif %}
{% endfor %}
</select>
</label>

View file

@ -62,6 +62,22 @@
</div>
</div>
{# Always update the bracket dropdown on commander change; hide 12 only when gc_commander is true #}
<div id="newdeck-bracket-slot" hx-swap-oob="true">
<label>Bracket
<select name="bracket">
{% for b in brackets %}
{% if not gc_commander or b.level >= 3 %}
<option value="{{ b.level }}" {% if b.level == 3 %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
{% endif %}
{% endfor %}
</select>
</label>
{% if gc_commander %}
<div class="muted" style="font-size:12px; margin-top:.25rem;">Commander is a Game Changer; brackets 12 are unavailable.</div>
{% endif %}
</div>
<script>
(function(){
var list = document.getElementById('modal-tag-list');

View file

@ -76,10 +76,12 @@
<legend>Budget/Power Bracket</legend>
<div style="display:grid; gap:.5rem;">
{% for b in brackets %}
{% if not gc_commander or b.level >= 3 %}
<label style="display:flex; gap:.5rem; align-items:flex-start;">
<input type="radio" name="bracket" value="{{ b.level }}" {% if (selected_bracket is defined and selected_bracket == b.level) or (selected_bracket is not defined and loop.first) %}checked{% endif %} />
<span><strong>{{ b.name }}</strong><small>{{ b.desc }}</small></span>
</label>
{% endif %}
{% endfor %}
</div>
<div class="muted" style="margin-top:.35rem; font-size:.9em;">

View file

@ -79,7 +79,14 @@
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
</div>
{% endif %}
{% if gated and (not status or not status.startswith('Build complete')) %}
<div class="alert" style="margin-top:.5rem; color:#fecaca; background:#7f1d1d; border:1px solid #991b1b; padding:.5rem .75rem; border-radius:8px;">
Compliance gating active — resolve violations above (replace or remove cards) to continue.
</div>
{% endif %}
{# Load compliance panel as soon as the page renders, regardless of final status #}
<div hx-get="/build/compliance" hx-trigger="load" hx-swap="afterend"></div>
{% if status and status.startswith('Build complete') %}
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
{% endif %}
@ -144,11 +151,11 @@
</form>
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
</form>
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Rerun Stage</button>
</form>
<span class="sep"></span>
<div class="replace-toggle" role="group" aria-label="Replace toggle">
@ -305,11 +312,9 @@
<!-- controls now above -->
{% if status and status.startswith('Build complete') %}
{% if summary %}
{% if status and status.startswith('Build complete') and summary %}
{% include "partials/deck_summary.html" %}
{% endif %}
{% endif %}
</div>
</div>
</section>

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Bracket compliance — Enforcement review</h2>
<p class="muted">Choose replacements for flagged cards, then click Apply enforcement.</p>
<div style="margin:.5rem 0 1rem 0;">
<a href="/build" class="btn">Back to Builder</a>
</div>
{% include "build/_compliance_panel.html" %}
</section>
<script>
// In full-page mode, submit enforcement as a normal form POST (not HTMX swap)
try{
document.querySelectorAll('form[hx-post="/build/enforce/apply"]').forEach(function(f){
f.removeAttribute('hx-post');
f.removeAttribute('hx-target');
f.removeAttribute('hx-swap');
f.setAttribute('action', '/build/enforce/apply');
f.setAttribute('method', 'post');
});
}catch(_){ }
// Auto-open the compliance details when shown on this dedicated page
try{
var det = document.querySelector('details');
if(det){ det.setAttribute('open', 'open'); }
}catch(_){ }
</script>
{% endblock %}