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

@ -13,10 +13,16 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### Added
- Bracket policy enforcement: global pool-level prune for disallowed categories when limits are 0 (e.g., Game Changers in Brackets 12). Applies to both Web and headless runs.
- Inline enforcement UI: violations surface before the summary; Continue/Rerun disabled until you replace or remove flagged cards. Alternatives are role-consistent and exclude commander/locked/in-deck cards.
- Auto-enforce option: `WEB_AUTO_ENFORCE=1` to apply the enforcement plan and re-export when compliance fails.
### Changed ### Changed
- Spells and creatures phases apply bracket-aware pre-filters to reduce violations proactively.
- Compliance detection for Game Changers falls back to in-code constants when `config/card_lists/game_changers.json` is empty.
### Fixed ### Fixed
- Summary/export mismatch in headless JSON runs where disallowed cards could be pruned from exports but appear in summaries; global prune ensures consistent state across phases and reports.
## [2.2.4] - 2025-09-02 ## [2.2.4] - 2025-09-02

BIN
README.md

Binary file not shown.

View file

@ -0,0 +1,226 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import json
import yaml
from deck_builder.combos import detect_combos
from .phases.phase0_core import BRACKET_DEFINITIONS
from type_definitions import ComplianceReport, CategoryFinding
POLICY_TAGS = {
"game_changers": "Bracket:GameChanger",
"extra_turns": "Bracket:ExtraTurn",
"mass_land_denial": "Bracket:MassLandDenial",
"tutors_nonland": "Bracket:TutorNonland",
}
# Local policy file mapping (mirrors tagging.bracket_policy_applier)
POLICY_FILES: Dict[str, str] = {
"game_changers": "config/card_lists/game_changers.json",
"extra_turns": "config/card_lists/extra_turns.json",
"mass_land_denial": "config/card_lists/mass_land_denial.json",
"tutors_nonland": "config/card_lists/tutors_nonland.json",
}
def _load_json_cards(path: str | Path) -> Tuple[List[str], Optional[str]]:
p = Path(path)
if not p.exists():
return [], None
try:
data = json.loads(p.read_text(encoding="utf-8"))
cards = [str(x).strip() for x in data.get("cards", []) if str(x).strip()]
version = str(data.get("list_version")) if data.get("list_version") else None
return cards, version
except Exception:
return [], None
def _load_brackets_yaml(path: str | Path = "config/brackets.yml") -> Dict[str, dict]:
p = Path(path)
if not p.exists():
return {}
try:
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
except Exception:
return {}
def _find_bracket_def(bracket_key: str) -> Tuple[str, int, Dict[str, Optional[int]]]:
key = (bracket_key or "core").strip().lower()
# Prefer YAML if available
y = _load_brackets_yaml()
if key in y:
meta = y[key]
name = str(meta.get("name", key.title()))
level = int(meta.get("level", 2))
limits = dict(meta.get("limits", {}))
return name, level, limits
# Fallback to in-code defaults
for bd in BRACKET_DEFINITIONS:
if bd.name.strip().lower() == key or str(bd.level) == key:
return bd.name, bd.level, dict(bd.limits)
# map common aliases
alias = bd.name.strip().lower()
if key in (alias, {1:"exhibition",2:"core",3:"upgraded",4:"optimized",5:"cedh"}.get(bd.level, "")):
return bd.name, bd.level, dict(bd.limits)
# Default to Core
core = next(b for b in BRACKET_DEFINITIONS if b.level == 2)
return core.name, core.level, dict(core.limits)
def _collect_tag_counts(card_library: Dict[str, Dict]) -> Tuple[Dict[str, int], Dict[str, List[str]]]:
counts: Dict[str, int] = {v: 0 for v in POLICY_TAGS.values()}
flagged_names: Dict[str, List[str]] = {k: [] for k in POLICY_TAGS.keys()}
for name, info in (card_library or {}).items():
tags = [t for t in (info.get("Tags") or []) if isinstance(t, str)]
for key, tag in POLICY_TAGS.items():
if tag in tags:
counts[tag] += 1
flagged_names[key].append(name)
return counts, flagged_names
def _canonicalize(name: str | None) -> str:
"""Match normalization similar to the tag applier.
- casefold
- normalize curly apostrophes to straight
- strip A- prefix (Arena/Alchemy variants)
- trim
"""
if not name:
return ""
s = str(name).strip().replace("\u2019", "'")
if s.startswith("A-") and len(s) > 2:
s = s[2:]
return s.casefold()
def _status_for(count: int, limit: Optional[int]) -> str:
if limit is None:
return "PASS"
return "PASS" if count <= int(limit) else "FAIL"
def evaluate_deck(
deck_cards: Dict[str, Dict],
commander_name: Optional[str],
bracket: str,
enforcement: str = "validate",
combos_path: str | Path = "config/card_lists/combos.json",
) -> ComplianceReport:
name, level, limits = _find_bracket_def(bracket)
counts_by_tag, names_by_key = _collect_tag_counts(deck_cards)
categories: Dict[str, CategoryFinding] = {}
messages: List[str] = []
# Prepare a canonicalized deck name map to support list-based matching
deck_canon_to_display: Dict[str, str] = {}
for n in (deck_cards or {}).keys():
cn = _canonicalize(n)
if cn and cn not in deck_canon_to_display:
deck_canon_to_display[cn] = n
# Map categories by combining tag-based counts with direct list matches by name
for key, tag in POLICY_TAGS.items():
# Start with any names found via tags
flagged_set: set[str] = set()
for nm in names_by_key.get(key, []) or []:
ckey = _canonicalize(nm)
if ckey:
flagged_set.add(ckey)
# Merge in list-based matches (by canonicalized name)
try:
file_path = POLICY_FILES.get(key)
if file_path:
names_list, _ver = _load_json_cards(file_path)
# Fallback for game_changers when file is empty: use in-code constants
if key == 'game_changers' and not names_list:
try:
from deck_builder import builder_constants as _bc
names_list = list(getattr(_bc, 'GAME_CHANGERS', []) or [])
except Exception:
names_list = []
listed = {_canonicalize(x) for x in names_list}
present = set(deck_canon_to_display.keys())
flagged_set |= (listed & present)
except Exception:
pass
# Build final flagged display names from the canonical set
flagged_names_disp = sorted({deck_canon_to_display.get(cn, cn) for cn in flagged_set})
c = len(flagged_set)
lim = limits.get(key)
status = _status_for(c, lim)
cat: CategoryFinding = {
"count": c,
"limit": lim,
"flagged": flagged_names_disp,
"status": status,
"notes": [],
}
categories[key] = cat
if status == "FAIL":
messages.append(f"{key.replace('_',' ').title()}: {c} exceeds limit {lim}")
# Two-card combos detection
combos = detect_combos(deck_cards.keys(), combos_path=combos_path)
cheap_early_pairs = [p for p in combos if p.cheap_early]
c_limit = limits.get("two_card_combos")
combos_status = _status_for(len(cheap_early_pairs), c_limit)
categories["two_card_combos"] = {
"count": len(cheap_early_pairs),
"limit": c_limit,
"flagged": [f"{p.a} + {p.b}" for p in cheap_early_pairs],
"status": combos_status,
"notes": ["Only counting cheap/early combos per policy"],
}
if combos_status == "FAIL":
messages.append("Two-card combos present beyond allowed bracket")
commander_flagged = False
if commander_name:
gch_cards, _ = _load_json_cards("config/card_lists/game_changers.json")
if any(commander_name.strip().lower() == x.lower() for x in gch_cards):
commander_flagged = True
# Exhibition/Core treat this as automatic fail; Upgraded counts toward limit
if level in (1, 2):
messages.append("Commander is on Game Changers list (not allowed for this bracket)")
categories["game_changers"]["status"] = "FAIL"
categories["game_changers"]["flagged"].append(commander_name)
# Build list_versions metadata
_, extra_ver = _load_json_cards("config/card_lists/extra_turns.json")
_, mld_ver = _load_json_cards("config/card_lists/mass_land_denial.json")
_, tutor_ver = _load_json_cards("config/card_lists/tutors_nonland.json")
_, gch_ver = _load_json_cards("config/card_lists/game_changers.json")
list_versions = {
"extra_turns": extra_ver,
"mass_land_denial": mld_ver,
"tutors_nonland": tutor_ver,
"game_changers": gch_ver,
}
# Overall verdict
overall = "PASS"
if any(cat.get("status") == "FAIL" for cat in categories.values()):
overall = "FAIL"
elif any(cat.get("status") == "WARN" for cat in categories.values()):
overall = "WARN"
report: ComplianceReport = {
"bracket": name.lower(),
"level": level,
"enforcement": enforcement,
"overall": overall,
"commander_flagged": commander_flagged,
"categories": categories,
"combos": [{"a": p.a, "b": p.b, "cheap_early": p.cheap_early, "setup_dependent": p.setup_dependent} for p in combos],
"list_versions": list_versions,
"messages": messages,
}
return report

View file

@ -119,6 +119,19 @@ class DeckBuilder(
# Modular reporting phase # Modular reporting phase
if hasattr(self, 'run_reporting_phase'): if hasattr(self, 'run_reporting_phase'):
self.run_reporting_phase() self.run_reporting_phase()
# Immediately after content additions and summary, if compliance is enforced later,
# we want to display what would be swapped. For interactive runs, surface a dry prompt.
try:
# Compute a quick compliance snapshot here to hint at upcoming enforcement
if hasattr(self, 'compute_and_print_compliance') and not getattr(self, 'headless', False):
from deck_builder.brackets_compliance import evaluate_deck as _eval # type: ignore
bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower()
commander = getattr(self, 'commander_name', None)
snap = _eval(self.card_library, commander_name=commander, bracket=bracket_key)
if snap.get('overall') == 'FAIL':
self.output_func("\nNote: Limits exceeded. You'll get a chance to review swaps next.")
except Exception:
pass
if hasattr(self, 'export_decklist_csv'): if hasattr(self, 'export_decklist_csv'):
# If user opted out of owned-only, silently load all owned files for marking # If user opted out of owned-only, silently load all owned files for marking
try: try:
@ -133,6 +146,25 @@ class DeckBuilder(
txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
# Display the text file contents for easy copy/paste to online deck builders # Display the text file contents for easy copy/paste to online deck builders
self._display_txt_contents(txt_path) self._display_txt_contents(txt_path)
# Compute bracket compliance and save a JSON report alongside exports
try:
if hasattr(self, 'compute_and_print_compliance'):
report0 = self.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined]
# If non-compliant and interactive, offer enforcement now
try:
if isinstance(report0, dict) and report0.get('overall') == 'FAIL' and not getattr(self, 'headless', False):
from deck_builder.phases.phase6_reporting import ReportingMixin as _RM # type: ignore
if isinstance(self, _RM) and hasattr(self, 'enforce_and_reexport'):
self.output_func("One or more bracket limits exceeded. Enter to auto-resolve, or Ctrl+C to skip.")
try:
_ = self.input_func("")
except Exception:
pass
self.enforce_and_reexport(base_stem=base, mode='prompt') # type: ignore[attr-defined]
except Exception:
pass
except Exception:
pass
# If owned-only build is incomplete, generate recommendations # If owned-only build is incomplete, generate recommendations
try: try:
total_cards = sum(int(v.get('Count', 1)) for v in self.card_library.values()) total_cards = sum(int(v.get('Count', 1)) for v in self.card_library.values())

View file

@ -0,0 +1,448 @@
from __future__ import annotations
from typing import Dict, List, Optional, Tuple, Set
from pathlib import Path
import json
# Lightweight, internal utilities to avoid circular imports
from .brackets_compliance import evaluate_deck, POLICY_FILES
def _load_list_cards(paths: List[str]) -> Set[str]:
out: Set[str] = set()
for p in paths:
try:
data = json.loads(Path(p).read_text(encoding="utf-8"))
for n in (data.get("cards") or []):
if isinstance(n, str) and n.strip():
out.add(n.strip())
except Exception:
continue
return out
def _candidate_pool_for_role(builder, role: str) -> List[Tuple[str, dict]]:
"""Return a prioritized list of (name, rowdict) candidates for a replacement of a given role.
This consults the current combined card pool, filters out lands and already-chosen names,
and applies a role->tag mapping to find suitable replacements.
"""
df = getattr(builder, "_combined_cards_df", None)
if df is None or getattr(df, "empty", True):
return []
if "name" not in df.columns:
return []
# Normalize tag list per row
def _norm_tags(x):
return [str(t).lower() for t in x] if isinstance(x, list) else []
work = df.copy()
work["_ltags"] = work.get("themeTags", []).apply(_norm_tags)
# Role to tag predicates
def _is_protection(tags: List[str]) -> bool:
return any("protection" 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 _is_removal(tags: List[str]) -> bool:
return any(("removal" in t) or ("spot removal" in t) for t in tags) and not any(("board wipe" in t) or ("mass removal" in t) for t in tags)
def _is_wipe(tags: List[str]) -> bool:
return any(("board wipe" in t) or ("mass removal" in t) for t in tags)
# Theme fallback: anything that matches selected tags (primary/secondary/tertiary)
sel_tags = [str(getattr(builder, k, "") or "").strip().lower() for k in ("primary_tag", "secondary_tag", "tertiary_tag")]
sel_tags = [t for t in sel_tags if t]
def _matches_theme(tags: List[str]) -> bool:
if not sel_tags:
return False
for t in tags:
for st in sel_tags:
if st in t:
return True
return False
pred = None
r = str(role or "").strip().lower()
if r == "protection":
pred = _is_protection
elif r == "card_advantage":
pred = _is_draw
elif r == "removal":
pred = _is_removal
elif r in ("wipe", "board_wipe", "wipes"):
pred = _is_wipe
else:
pred = _matches_theme
pool = work[~work["type"].fillna("").str.contains("Land", case=False, na=False)]
if pred is _matches_theme:
pool = pool[pool["_ltags"].apply(_matches_theme)]
else:
pool = pool[pool["_ltags"].apply(pred)]
# Exclude names already in the library
already_lower = {str(n).lower() for n in getattr(builder, "card_library", {}).keys()}
pool = pool[~pool["name"].astype(str).str.lower().isin(already_lower)]
# Sort by edhrecRank then manaValue
try:
from . import builder_utils as bu
sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined]
# Prefer-owned bias
if getattr(builder, "prefer_owned", False):
owned = getattr(builder, "owned_card_names", None)
if owned:
sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined]
except Exception:
sorted_df = pool
out: List[Tuple[str, dict]] = []
for _, r in sorted_df.iterrows():
nm = str(r.get("name"))
if not nm:
continue
out.append((nm, r.to_dict()))
return out
def _remove_card(builder, name: str) -> bool:
entry = getattr(builder, "card_library", {}).get(name)
if not entry:
return False
# Protect commander and locks
if bool(entry.get("Commander")):
return False
if str(entry.get("AddedBy", "")).strip().lower() == "lock":
return False
try:
del builder.card_library[name]
return True
except Exception:
return False
def _try_add_replacement(builder, target_role: Optional[str], forbidden: Set[str]) -> Optional[str]:
"""Attempt to add one replacement card for the given role, avoiding forbidden names.
Returns the name added, or None if no suitable candidate was found/added.
"""
role = (target_role or "").strip().lower()
tried_roles = [role] if role else []
if role not in ("protection", "card_advantage", "removal", "wipe", "board_wipe", "wipes"):
tried_roles.append("card_advantage")
tried_roles.append("protection")
tried_roles.append("removal")
for r in tried_roles or ["card_advantage"]:
candidates = _candidate_pool_for_role(builder, r)
for nm, row in candidates:
if nm in forbidden:
continue
# Enforce owned-only and color identity legality via builder.add_card (it will silently skip if illegal)
before = set(getattr(builder, "card_library", {}).keys())
builder.add_card(
nm,
card_type=str(row.get("type", row.get("type_line", "")) or ""),
mana_cost=str(row.get("mana_cost", row.get("manaCost", "")) or ""),
role=target_role or ("card_advantage" if r == "card_advantage" else ("protection" if r == "protection" else ("removal" if r == "removal" else "theme_spell"))),
added_by="enforcement"
)
after = set(getattr(builder, "card_library", {}).keys())
added = list(after - before)
if added:
return added[0]
return None
def enforce_bracket_compliance(builder, mode: str = "prompt") -> Dict:
"""Trim over-limit bracket categories and add role-consistent replacements.
mode: 'prompt' for interactive CLI (respects builder.headless); 'auto' for non-interactive.
Returns the final compliance report after enforcement (or the original if no changes).
"""
# Compute initial report
bracket_key = str(getattr(builder, 'bracket_name', '') or getattr(builder, 'bracket_level', 'core')).lower()
commander = getattr(builder, 'commander_name', None)
report = evaluate_deck(getattr(builder, 'card_library', {}), commander_name=commander, bracket=bracket_key)
if report.get("overall") != "FAIL":
return report
# Prepare prohibited set (avoid adding these during replacement)
forbidden_lists = list(POLICY_FILES.values())
prohibited: Set[str] = _load_list_cards(forbidden_lists)
# Determine offenders per category
cats = report.get("categories", {}) or {}
to_remove: List[str] = []
# Build a helper to rank offenders: keep better (lower edhrecRank) ones
df = getattr(builder, "_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 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))
# Interactive helper
interactive = (mode == 'prompt' and not bool(getattr(builder, 'headless', False)))
for key, cat in cats.items():
if key not in ("game_changers", "extra_turns", "mass_land_denial", "tutors_nonland"):
continue
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)]
# Only consider flagged names that are actually in the library now
lib = getattr(builder, 'card_library', {})
present = [n for n in flagged if n in lib]
if not present:
continue
# Determine how many need trimming
over = cnt - int(lim)
# Sort by ascending desirability to keep: worst ranks first for removal
present_sorted = sorted(present, key=_score, reverse=True) # worst first
if interactive:
# Present choices to keep
try:
out = getattr(builder, 'output_func', print)
inp = getattr(builder, 'input_func', input)
out(f"\nEnforcement: {key.replace('_',' ').title()} is over the limit ({cnt} > {lim}).")
out("Select the indices to KEEP (comma-separated). Press Enter to auto-keep the best:")
for i, nm in enumerate(sorted(present, key=_score)):
sc = _score(nm)
out(f" [{i}] {nm} (edhrecRank={sc[0] if sc[0] < 10**9 else 'n/a'})")
raw = str(inp("Keep which? ").strip())
keep_idx: Set[int] = set()
if raw:
for tok in raw.split(','):
tok = tok.strip()
if tok.isdigit():
keep_idx.add(int(tok))
# Compute the names to keep up to the allowed count
allowed = max(0, int(lim))
keep_list: List[str] = []
for i, nm in enumerate(sorted(present, key=_score)):
if len(keep_list) >= allowed:
break
if i in keep_idx:
keep_list.append(nm)
# If still short, fill with best-ranked remaining
for nm in sorted(present, key=_score):
if len(keep_list) >= allowed:
break
if nm not in keep_list:
keep_list.append(nm)
# Remove the others (beyond keep_list)
for nm in present:
if nm not in keep_list and over > 0:
to_remove.append(nm)
over -= 1
if over > 0:
# If user kept too many, trim worst extras
for nm in present_sorted:
if over <= 0:
break
if nm in keep_list:
to_remove.append(nm)
over -= 1
except Exception:
# Fallback to auto behavior
to_remove.extend(present_sorted[:over])
else:
# Auto: remove the worst-ranked extras first
to_remove.extend(present_sorted[:over])
# Execute removals and replacements
actually_removed: List[str] = []
actually_added: List[str] = []
swaps: List[dict] = []
# Load preferred replacements mapping (lowercased keys/values)
pref_map_lower: Dict[str, str] = {}
try:
raw = getattr(builder, 'preferred_replacements', {}) or {}
for k, v in raw.items():
ks = str(k).strip().lower()
vs = str(v).strip().lower()
if ks and vs:
pref_map_lower[ks] = vs
except Exception:
pref_map_lower = {}
for nm in to_remove:
entry = getattr(builder, 'card_library', {}).get(nm)
if not entry:
continue
role = entry.get('Role') or None
if _remove_card(builder, nm):
actually_removed.append(nm)
# First, honor any explicit user-chosen replacement
added = None
try:
want = pref_map_lower.get(str(nm).strip().lower())
if want:
# Avoid adding prohibited or duplicates
lib_l = {str(x).strip().lower() for x in getattr(builder, 'card_library', {}).keys()}
if (want not in prohibited) and (want not in lib_l):
df = getattr(builder, '_combined_cards_df', None)
target_name = None
card_type = ''
mana_cost = ''
if df is not None and not getattr(df, 'empty', True) and 'name' in df.columns:
r = df[df['name'].astype(str).str.lower() == want]
if not r.empty:
target_name = str(r.iloc[0]['name'])
card_type = str(r.iloc[0].get('type', r.iloc[0].get('type_line', '')) or '')
mana_cost = str(r.iloc[0].get('mana_cost', r.iloc[0].get('manaCost', '')) or '')
# If we couldn't resolve row, still try to add by name
target = target_name or want
before = set(getattr(builder, 'card_library', {}).keys())
builder.add_card(target, card_type=card_type, mana_cost=mana_cost, role=role, added_by='enforcement')
after = set(getattr(builder, 'card_library', {}).keys())
delta = list(after - before)
if delta:
added = delta[0]
except Exception:
added = None
# If no explicit or failed, try to add an automatic role-consistent replacement
if not added:
added = _try_add_replacement(builder, role, prohibited)
if added:
actually_added.append(added)
swaps.append({"removed": nm, "added": added, "role": role})
else:
swaps.append({"removed": nm, "added": None, "role": role})
# Recompute report after initial category-based changes
final_report = evaluate_deck(getattr(builder, 'card_library', {}), commander_name=commander, bracket=bracket_key)
# --- Second pass: break cheap/early two-card combos if still over the limit ---
try:
cats2 = final_report.get("categories", {}) or {}
two = cats2.get("two_card_combos") or {}
curr = int(two.get("count", 0) or 0)
lim = two.get("limit")
if lim is not None and curr > int(lim):
# Build present cheap/early pairs from the report
pairs: List[Tuple[str, str]] = []
for p in (final_report.get("combos") or []):
try:
if not p.get("cheap_early"):
continue
a = str(p.get("a") or "").strip()
b = str(p.get("b") or "").strip()
if not a or not b:
continue
# Only consider if both still present
lib = getattr(builder, 'card_library', {}) or {}
if a in lib and b in lib:
pairs.append((a, b))
except Exception:
continue
# Helper to recompute count and frequencies from current pairs
def _freq(ps: List[Tuple[str, str]]) -> Dict[str, int]:
mp: Dict[str, int] = {}
for (a, b) in ps:
mp[a] = mp.get(a, 0) + 1
mp[b] = mp.get(b, 0) + 1
return mp
current_pairs = list(pairs)
blocked: Set[str] = set()
# Keep removing until combos count <= limit or no progress possible
while len(current_pairs) > int(lim):
freq = _freq(current_pairs)
if not freq:
break
# Rank candidates: break the most combos first; break ties by worst desirability
cand_names = list(freq.keys())
cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type]
removed_any = False
for nm in cand_names:
if nm in blocked:
continue
entry = getattr(builder, 'card_library', {}).get(nm)
role = entry.get('Role') if isinstance(entry, dict) else None
# Try to remove; protects commander/locks inside helper
if _remove_card(builder, nm):
actually_removed.append(nm)
# Preferred replacement first
added = None
try:
want = pref_map_lower.get(str(nm).strip().lower())
if want:
lib_l = {str(x).strip().lower() for x in getattr(builder, 'card_library', {}).keys()}
if (want not in prohibited) and (want not in lib_l):
df2 = getattr(builder, '_combined_cards_df', None)
target_name = None
card_type = ''
mana_cost = ''
if df2 is not None and not getattr(df2, 'empty', True) and 'name' in df2.columns:
r = df2[df2['name'].astype(str).str.lower() == want]
if not r.empty:
target_name = str(r.iloc[0]['name'])
card_type = str(r.iloc[0].get('type', r.iloc[0].get('type_line', '')) or '')
mana_cost = str(r.iloc[0].get('mana_cost', r.iloc[0].get('manaCost', '')) or '')
target = target_name or want
before = set(getattr(builder, 'card_library', {}).keys())
builder.add_card(target, card_type=card_type, mana_cost=mana_cost, role=role, added_by='enforcement')
after = set(getattr(builder, 'card_library', {}).keys())
delta = list(after - before)
if delta:
added = delta[0]
except Exception:
added = None
if not added:
added = _try_add_replacement(builder, role, prohibited)
if added:
actually_added.append(added)
swaps.append({"removed": nm, "added": added, "role": role})
else:
swaps.append({"removed": nm, "added": None, "role": role})
# Update pairs by removing any that contain nm
current_pairs = [(a, b) for (a, b) in current_pairs if (a != nm and b != nm)]
removed_any = True
break
else:
blocked.add(nm)
if not removed_any:
# Cannot break further due to locks/commander; stop to avoid infinite loop
break
# Recompute report after combo-breaking
final_report = evaluate_deck(getattr(builder, 'card_library', {}), commander_name=commander, bracket=bracket_key)
except Exception:
# If combo-breaking fails for any reason, fall back to the current report
pass
# Attach enforcement actions for downstream consumers
try:
final_report.setdefault('enforcement', {})
final_report['enforcement']['removed'] = list(actually_removed)
final_report['enforcement']['added'] = list(actually_added)
final_report['enforcement']['swaps'] = list(swaps)
except Exception:
pass
# Log concise summary if possible
try:
out = getattr(builder, 'output_func', print)
if actually_removed or actually_added:
out("\nEnforcement applied:")
if actually_removed:
out("Removed:")
for x in actually_removed:
out(f" - {x}")
if actually_added:
out("Added:")
for x in actually_added:
out(f" + {x}")
out(f"Compliance after enforcement: {final_report.get('overall')}")
except Exception:
pass
return final_report

View file

@ -380,6 +380,8 @@ class CreatureAdditionMixin:
commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None) commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None)
if commander_name and 'name' in creature_df.columns: if commander_name and 'name' in creature_df.columns:
creature_df = creature_df[creature_df['name'] != commander_name] creature_df = creature_df[creature_df['name'] != commander_name]
# Apply bracket-based pre-filters (e.g., disallow game changers or tutors when bracket limit == 0)
creature_df = self._apply_bracket_pre_filters(creature_df)
if creature_df.empty: if creature_df.empty:
return None return None
if '_parsedThemeTags' not in creature_df.columns: if '_parsedThemeTags' not in creature_df.columns:
@ -392,6 +394,66 @@ class CreatureAdditionMixin:
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst)) creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
return creature_df return creature_df
def _apply_bracket_pre_filters(self, df):
"""Preemptively filter disallowed categories for the current bracket for creatures.
Excludes when bracket limit == 0 for a category:
- Game Changers
- Nonland Tutors
Note: Extra Turns and Mass Land Denial generally don't apply to creature cards,
but if present as tags, they'll be respected too.
"""
try:
if df is None or getattr(df, 'empty', False):
return df
limits = getattr(self, 'bracket_limits', {}) or {}
disallow = {
'game_changers': (limits.get('game_changers') is not None and int(limits.get('game_changers')) == 0),
'tutors_nonland': (limits.get('tutors_nonland') is not None and int(limits.get('tutors_nonland')) == 0),
'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),
}
if not any(disallow.values()):
return df
def norm_tags(val):
try:
return [str(t).strip().lower() for t in (val or [])]
except Exception:
return []
if '_ltags' not in df.columns:
try:
if 'themeTags' in df.columns:
df = df.copy()
df['_ltags'] = df['themeTags'].apply(bu.normalize_tag_cell)
except Exception:
pass
tag_col = '_ltags' if '_ltags' in df.columns else ('themeTags' if 'themeTags' in df.columns else None)
if not tag_col:
return df
syn = {
'game_changers': { 'bracket:gamechanger', 'gamechanger', 'game-changer', 'game changer' },
'tutors_nonland': { 'bracket:tutornonland', 'tutor', 'tutors', 'nonland tutor', 'non-land tutor' },
'extra_turns': { 'bracket:extraturn', 'extra turn', 'extra turns', 'extraturn' },
'mass_land_denial': { 'bracket:masslanddenial', 'mass land denial', 'mld', 'masslanddenial' },
}
tags_series = df[tag_col].apply(norm_tags)
mask_keep = [True] * len(df)
for cat, dis in disallow.items():
if not dis:
continue
needles = syn.get(cat, set())
drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst))
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=df.index)
except Exception:
pass
return df[mask_keep]
except Exception:
return df
def _add_creatures_for_role(self, role: str): def _add_creatures_for_role(self, role: str):
"""Add creatures for a single theme role ('primary'|'secondary'|'tertiary').""" """Add creatures for a single theme role ('primary'|'secondary'|'tertiary')."""
df = getattr(self, '_combined_cards_df', None) df = getattr(self, '_combined_cards_df', None)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import math import math
from typing import List, Dict from typing import List, Dict
import os
from .. import builder_utils as bu from .. import builder_utils as bu
from .. import builder_constants as bc from .. import builder_constants as bc
@ -16,6 +17,99 @@ class SpellAdditionMixin:
(e.g., further per-category sub-mixins) can split this class if complexity grows. (e.g., further per-category sub-mixins) can split this class if complexity grows.
""" """
def _apply_bracket_pre_filters(self, df):
"""Preemptively filter disallowed categories for the current bracket.
Excludes when bracket limit == 0 for a category:
- Game Changers
- Extra Turns
- Mass Land Denial (MLD)
- Nonland Tutors
"""
try:
if df is None or getattr(df, 'empty', False):
return df
limits = getattr(self, 'bracket_limits', {}) or {}
# Determine which categories are hard-disallowed
disallow = {
'game_changers': (limits.get('game_changers') is not None and int(limits.get('game_changers')) == 0),
'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),
}
if not any(disallow.values()):
return df
# Normalize tags helper
def norm_tags(val):
try:
return [str(t).strip().lower() for t in (val or [])]
except Exception:
return []
# Build predicate masks only if column exists
if '_ltags' not in df.columns:
try:
from .. import builder_utils as _bu
if 'themeTags' in df.columns:
df = df.copy()
df['_ltags'] = df['themeTags'].apply(_bu.normalize_tag_cell)
except Exception:
pass
def has_any(tags, needles):
return any((nd in t) for t in tags for nd in needles)
tag_col = '_ltags' if '_ltags' in df.columns else ('themeTags' if 'themeTags' in df.columns else None)
if not tag_col:
return df
# Define synonyms per category
syn = {
'game_changers': { 'bracket:gamechanger', 'gamechanger', 'game-changer', 'game changer' },
'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' },
}
# Build exclusion mask
mask_keep = [True] * len(df)
tags_series = df[tag_col].apply(norm_tags)
for cat, dis in disallow.items():
if not dis:
continue
needles = syn.get(cat, set())
drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst))
# Combine into keep mask
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=df.index)
except Exception:
pass
return df[mask_keep]
except Exception:
return df
def _debug_dump_pool(self, df, label: str) -> None:
"""If DEBUG_SPELL_POOLS_WRITE is set, write the pool to logs/pool_{label}_{timestamp}.csv"""
try:
if str(os.getenv('DEBUG_SPELL_POOLS_WRITE', '')).strip().lower() not in {"1","true","yes","on"}:
return
import os as _os
from datetime import datetime as _dt
_os.makedirs('logs', exist_ok=True)
ts = getattr(self, 'timestamp', _dt.now().strftime('%Y%m%d%H%M%S'))
path = _os.path.join('logs', f"pool_{label}_{ts}.csv")
cols = [c for c in ['name','type','manaValue','manaCost','edhrecRank','themeTags'] if c in df.columns]
try:
if cols:
df[cols].to_csv(path, index=False, encoding='utf-8')
else:
df.to_csv(path, index=False, encoding='utf-8')
except Exception:
df.to_csv(path, index=False)
try:
self.output_func(f"[DEBUG] Wrote pool CSV: {path} ({len(df)})")
except Exception:
pass
except Exception:
pass
# --------------------------- # ---------------------------
# Ramp # Ramp
# --------------------------- # ---------------------------
@ -56,7 +150,16 @@ class SpellAdditionMixin:
commander_name = getattr(self, 'commander', None) commander_name = getattr(self, 'commander', None)
if commander_name: if commander_name:
work = work[work['name'] != commander_name] work = work[work['name'] != commander_name]
work = self._apply_bracket_pre_filters(work)
work = bu.sort_by_priority(work, ['edhrecRank','manaValue']) work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
self._debug_dump_pool(work, 'ramp_all')
# Debug: print ramp pool details
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
names = work['name'].astype(str).head(30).tolist()
self.output_func(f"[DEBUG][Ramp] Total pool (non-lands): {len(work)}; top {len(names)}: {', '.join(names)}")
except Exception:
pass
# Prefer-owned bias: stable reorder to put owned first while preserving prior sort # Prefer-owned bias: stable reorder to put owned first while preserving prior sort
if getattr(self, 'prefer_owned', False): if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None) owned_set = getattr(self, 'owned_card_names', None)
@ -97,10 +200,24 @@ class SpellAdditionMixin:
return added_now return added_now
rocks_pool = work[work['type'].fillna('').str.contains('Artifact', case=False, na=False)] rocks_pool = work[work['type'].fillna('').str.contains('Artifact', case=False, na=False)]
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
rnames = rocks_pool['name'].astype(str).head(25).tolist()
self.output_func(f"[DEBUG][Ramp] Rocks pool: {len(rocks_pool)}; sample: {', '.join(rnames)}")
except Exception:
pass
self._debug_dump_pool(rocks_pool, 'ramp_rocks')
if rocks_target > 0: if rocks_target > 0:
add_from_pool(rocks_pool, rocks_target, added_rocks, 'Rocks') add_from_pool(rocks_pool, rocks_target, added_rocks, 'Rocks')
dorks_pool = work[work['type'].fillna('').str.contains('Creature', case=False, na=False)] dorks_pool = work[work['type'].fillna('').str.contains('Creature', case=False, na=False)]
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
dnames = dorks_pool['name'].astype(str).head(25).tolist()
self.output_func(f"[DEBUG][Ramp] Dorks pool: {len(dorks_pool)}; sample: {', '.join(dnames)}")
except Exception:
pass
self._debug_dump_pool(dorks_pool, 'ramp_dorks')
if dorks_target > 0: if dorks_target > 0:
add_from_pool(dorks_pool, dorks_target, added_dorks, 'Dorks') add_from_pool(dorks_pool, dorks_target, added_dorks, 'Dorks')
@ -108,6 +225,13 @@ class SpellAdditionMixin:
remaining = target_total - current_total remaining = target_total - current_total
if remaining > 0: if remaining > 0:
general_pool = work[~work['name'].isin(added_rocks + added_dorks)] general_pool = work[~work['name'].isin(added_rocks + added_dorks)]
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
gnames = general_pool['name'].astype(str).head(25).tolist()
self.output_func(f"[DEBUG][Ramp] General pool (remaining): {len(general_pool)}; sample: {', '.join(gnames)}")
except Exception:
pass
self._debug_dump_pool(general_pool, 'ramp_general')
add_from_pool(general_pool, remaining, added_general, 'General') add_from_pool(general_pool, remaining, added_general, 'General')
total_added_now = len(added_rocks)+len(added_dorks)+len(added_general) total_added_now = len(added_rocks)+len(added_dorks)+len(added_general)
@ -148,7 +272,15 @@ class SpellAdditionMixin:
commander_name = getattr(self, 'commander', None) commander_name = getattr(self, 'commander', None)
if commander_name: if commander_name:
pool = pool[pool['name'] != commander_name] pool = pool[pool['name'] != commander_name]
pool = self._apply_bracket_pre_filters(pool)
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue']) pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
self._debug_dump_pool(pool, 'removal')
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
names = pool['name'].astype(str).head(40).tolist()
self.output_func(f"[DEBUG][Removal] Pool size: {len(pool)}; top {len(names)}: {', '.join(names)}")
except Exception:
pass
if getattr(self, 'prefer_owned', False): if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None) owned_set = getattr(self, 'owned_card_names', None)
if owned_set: if owned_set:
@ -210,7 +342,15 @@ class SpellAdditionMixin:
commander_name = getattr(self, 'commander', None) commander_name = getattr(self, 'commander', None)
if commander_name: if commander_name:
pool = pool[pool['name'] != commander_name] pool = pool[pool['name'] != commander_name]
pool = self._apply_bracket_pre_filters(pool)
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue']) pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
self._debug_dump_pool(pool, 'wipes')
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
names = pool['name'].astype(str).head(30).tolist()
self.output_func(f"[DEBUG][Wipes] Pool size: {len(pool)}; sample: {', '.join(names)}")
except Exception:
pass
if getattr(self, 'prefer_owned', False): if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None) owned_set = getattr(self, 'owned_card_names', None)
if owned_set: if owned_set:
@ -278,6 +418,7 @@ class SpellAdditionMixin:
def is_draw(tags): def is_draw(tags):
return any(('draw' in t) or ('card advantage' in t) for t in tags) return any(('draw' in t) or ('card advantage' in t) for t in tags)
df = df[df['_ltags'].apply(is_draw)] df = df[df['_ltags'].apply(is_draw)]
df = self._apply_bracket_pre_filters(df)
df = df[~df['type'].fillna('').str.contains('Land', case=False, na=False)] df = df[~df['type'].fillna('').str.contains('Land', case=False, na=False)]
commander_name = getattr(self, 'commander', None) commander_name = getattr(self, 'commander', None)
if commander_name: if commander_name:
@ -291,6 +432,19 @@ class SpellAdditionMixin:
return bu.sort_by_priority(d, ['edhrecRank','manaValue']) return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
conditional_df = sortit(conditional_df) conditional_df = sortit(conditional_df)
unconditional_df = sortit(unconditional_df) unconditional_df = sortit(unconditional_df)
self._debug_dump_pool(conditional_df, 'card_advantage_conditional')
self._debug_dump_pool(unconditional_df, 'card_advantage_unconditional')
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
c_names = conditional_df['name'].astype(str).head(30).tolist()
u_names = unconditional_df['name'].astype(str).head(30).tolist()
self.output_func(f"[DEBUG][CardAdv] Total pool: {len(df)}; conditional: {len(conditional_df)}; unconditional: {len(unconditional_df)}")
if c_names:
self.output_func(f"[DEBUG][CardAdv] Conditional sample: {', '.join(c_names)}")
if u_names:
self.output_func(f"[DEBUG][CardAdv] Unconditional sample: {', '.join(u_names)}")
except Exception:
pass
if getattr(self, 'prefer_owned', False): if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None) owned_set = getattr(self, 'owned_card_names', None)
if owned_set: if owned_set:
@ -368,7 +522,15 @@ class SpellAdditionMixin:
commander_name = getattr(self, 'commander', None) commander_name = getattr(self, 'commander', None)
if commander_name: if commander_name:
pool = pool[pool['name'] != commander_name] pool = pool[pool['name'] != commander_name]
pool = self._apply_bracket_pre_filters(pool)
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue']) pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
self._debug_dump_pool(pool, 'protection')
try:
if str(os.getenv('DEBUG_SPELL_POOLS', '')).strip().lower() in {"1","true","yes","on"}:
names = pool['name'].astype(str).head(30).tolist()
self.output_func(f"[DEBUG][Protection] Pool size: {len(pool)}; sample: {', '.join(names)}")
except Exception:
pass
if getattr(self, 'prefer_owned', False): if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None) owned_set = getattr(self, 'owned_card_names', None)
if owned_set: if owned_set:
@ -467,6 +629,7 @@ class SpellAdditionMixin:
~df['type'].str.contains('Land', case=False, na=False) ~df['type'].str.contains('Land', case=False, na=False)
& ~df['type'].str.contains('Creature', case=False, na=False) & ~df['type'].str.contains('Creature', case=False, na=False)
].copy() ].copy()
spells_df = self._apply_bracket_pre_filters(spells_df)
if spells_df.empty: if spells_df.empty:
return return
selected_tags_lower = [t.lower() for _r, t in themes_ordered] selected_tags_lower = [t.lower() for _r, t in themes_ordered]
@ -521,6 +684,7 @@ class SpellAdditionMixin:
if owned_set: if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set}) subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
pool = subset.head(top_n).copy() pool = subset.head(top_n).copy()
pool = self._apply_bracket_pre_filters(pool)
pool = pool[~pool['name'].isin(self.card_library.keys())] pool = pool[~pool['name'].isin(self.card_library.keys())]
if pool.empty: if pool.empty:
continue continue
@ -563,6 +727,7 @@ class SpellAdditionMixin:
if total_added < remaining: if total_added < remaining:
need = remaining - total_added need = remaining - total_added
multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy() multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
multi_pool = self._apply_bracket_pre_filters(multi_pool)
if combine_mode == 'AND' and len(selected_tags_lower) > 1: if combine_mode == 'AND' and len(selected_tags_lower) > 1:
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2] prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
if prioritized.empty: if prioritized.empty:
@ -607,6 +772,7 @@ class SpellAdditionMixin:
if total_added < remaining: if total_added < remaining:
extra_needed = remaining - total_added extra_needed = remaining - total_added
leftover = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy() leftover = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
leftover = self._apply_bracket_pre_filters(leftover)
if not leftover.empty: if not leftover.empty:
if '_normTags' not in leftover.columns: if '_normTags' not in leftover.columns:
leftover['_normTags'] = leftover['themeTags'].apply( leftover['_normTags'] = leftover['themeTags'].apply(

View file

@ -26,6 +26,176 @@ class ReportingMixin:
self.print_card_library(table=True) self.print_card_library(table=True)
"""Phase 6: Reporting, summaries, and export helpers.""" """Phase 6: Reporting, summaries, and export helpers."""
def enforce_and_reexport(self, base_stem: str | None = None, mode: str = "prompt") -> dict:
"""Run bracket enforcement, then re-export CSV/TXT and recompute compliance.
mode: 'prompt' for CLI interactive; 'auto' for headless/web.
Returns the final compliance report dict.
"""
try:
# Lazy import to avoid cycles
from deck_builder.enforcement import enforce_bracket_compliance # type: ignore
except Exception:
self.output_func("Enforcement module unavailable.")
return {}
# Enforce
report = enforce_bracket_compliance(self, mode=mode)
# If enforcement removed cards without enough replacements, top up to 100 using theme filler
try:
total_cards = 0
for _n, _e in getattr(self, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'):
before = int(total_cards)
try:
self.fill_remaining_theme_spells() # type: ignore[attr-defined]
except Exception:
pass
# Recompute after filler
try:
total_cards = 0
for _n, _e in getattr(self, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = before
try:
self.output_func(f"Topped up deck to {total_cards}/100 after enforcement.")
except Exception:
pass
except Exception:
pass
# Print what changed
try:
enf = report.get('enforcement') or {}
removed = list(enf.get('removed') or [])
added = list(enf.get('added') or [])
if removed or added:
self.output_func("\nEnforcement Summary (swaps):")
if removed:
self.output_func("Removed:")
for n in removed:
self.output_func(f" - {n}")
if added:
self.output_func("Added:")
for n in added:
self.output_func(f" + {n}")
except Exception:
pass
# Re-export using same base, if provided
try:
import os as _os
import json as _json
if isinstance(base_stem, str) and base_stem.strip():
# Mirror CSV/TXT export naming
csv_name = base_stem + ".csv"
txt_name = base_stem + ".txt"
# Overwrite exports with updated library
self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) # type: ignore[attr-defined]
self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # type: ignore[attr-defined]
# Recompute and write compliance next to them
self.compute_and_print_compliance(base_stem=base_stem) # type: ignore[attr-defined]
# Inject enforcement details into the saved compliance JSON for UI transparency
comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
try:
if _os.path.exists(comp_path) and isinstance(report, dict) and report.get('enforcement'):
with open(comp_path, 'r', encoding='utf-8') as _f:
comp_obj = _json.load(_f)
comp_obj['enforcement'] = report.get('enforcement')
with open(comp_path, 'w', encoding='utf-8') as _f:
_json.dump(comp_obj, _f, indent=2)
except Exception:
pass
else:
# Fall back to default export flow
csv_path = self.export_decklist_csv() # type: ignore[attr-defined]
try:
base, _ = _os.path.splitext(csv_path)
base_only = _os.path.basename(base)
except Exception:
base_only = None
self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # type: ignore[attr-defined]
if base_only:
self.compute_and_print_compliance(base_stem=base_only) # type: ignore[attr-defined]
# Inject enforcement into written JSON as above
try:
comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json")
if _os.path.exists(comp_path) and isinstance(report, dict) and report.get('enforcement'):
with open(comp_path, 'r', encoding='utf-8') as _f:
comp_obj = _json.load(_f)
comp_obj['enforcement'] = report.get('enforcement')
with open(comp_path, 'w', encoding='utf-8') as _f:
_json.dump(comp_obj, _f, indent=2)
except Exception:
pass
except Exception:
pass
return report
def compute_and_print_compliance(self, base_stem: str | None = None) -> dict:
"""Compute bracket compliance, print a compact summary, and optionally write a JSON report.
If base_stem is provided, writes deck_files/{base_stem}_compliance.json.
Returns the compliance report dict.
"""
try:
# Late import to avoid circulars in some environments
from deck_builder.brackets_compliance import evaluate_deck # type: ignore
except Exception:
self.output_func("Bracket compliance module unavailable.")
return {}
try:
bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower()
commander = getattr(self, 'commander_name', None)
report = evaluate_deck(self.card_library, commander_name=commander, bracket=bracket_key)
except Exception as e:
self.output_func(f"Compliance evaluation failed: {e}")
return {}
# Print concise summary
try:
self.output_func("\nBracket Compliance:")
self.output_func(f" Overall: {report.get('overall', 'PASS')}")
cats = report.get('categories', {}) or {}
order = [
('game_changers', 'Game Changers'),
('mass_land_denial', 'Mass Land Denial'),
('extra_turns', 'Extra Turns'),
('tutors_nonland', 'Nonland Tutors'),
('two_card_combos', 'Two-Card Combos'),
]
for key, label in order:
c = cats.get(key, {}) or {}
cnt = int(c.get('count', 0) or 0)
lim = c.get('limit')
status = str(c.get('status') or 'PASS')
lim_txt = ('Unlimited' if lim is None else str(int(lim)))
self.output_func(f" {label:<16} {cnt} / {lim_txt} [{status}]")
except Exception:
pass
# Optionally write JSON report next to exports
if isinstance(base_stem, str) and base_stem.strip():
try:
import os as _os
_os.makedirs('deck_files', exist_ok=True)
path = _os.path.join('deck_files', f"{base_stem}_compliance.json")
import json as _json
with open(path, 'w', encoding='utf-8') as f:
_json.dump(report, f, indent=2)
self.output_func(f"Compliance report saved to {path}")
except Exception:
pass
return report
def _wrap_cell(self, text: str, width: int = 28) -> str: def _wrap_cell(self, text: str, width: int = 28) -> str:
"""Wraps a string to a specified width for table display. """Wraps a string to a specified width for table display.
Used for pretty-printing card names, roles, and tags in tabular output. Used for pretty-printing card names, roles, and tags in tabular output.

View file

@ -0,0 +1,120 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, Iterable, Set
import pandas as pd
def _ensure_norm_series(df: pd.DataFrame, source_col: str, norm_col: str) -> pd.Series:
"""Minimal normalized string cache (subset of tag_utils)."""
if norm_col in df.columns:
return df[norm_col]
series = df[source_col].fillna('') if source_col in df.columns else pd.Series([''] * len(df), index=df.index)
series = series.astype(str)
df[norm_col] = series
return df[norm_col]
def _apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series, tags):
"""Minimal tag applier (subset of tag_utils)."""
if not isinstance(tags, list):
tags = [tags]
current = df.loc[mask, 'themeTags']
df.loc[mask, 'themeTags'] = current.apply(lambda x: sorted(list(set((x if isinstance(x, list) else []) + tags))))
try:
import logging_util
except Exception:
# Fallback for direct module loading
import importlib.util # type: ignore
root = Path(__file__).resolve().parents[1]
lu_path = root / 'logging_util.py'
spec = importlib.util.spec_from_file_location('logging_util', str(lu_path))
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
assert spec and spec.loader
spec.loader.exec_module(mod) # type: ignore[assignment]
logging_util = mod # type: ignore
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
POLICY_FILES: Dict[str, str] = {
'Bracket:GameChanger': 'config/card_lists/game_changers.json',
'Bracket:ExtraTurn': 'config/card_lists/extra_turns.json',
'Bracket:MassLandDenial': 'config/card_lists/mass_land_denial.json',
'Bracket:TutorNonland': 'config/card_lists/tutors_nonland.json',
}
def _canonicalize(name: str) -> str:
"""Normalize names for robust matching.
- casefold
- strip spaces
- normalize common unicode apostrophes
- drop Alchemy/Arena prefix "A-"
"""
if name is None:
return ''
s = str(name).strip().replace('\u2019', "'")
if s.startswith('A-') and len(s) > 2:
s = s[2:]
return s.casefold()
def _load_names_from_list(file_path: str | Path) -> Set[str]:
p = Path(file_path)
if not p.exists():
logger.warning('Bracket policy list missing: %s', p)
return set()
try:
data = json.loads(p.read_text(encoding='utf-8'))
names: Iterable[str] = data.get('cards', []) or []
return { _canonicalize(n) for n in names }
except Exception as e:
logger.error('Failed to read policy list %s: %s', p, e)
return set()
def _build_name_series(df: pd.DataFrame) -> pd.Series:
# Combine name and faceName if available, prefer exact name but fall back to faceName text
name_series = _ensure_norm_series(df, 'name', '__name_s')
if 'faceName' in df.columns:
face_series = _ensure_norm_series(df, 'faceName', '__facename_s')
# Use name when present, else facename
combined = name_series.copy()
combined = combined.where(name_series.astype(bool), face_series)
return combined
return name_series
def apply_bracket_policy_tags(df: pd.DataFrame) -> None:
"""Apply Bracket:* tags to rows whose name is present in policy lists.
Mutates df['themeTags'] in place.
"""
if len(df) == 0:
return
name_series = _build_name_series(df)
canon_series = name_series.apply(_canonicalize)
total_tagged = 0
for tag, file in POLICY_FILES.items():
names = _load_names_from_list(file)
if not names:
continue
mask = canon_series.isin(names)
if mask.any():
_apply_tag_vectorized(df, mask, [tag])
count = int(mask.sum())
total_tagged += count
logger.info('Applied %s to %d cards', tag, count)
if total_tagged == 0:
logger.info('No Bracket:* tags applied (no matches or lists empty).')

View file

@ -11,6 +11,7 @@ import pandas as pd
# Local application imports # Local application imports
from . import tag_utils from . import tag_utils
from . import tag_constants from . import tag_constants
from .bracket_policy_applier import apply_bracket_policy_tags
from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS, COLORS from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS, COLORS
import logging_util import logging_util
from file_setup import setup from file_setup import setup
@ -163,6 +164,10 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
tag_for_interaction(df, color) tag_for_interaction(df, color)
print('\n====================\n') print('\n====================\n')
# Apply bracket policy tags (from config/card_lists/*.json)
apply_bracket_policy_tags(df)
print('\n====================\n')
# Lastly, sort all theme tags for easier reading and reorder columns # Lastly, sort all theme tags for easier reading and reorder columns
df = sort_theme_tags(df, color) df = sort_theme_tags(df, color)
df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False) df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False)

View file

@ -0,0 +1,44 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import pandas as pd
def _load_applier():
root = Path(__file__).resolve().parents[2]
mod_path = root / 'code' / 'tagging' / 'bracket_policy_applier.py'
spec = importlib.util.spec_from_file_location('bracket_policy_applier', str(mod_path))
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
assert spec and spec.loader
spec.loader.exec_module(mod) # type: ignore[assignment]
return mod
def test_apply_bracket_policy_tags(tmp_path: Path, monkeypatch):
# Create minimal DataFrame
df = pd.DataFrame([
{ 'name': "Time Warp", 'faceName': '', 'text': '', 'type': 'Sorcery', 'keywords': '', 'creatureTypes': [], 'themeTags': [] },
{ 'name': "Armageddon", 'faceName': '', 'text': '', 'type': 'Sorcery', 'keywords': '', 'creatureTypes': [], 'themeTags': [] },
{ 'name': "Demonic Tutor", 'faceName': '', 'text': '', 'type': 'Sorcery', 'keywords': '', 'creatureTypes': [], 'themeTags': [] },
{ 'name': "Forest", 'faceName': '', 'text': '', 'type': 'Basic Land — Forest', 'keywords': '', 'creatureTypes': [], 'themeTags': [] },
])
# Ensure the JSON lists exist with expected names
lists_dir = Path('config/card_lists')
lists_dir.mkdir(parents=True, exist_ok=True)
(lists_dir / 'extra_turns.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Time Warp'] }), encoding='utf-8')
(lists_dir / 'mass_land_denial.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Armageddon'] }), encoding='utf-8')
(lists_dir / 'tutors_nonland.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': ['Demonic Tutor'] }), encoding='utf-8')
(lists_dir / 'game_changers.json').write_text(json.dumps({ 'source_url': 'test', 'generated_at': 'now', 'cards': [] }), encoding='utf-8')
mod = _load_applier()
mod.apply_bracket_policy_tags(df)
row = df.set_index('name')
assert any('Bracket:ExtraTurn' == t for t in row.loc['Time Warp', 'themeTags'])
assert any('Bracket:MassLandDenial' == t for t in row.loc['Armageddon', 'themeTags'])
assert any('Bracket:TutorNonland' == t for t in row.loc['Demonic Tutor', 'themeTags'])
assert not row.loc['Forest', 'themeTags']

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from deck_builder.brackets_compliance import evaluate_deck
def _mk_card(tags: list[str] | None = None):
return {
"Card Name": "X",
"Card Type": "Sorcery",
"Tags": list(tags or []),
"Count": 1,
}
def test_exhibition_fails_on_game_changer():
deck = {
"Sol Ring": _mk_card(["Bracket:GameChanger"]),
"Cultivate": _mk_card([]),
}
rep = evaluate_deck(deck, commander_name=None, bracket="exhibition")
assert rep["level"] == 1
assert rep["categories"]["game_changers"]["status"] == "FAIL"
assert rep["overall"] == "FAIL"
def test_core_allows_some_extra_turns_but_fails_over_limit():
deck = {
f"Time Warp {i}": _mk_card(["Bracket:ExtraTurn"]) for i in range(1, 5)
}
rep = evaluate_deck(deck, commander_name=None, bracket="core")
assert rep["level"] == 2
assert rep["categories"]["extra_turns"]["limit"] == 3
assert rep["categories"]["extra_turns"]["count"] == 4
assert rep["categories"]["extra_turns"]["status"] == "FAIL"
assert rep["overall"] == "FAIL"
def test_two_card_combination_detection_respects_cheap_early():
deck = {
"Thassa's Oracle": _mk_card([]),
"Demonic Consultation": _mk_card([]),
"Isochron Scepter": _mk_card([]),
"Dramatic Reversal": _mk_card([]),
}
# Exhibition should fail due to presence of a cheap/early pair
rep1 = evaluate_deck(deck, commander_name=None, bracket="exhibition")
assert rep1["categories"]["two_card_combos"]["count"] >= 1
assert rep1["categories"]["two_card_combos"]["status"] == "FAIL"
# Optimized has no limit
rep2 = evaluate_deck(deck, commander_name=None, bracket="optimized")
assert rep2["categories"]["two_card_combos"]["limit"] is None
assert rep2["overall"] == "PASS"

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List, TypedDict, Union from typing import Dict, List, TypedDict, Union, Optional, Literal
import pandas as pd import pandas as pd
class CardDict(TypedDict): class CardDict(TypedDict):
@ -48,3 +48,24 @@ InstantDF = pd.DataFrame
PlaneswalkerDF = pd.DataFrame PlaneswalkerDF = pd.DataFrame
NonPlaneswalkerDF = pd.DataFrame NonPlaneswalkerDF = pd.DataFrame
SorceryDF = pd.DataFrame SorceryDF = pd.DataFrame
# Bracket compliance typing
Verdict = Literal["PASS", "WARN", "FAIL"]
class CategoryFinding(TypedDict, total=False):
count: int
limit: Optional[int]
flagged: List[str]
status: Verdict
notes: List[str]
class ComplianceReport(TypedDict, total=False):
bracket: str
level: int
enforcement: Literal["validate", "prefer", "strict"]
overall: Verdict
commander_flagged: bool
categories: Dict[str, CategoryFinding]
combos: List[Dict[str, Union[str, bool]]]
list_versions: Dict[str, Optional[str]]
messages: List[str]

View file

@ -133,7 +133,11 @@ async def build_index(request: Request) -> HTMLResponse:
return resp 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) @router.get("/multicopy/check", response_class=HTMLResponse)
async def multicopy_check(request: Request) -> 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 = orch.recommended_tags_for_commander(info["name"]) if tags else []
recommended_reasons = orch.recommended_tag_reasons_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 # 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 = { ctx = {
"request": request, "request": request,
"commander": {"name": info["name"]}, "commander": {"name": info["name"]},
"tags": tags, "tags": tags,
"recommended": recommended, "recommended": recommended,
"recommended_reasons": recommended_reasons, "recommended_reasons": recommended_reasons,
"gc_commander": is_gc,
"brackets": orch.bracket_options(),
} }
return templates.TemplateResponse("build/_new_deck_tags.html", ctx) 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 = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp 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 # Save to session
sess["commander"] = sel.get("name") or commander sess["commander"] = sel.get("name") or commander
tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] 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": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(), "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, "clear_persisted": True,
}, },
) )
@ -712,6 +737,12 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
except Exception: except Exception:
pass pass
sess["last_step"] = 2 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( resp = templates.TemplateResponse(
"build/_step2.html", "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": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(), "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, # Signal that this navigation came from a fresh commander confirmation,
# so the Step 2 UI should clear any localStorage theme persistence. # so the Step 2 UI should clear any localStorage theme persistence.
"clear_persisted": True, "clear_persisted": True,
@ -830,6 +863,20 @@ async def build_step2_get(request: Request) -> HTMLResponse:
return resp return resp
tags = orch.tags_for_commander(commander) tags = orch.tags_for_commander(commander)
selected = sess.get("tags", []) 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( resp = templates.TemplateResponse(
"build/_step2.html", "build/_step2.html",
{ {
@ -842,8 +889,9 @@ async def build_step2_get(request: Request) -> HTMLResponse:
"primary_tag": selected[0] if len(selected) > 0 else "", "primary_tag": selected[0] if len(selected) > 0 else "",
"secondary_tag": selected[1] if len(selected) > 1 else "", "secondary_tag": selected[1] if len(selected) > 1 else "",
"tertiary_tag": selected[2] if len(selected) > 2 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"), "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 # If there are no server-side tags for this commander, let the client clear any persisted ones
# to avoid themes sticking between fresh runs. # to avoid themes sticking between fresh runs.
"clear_persisted": False if selected else True, "clear_persisted": False if selected else True,
@ -869,6 +917,18 @@ async def build_step2_submit(
sid = request.cookies.get("sid") or new_sid() sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid) sess = get_session(sid)
sess["last_step"] = 2 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( resp = templates.TemplateResponse(
"build/_step2.html", "build/_step2.html",
{ {
@ -882,13 +942,26 @@ async def build_step2_submit(
"primary_tag": primary_tag or "", "primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "", "secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_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"), "tag_mode": (tag_mode or "AND"),
"gc_commander": is_gc,
}, },
) )
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp 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) # Save selection to session (basic MVP; real build will use this later)
sid = request.cookies.get("sid") or new_sid() sid = request.cookies.get("sid") or new_sid()
sess = get_session(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}" sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception: except Exception:
pass pass
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5 sess["last_step"] = 5
ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx2) 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}" sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}"
except Exception: except Exception:
pass pass
# Note: no redirect; the inline compliance panel will render inside Step 5
sess["last_step"] = 5 sess["last_step"] = 5
ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped)
resp = templates.TemplateResponse("build/_step5.html", ctx) 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) @router.get("/alternatives", response_class=HTMLResponse)
async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> 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() sid = request.cookies.get("sid") or new_sid()
sess = get_session(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>' html = '<div class="alts"><div class="muted">Start the build to see alternatives.</div></div>'
return HTMLResponse(html) return HTMLResponse(html)
try: 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() commander_l = str((sess.get("commander") or "")).strip().lower()
locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])}
# Check cache: key = (seed, commander, require_owned) # Exclusions from prior inline replacements
cache_key = (name_l, commander_l, require_owned) 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) cached = _alts_get_cached(cache_key)
if cached is not None: if cached is not None:
return HTMLResponse(cached) 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 {} tags_idx = getattr(b, "_card_name_tags_index", {}) or {}
seed_tags = set(tags_idx.get(name_l) 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()) all_names = set(tags_idx.keys())
candidates: list[tuple[str, int]] = [] # (name, score) candidates: list[tuple[str, int]] = [] # (name, score)
for nm in all_names: for nm in all_names:
if nm == name_l: if nm == name_l:
continue continue
# Exclude commander and any names we believe are already in the current deck
if commander_l and nm == commander_l: if commander_l and nm == commander_l:
continue continue
if in_deck and nm in in_deck: if in_deck and nm in in_deck:
continue continue
# Also exclude any card currently locked (these are intended to be kept)
if locked_set and nm in locked_set: if locked_set and nm in locked_set:
continue continue
if nm in alts_exclude:
continue
tgs = set(tags_idx.get(nm) or []) tgs = set(tags_idx.get(nm) or [])
score = len(seed_tags & tgs) score = len(seed_tags & tgs)
if score <= 0: if score <= 0:
continue continue
candidates.append((nm, score)) candidates.append((nm, score))
# If no tag-based candidates, try using same trigger tag if present # If no tag-based candidates, try shared trigger tag from library entry
if not candidates and isinstance(lib_entry, dict): if not candidates and isinstance(entry, dict):
try: try:
trig = str(lib_entry.get("TriggerTag") or "").strip().lower() trig = str(entry.get("TriggerTag") or "").strip().lower()
except Exception: except Exception:
trig = "" trig = ""
if trig: if trig:
@ -1655,15 +1905,11 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
continue continue
if trig in {str(t).strip().lower() for t in (tglist or [])}: if trig in {str(t).strip().lower() for t in (tglist or [])}:
candidates.append((nm, 1)) candidates.append((nm, 1))
# Sort by score desc, then owned-first, then name asc
def _owned(nm: str) -> bool: def _owned(nm: str) -> bool:
return nm in owned_set return nm in owned_set
candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) 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} pool_lower = {nm for (nm, _s) in candidates}
display_map: dict[str, str] = builder_display_map(b, pool_lower) display_map = _display_map_for(pool_lower)
# Build structured items for the partial
items: list[dict] = []
seen = set() seen = set()
for nm, score in candidates: for nm, score in candidates:
if nm in seen: 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) is_owned = (nm in owned_set)
if require_owned and not is_owned: if require_owned and not is_owned:
continue continue
disp = display_map.get(nm, nm)
items.append({ items.append({
"name": disp, "name": display_map.get(nm, nm),
"name_lower": nm, "name_lower": nm,
"owned": is_owned, "owned": is_owned,
"tags": list(tags_idx.get(nm) or []), "tags": list(tags_idx.get(nm) or []),
}) })
if len(items) >= 10: if len(items) >= 10:
break break
# Render partial via Jinja template and cache it return _render_and_cache(items)
ctx2 = {"request": request, "name": name, "require_owned": require_owned, "items": items}
html_str = templates.get_template("build/_alternatives.html").render(ctx2)
_alts_set_cached(cache_key, html_str)
return HTMLResponse(html_str)
except Exception as e: except Exception as e:
return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>') return HTMLResponse(f'<div class="alts"><div class="muted">No alternatives: {e}</div></div>')
@router.post("/replace", response_class=HTMLResponse) @router.post("/replace", response_class=HTMLResponse)
async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> 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() sid = request.cookies.get("sid") or new_sid()
sess = get_session(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", [])) 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.discard(o)
locks.add(n) locks.add(n)
sess["locks"] = list(locks) 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} sess["last_replace"] = {"old": o, "new": n}
except Exception: except Exception:
pass 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: 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: except Exception:
# Fall back to rerun guidance if inline swap fails
pass pass
# Return a small confirmation with a shortcut to rerun # Fallback: advise rerun
hint = ( hint = (
'<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">' '<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>' 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;">' '<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;">' '<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">'
'<input type="hidden" name="show_skipped" value="1" />' '<input type="hidden" name="show_skipped" value="1" />'
'<button type="submit" class="btn-rerun">Rerun stage</button>' '<button type="submit" class="btn-rerun">Rerun stage</button>'
'</form>' '</form>'
'<form hx-post="/build/replace/undo" hx-target="closest .alts" hx-swap="outerHTML" style="display:inline; margin:0;">' '<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="old" value="{old}" />'
f'<input type="hidden" name="new" value="{new}" />' f'<input type="hidden" name="new" value="{new}" />'
'<button type="submit" class="btn" title="Undo this replace">Undo</button>' '<button type="submit" class="btn" title="Undo this replace">Undo</button>'
'</form>' '</form>'
'<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>' '<button type="button" class="btn" onclick="try{this.closest(\'.alts\').remove();}catch(_){}">Close</button>'
'</div>' '</div>'
'</div>' '</div>'
) )
# Also emit an OOB last-action chip
chip = ( chip = (
f'<div id="last-action" hx-swap-oob="true">' 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'<span class="chip" title="Click to dismiss">Replaced <strong>{old}</strong> → <strong>{new}</strong></span>'
f'</div>' 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) return HTMLResponse(hint + chip)
@ -1804,6 +2225,288 @@ async def build_compare(runA: str, runB: str):
return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) 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") @router.get("/permalink")
async def build_permalink(request: Request): async def build_permalink(request: Request):
"""Return a URL-safe JSON payload representing current run config (basic).""" """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, "csv_path": res.get("csv_path") if done else None,
"txt_path": res.get("txt_path") if done else None, "txt_path": res.get("txt_path") if done else None,
"summary": res.get("summary") 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), "show_skipped": bool(show_skipped),
"total_cards": res.get("total_cards"), "total_cards": res.get("total_cards"),
"added_total": res.get("added_total"), "added_total": res.get("added_total"),
@ -125,6 +126,7 @@ def step5_ctx_from_result(
"clamped_overflow": res.get("clamped_overflow"), "clamped_overflow": res.get("clamped_overflow"),
"mc_summary": res.get("mc_summary"), "mc_summary": res.get("mc_summary"),
"skipped": bool(res.get("skipped")), "skipped": bool(res.get("skipped")),
"gated": bool(res.get("gated")),
} }
if extras: if extras:
ctx.update(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', 'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck',
): ):
_add_names(getattr(builder, attr, None)) _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'): for attr in ('current_names', 'deck_names', 'final_names'):
val = getattr(builder, attr, None) val = getattr(builder, attr, None)
if isinstance(val, (list, tuple, set)): if isinstance(val, (list, tuple, set)):

View file

@ -14,6 +14,163 @@ import unicodedata
from glob import glob 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]: def commander_names() -> List[str]:
tmp = DeckBuilder() tmp = DeckBuilder()
df = tmp.load_commander_data() 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: except Exception:
pass pass
# Defaults for return payload
csv_path = None
txt_path = None
summary = None
compliance_obj = None
try: try:
# Provide a no-op input function so any leftover prompts auto-accept defaults # Provide a no-op input function so any leftover prompts auto-accept defaults
b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True) 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: try:
b.determine_color_identity() b.determine_color_identity()
b.setup_dataframes() 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: except Exception as e:
out(f"Failed to load color identity/card pool: {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: except Exception as e:
out(f"Post-spell land adjust failed: {e}") out(f"Post-spell land adjust failed: {e}")
# Reporting/exports # Reporting/exports
csv_path = None
txt_path = None
try: try:
if hasattr(b, 'run_reporting_phase'): if hasattr(b, 'run_reporting_phase'):
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) b._display_txt_contents(txt_path)
except Exception: except Exception:
pass 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: except Exception as e:
out(f"Text export failed: {e}") out(f"Text export failed: {e}")
# Build structured summary for UI # Build structured summary for UI
summary = None
try: try:
if hasattr(b, 'build_deck_summary'): if hasattr(b, 'build_deck_summary'):
summary = b.build_deck_summary() # type: ignore[attr-defined] 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) _json.dump(payload, f, ensure_ascii=False, indent=2)
except Exception: except Exception:
pass 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: except Exception as e:
logs.append(f"Build failed: {e}") logs.append(f"Build failed: {e}")
return {"ok": False, "error": str(e), "log": "\n".join(logs)} 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)) prefer_c = bool(getattr(b, 'prefer_combos', False))
except Exception: except Exception:
prefer_c = False 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__"}) 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 # Ensure we include the theme filler step to top up to 100 cards
if callable(getattr(b, 'fill_remaining_theme_spells', None)): 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'): elif hasattr(b, 'add_spells_phase'):
# For monolithic spells, insert combos BEFORE the big spells stage so additions aren't clamped away # For monolithic spells, insert combos BEFORE the big spells stage so additions aren't clamped away
try: 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__"}) stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"})
except Exception: except Exception:
pass pass
@ -1240,6 +1447,8 @@ def start_build_ctx(
# Data load # Data load
b.determine_color_identity() b.determine_color_identity()
b.setup_dataframes() 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 # Thread multi-copy selection onto builder for stage generation/runner
try: try:
b._web_multi_copy = (multi_copy or None) 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)) setattr(b, 'custom_export_base', str(custom_base))
except Exception: except Exception:
pass 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: try:
ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined]
except Exception as e: 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] b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
except Exception: except Exception:
pass 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: except Exception as e:
logs.append(f"Text export failed: {e}") logs.append(f"Text export failed: {e}")
# Final lock enforcement before finishing # 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"), "csv_path": ctx.get("csv_path"),
"txt_path": ctx.get("txt_path"), "txt_path": ctx.get("txt_path"),
"summary": summary, "summary": summary,
"compliance": ctx.get("compliance"),
} }
# Determine which stage index to run (rerun last visible, else current) # 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: else:
i = ctx["idx"] 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 # Iterate forward until we find a stage that adds cards, skipping no-ops
while i < len(stages): while i < len(stages):
stage = stages[i] stage = stages[i]
@ -1866,6 +2149,45 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
except Exception: except Exception:
clamped_overflow = 0 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 this stage added cards, present it and advance idx
if added_cards: if added_cards:
# Progress counts # 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 # No cards added: either skip or surface as a 'skipped' stage
if show_skipped: 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 # Progress counts even when skipped
try: try:
total_cards = 0 total_cards = 0
@ -1945,7 +2299,39 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"added_total": 0, "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 i += 1
# Continue loop to auto-advance # 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] b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined]
except Exception: except Exception:
pass 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: except Exception as e:
logs.append(f"Text export failed: {e}") logs.append(f"Text export failed: {e}")
# Build structured summary for UI # 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, "summary": summary,
"total_cards": total_cards, "total_cards": total_cards,
"added_total": 0, "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" /> <input type="hidden" name="tag_mode" value="AND" />
</div> </div>
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></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 <label>Bracket
<select name="bracket"> <select name="bracket">
{% for b in brackets %} {% 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 %} {% endfor %}
</select> </select>
</label> </label>

View file

@ -62,6 +62,22 @@
</div> </div>
</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> <script>
(function(){ (function(){
var list = document.getElementById('modal-tag-list'); var list = document.getElementById('modal-tag-list');

View file

@ -76,10 +76,12 @@
<legend>Budget/Power Bracket</legend> <legend>Budget/Power Bracket</legend>
<div style="display:grid; gap:.5rem;"> <div style="display:grid; gap:.5rem;">
{% for b in brackets %} {% for b in brackets %}
{% if not gc_commander or b.level >= 3 %}
<label style="display:flex; gap:.5rem; align-items:flex-start;"> <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 %} /> <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> <span><strong>{{ b.name }}</strong><small>{{ b.desc }}</small></span>
</label> </label>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<div class="muted" style="margin-top:.35rem; font-size:.9em;"> <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 %} <strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
</div> </div>
{% endif %} {% 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') %} {% if status and status.startswith('Build complete') %}
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div> <div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
{% endif %} {% endif %}
@ -144,11 +151,11 @@
</form> </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(_){}"> <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' }}" /> <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>
<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(_){}"> <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' }}" /> <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> </form>
<span class="sep"></span> <span class="sep"></span>
<div class="replace-toggle" role="group" aria-label="Replace toggle"> <div class="replace-toggle" role="group" aria-label="Replace toggle">
@ -305,11 +312,9 @@
<!-- controls now above --> <!-- controls now above -->
{% if status and status.startswith('Build complete') %} {% if status and status.startswith('Build complete') and summary %}
{% if summary %}
{% include "partials/deck_summary.html" %} {% include "partials/deck_summary.html" %}
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
</section> </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 %}

47
config/brackets.yml Normal file
View file

@ -0,0 +1,47 @@
# Bracket policy limits (None means unlimited)
# Mirrors defaults in code.deck_builder.phases.phase0_core.BRACKET_DEFINITIONS
exhibition:
level: 1
name: Exhibition
limits:
game_changers: 0
mass_land_denial: 0
extra_turns: 0
tutors_nonland: 3
two_card_combos: 0
core:
level: 2
name: Core
limits:
game_changers: 0
mass_land_denial: 0
extra_turns: 3
tutors_nonland: 3
two_card_combos: 0
upgraded:
level: 3
name: Upgraded
limits:
game_changers: 3
mass_land_denial: 0
extra_turns: 3
tutors_nonland: null
two_card_combos: 0
optimized:
level: 4
name: Optimized
limits:
game_changers: null
mass_land_denial: null
extra_turns: null
tutors_nonland: null
two_card_combos: null
cedh:
level: 5
name: cEDH
limits:
game_changers: null
mass_land_denial: null
extra_turns: null
tutors_nonland: null
two_card_combos: null

View file

@ -1,6 +1,6 @@
{ {
"list_version": "0.3.0", "list_version": "0.3.0",
"generated_at": null, "generated_at": "2025-09-03T15:30:32+00:00",
"pairs": [ "pairs": [
{ "a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }, { "a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },
{ "a": "Thassa's Oracle", "b": "Tainted Pact", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }, { "a": "Thassa's Oracle", "b": "Tainted Pact", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] },

View file

@ -0,0 +1 @@
{"source_url": "test", "generated_at": "now", "cards": ["Time Warp"]}

View file

@ -0,0 +1 @@
{"source_url": "test", "generated_at": "now", "cards": []}

View file

@ -0,0 +1 @@
{"source_url": "test", "generated_at": "now", "cards": ["Armageddon"]}

View file

@ -1,6 +1,6 @@
{ {
"list_version": "0.4.0", "list_version": "0.4.0",
"generated_at": null, "generated_at": "2025-09-03T15:30:40+00:00",
"pairs": [ "pairs": [
{ "a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats", "value"], "notes": "Sacrifice enables repeated edicts" }, { "a": "Grave Pact", "b": "Phyrexian Altar", "tags": ["aristocrats", "value"], "notes": "Sacrifice enables repeated edicts" },
{ "a": "Panharmonicon", "b": "Mulldrifter", "tags": ["etb", "value"], "notes": "Amplifies ETB triggers" } { "a": "Panharmonicon", "b": "Mulldrifter", "tags": ["etb", "value"], "notes": "Amplifies ETB triggers" }

View file

@ -0,0 +1 @@
{"source_url": "test", "generated_at": "now", "cards": ["Demonic Tutor"]}

View file

@ -1,29 +1,4 @@
services: services:
# Command line driven build
# mtg-deckbuilder:
# build: .
# container_name: mtg-deckbuilder-main
# stdin_open: true # Equivalent to docker run -i
# tty: true # Equivalent to docker run -t
# volumes:
# - ${PWD}/deck_files:/app/deck_files
# - ${PWD}/logs:/app/logs
# - ${PWD}/csv_files:/app/csv_files
# # Optional: mount a config directory for headless JSON and owned cards
# - ${PWD}/config:/app/config
# - ${PWD}/owned_cards:/app/owned_cards
# environment:
# - PYTHONUNBUFFERED=1
# - TERM=xterm-256color
# - DEBIAN_FRONTEND=noninteractive
# # Set DECK_MODE=headless to auto-run non-interactive mode on start
# # - DECK_MODE=headless
# # Optional headless configuration (examples):
# # - DECK_CONFIG=/app/config/deck.json
# # - DECK_COMMANDER=Pantlaza
# # Ensure proper cleanup
# restart: "no"
web: web:
build: . build: .
container_name: mtg-deckbuilder-web container_name: mtg-deckbuilder-web
@ -33,21 +8,66 @@ services:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
TERM: "xterm-256color" TERM: "xterm-256color"
DEBIAN_FRONTEND: "noninteractive" DEBIAN_FRONTEND: "noninteractive"
# Default theme for first-time visitors (no local preference yet): system|light|dark
# When set to 'light', it maps to the consolidated Light (Blend) palette in the UI # UI features/flags
# ENABLE_THEMES: "1" SHOW_LOGS: "1" # 1=enable /logs page; 0=hide
THEME: "dark" SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1)
# Logging and error utilities SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide
SHOW_LOGS: "1" ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental)
SHOW_DIAGNOSTICS: "1" ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied)
# ENABLE_PWA: "1" ENABLE_PRESETS: "0" # 1=show presets section
# Speed up setup/tagging in Web UI via parallel workers WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
WEB_TAG_PARALLEL: "1"
WEB_TAG_WORKERS: "4" # Theming
# Enable virtualization + lazy image tweaks in Step 5 THEME: "dark" # system|light|dark
WEB_VIRTUALIZE: "1"
# Version label (optional; shown in footer/diagnostics) # Setup/Tagging performance
APP_VERSION: "v2.2.3" WEB_AUTO_SETUP: "1" # 1=auto-run setup/tagging when needed
WEB_AUTO_REFRESH_DAYS: "7" # Refresh cards.csv if older than N days; 0=never
WEB_TAG_PARALLEL: "1" # 1=parallelize tagging
WEB_TAG_WORKERS: "4" # Worker count when parallel tagging
# Compliance/exports
WEB_AUTO_ENFORCE: "0" # 1=auto-apply bracket enforcement and re-export
APP_VERSION: "v2.2.5" # Optional label shown in footer
# WEB_CUSTOM_EXPORT_BASE: "" # Optional custom export basename
# Paths (optional overrides)
# DECK_EXPORTS: "/app/deck_files" # Where the deck browser looks for exports
# DECK_CONFIG: "/app/config" # Where the config browser looks for *.json
# OWNED_CARDS_DIR: "/app/owned_cards" # Preferred path for owned inventory uploads
# CARD_LIBRARY_DIR: "/app/owned_cards" # Back-compat alias for OWNED_CARDS_DIR
# Headless-only settings
# DECK_MODE: "headless" # Auto-run headless flow in CLI mode
# HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON
# DECK_COMMANDER: "" # Commander name query
# DECK_PRIMARY_CHOICE: "1" # Primary tag index (1-based)
# DECK_SECONDARY_CHOICE: "" # Optional secondary index
# DECK_TERTIARY_CHOICE: "" # Optional tertiary index
# DECK_PRIMARY_TAG: "" # Or tag names instead of indices
# DECK_SECONDARY_TAG: ""
# DECK_TERTIARY_TAG: ""
# DECK_BRACKET_LEVEL: "3" # 15
# DECK_ADD_LANDS: "1"
# DECK_ADD_CREATURES: "1"
# DECK_ADD_NON_CREATURE_SPELLS: "1"
# DECK_ADD_RAMP: "1"
# DECK_ADD_REMOVAL: "1"
# DECK_ADD_WIPES: "1"
# DECK_ADD_CARD_ADVANTAGE: "1"
# DECK_ADD_PROTECTION: "1"
# DECK_FETCH_COUNT: "3"
# DECK_DUAL_COUNT: ""
# DECK_TRIPLE_COUNT: ""
# DECK_UTILITY_COUNT: ""
# DECK_TAG_MODE: "AND" # AND|OR (if supported)
# Entrypoint knobs (only if you change the entrypoint behavior)
# APP_MODE: "web" # web|cli — selects uvicorn vs CLI
# HOST: "0.0.0.0" # Uvicorn bind host
# PORT: "8080" # Uvicorn port
# WORKERS: "1" # Uvicorn workers
volumes: volumes:
- ${PWD}/deck_files:/app/deck_files - ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs - ${PWD}/logs:/app/logs

View file

@ -1,34 +1,77 @@
services: services:
web: web:
image: mwisnowski/mtg-python-deckbuilder:latest image: mwisnowski/mtg-python-deckbuilder:latest
# Tip: pin to a specific tag when available, e.g. :2.2.2
container_name: mtg-deckbuilder-web container_name: mtg-deckbuilder-web
ports: ports:
- "8080:8080" # Host:Container — open http://localhost:8080 - "8080:8080" # Host:Container — open http://localhost:8080
environment: environment:
# UI features/flags (all optional) PYTHONUNBUFFERED: "1"
SHOW_LOGS: "1" # 1=enable /logs page; 0=hide (default off if unset) TERM: "xterm-256color"
SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide (default off) DEBIAN_FRONTEND: "noninteractive"
ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental); 0=disabled
WEB_VIRTUALIZE: "1" # 1=enable list virtualization/lazy tweaks in Web UI; 0=off
WEB_TAG_PARALLEL: "1" # 1=parallelize heavy tagging steps in Web UI; 0=serial
WEB_TAG_WORKERS: "4" # Worker count for parallel tagging (only used if WEB_TAG_PARALLEL=1)
# Theming (optional) # UI features/flags
THEME: "system" # Default theme for first-time visitors: system|light|dark SHOW_LOGS: "1"
# 'light' maps to the consolidated Light (Blend) palette SHOW_SETUP: "1"
ENABLE_THEMES: "1" # 1=show theme selector in header; 0=hide selector SHOW_DIAGNOSTICS: "1"
# Note: THEME still applies as the default even if selector is hidden ENABLE_PWA: "0"
ENABLE_THEMES: "1"
ENABLE_PRESETS: "0"
WEB_VIRTUALIZE: "1"
# Version label (optional; shown in footer/diagnostics) # Theming
APP_VERSION: "v2.2.3" THEME: "system"
# Setup/Tagging performance
WEB_AUTO_SETUP: "1"
WEB_AUTO_REFRESH_DAYS: "7"
WEB_TAG_PARALLEL: "1"
WEB_TAG_WORKERS: "4"
# Compliance/exports
WEB_AUTO_ENFORCE: "0"
APP_VERSION: "v2.2.5"
# WEB_CUSTOM_EXPORT_BASE: ""
# Paths (optional overrides)
# DECK_EXPORTS: "/app/deck_files"
# DECK_CONFIG: "/app/config"
# OWNED_CARDS_DIR: "/app/owned_cards"
# CARD_LIBRARY_DIR: "/app/owned_cards"
# Headless-only settings
# DECK_MODE: "headless"
# HEADLESS_EXPORT_JSON: "1"
# DECK_COMMANDER: ""
# DECK_PRIMARY_CHOICE: "1"
# DECK_SECONDARY_CHOICE: ""
# DECK_TERTIARY_CHOICE: ""
# DECK_PRIMARY_TAG: ""
# DECK_SECONDARY_TAG: ""
# DECK_TERTIARY_TAG: ""
# DECK_BRACKET_LEVEL: "3"
# DECK_ADD_LANDS: "1"
# DECK_ADD_CREATURES: "1"
# DECK_ADD_NON_CREATURE_SPELLS: "1"
# DECK_ADD_RAMP: "1"
# DECK_ADD_REMOVAL: "1"
# DECK_ADD_WIPES: "1"
# DECK_ADD_CARD_ADVANTAGE: "1"
# DECK_ADD_PROTECTION: "1"
# DECK_FETCH_COUNT: "3"
# DECK_DUAL_COUNT: ""
# DECK_TRIPLE_COUNT: ""
# DECK_UTILITY_COUNT: ""
# DECK_TAG_MODE: "AND"
# Entrypoint knobs
# APP_MODE: "web"
# HOST: "0.0.0.0"
# PORT: "8080"
# WORKERS: "1"
volumes: volumes:
# Persist app data locally; ensure these directories exist next to this compose file
- ${PWD}/deck_files:/app/deck_files - ${PWD}/deck_files:/app/deck_files
- ${PWD}/logs:/app/logs - ${PWD}/logs:/app/logs
- ${PWD}/csv_files:/app/csv_files - ${PWD}/csv_files:/app/csv_files
- ${PWD}/config:/app/config - ${PWD}/config:/app/config
- ${PWD}/owned_cards:/app/owned_cards - ${PWD}/owned_cards:/app/owned_cards
restart: unless-stopped restart: unless-stopped

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "mtg-deckbuilder" name = "mtg-deckbuilder"
version = "2.2.4" version = "2.2.5"
description = "A command-line tool for building and analyzing Magic: The Gathering decks" description = "A command-line tool for building and analyzing Magic: The Gathering decks"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}