From 4e03997923031b626959a8b013a215af4a055931 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Wed, 3 Sep 2025 18:00:06 -0700 Subject: [PATCH] Bracket enforcement + inline gating; global pool prune; compliance JSON artifacts; UI combos gating; compose envs consolidated; fix YAML; bump version to 2.2.5 --- CHANGELOG.md | 6 + README.md | Bin 46054 -> 50950 bytes code/deck_builder/brackets_compliance.py | 226 +++++ code/deck_builder/builder.py | 32 + code/deck_builder/enforcement.py | 448 ++++++++++ code/deck_builder/phases/phase3_creatures.py | 62 ++ code/deck_builder/phases/phase4_spells.py | 166 ++++ code/deck_builder/phases/phase6_reporting.py | 170 ++++ code/tagging/bracket_policy_applier.py | 120 +++ code/tagging/tagger.py | 5 + code/tests/test_bracket_policy_applier.py | 44 + code/tests/test_brackets_compliance.py | 53 ++ code/type_definitions.py | 25 +- code/web/routes/build.py | 797 ++++++++++++++++-- code/web/services/build_utils.py | 11 + code/web/services/orchestrator.py | 431 +++++++++- .../templates/build/_compliance_panel.html | 46 + code/web/templates/build/_new_deck_modal.html | 6 +- code/web/templates/build/_new_deck_tags.html | 16 + code/web/templates/build/_step2.html | 2 + code/web/templates/build/_step5.html | 15 +- code/web/templates/build/enforcement.html | 29 + config/brackets.yml | 47 ++ config/card_lists/combos.json | 2 +- config/card_lists/extra_turns.json | 1 + config/card_lists/game_changers.json | 1 + config/card_lists/mass_land_denial.json | 1 + config/card_lists/synergies.json | 2 +- config/card_lists/tutors_nonland.json | 1 + docker-compose.yml | 100 ++- dockerhub-docker-compose.yml | 77 +- pyproject.toml | 2 +- 32 files changed, 2819 insertions(+), 125 deletions(-) create mode 100644 code/deck_builder/brackets_compliance.py create mode 100644 code/deck_builder/enforcement.py create mode 100644 code/tagging/bracket_policy_applier.py create mode 100644 code/tests/test_bracket_policy_applier.py create mode 100644 code/tests/test_brackets_compliance.py create mode 100644 code/web/templates/build/_compliance_panel.html create mode 100644 code/web/templates/build/enforcement.html create mode 100644 config/brackets.yml create mode 100644 config/card_lists/extra_turns.json create mode 100644 config/card_lists/game_changers.json create mode 100644 config/card_lists/mass_land_denial.json create mode 100644 config/card_lists/tutors_nonland.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bf39a1c..771b1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,16 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added +- Bracket policy enforcement: global pool-level prune for disallowed categories when limits are 0 (e.g., Game Changers in Brackets 1–2). 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 +- 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 +- 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 diff --git a/README.md b/README.md index c12c58fa59619fdd6b4073e8cc2cc9f0c368de4f..41601ba5945bee78a295a1d615878d356f29be11 100644 GIT binary patch delta 4273 zcma)b0H7{?EEArP9PWoR12PBoem?VE;$5~#HnsJsYm0gaI9bY?mXbY_~)w54$| z>H=4~Il9n9`2sGC{RFIBxgc@j#+8XK`~>d&|IRa~ccz^JX>RA2zf#zbJW{2gYdx$k%OWf2Lo(VoNvy8lk&=9fZ9^ODib|1aJ9(@GXJ_?)Mb89< z^L44i3G#eTcR4+m!;X^n-~xu0^-E?dy2pie|JyHpTPw%DI2g;7>R2E@ex^T#X`-%5 z6o)9$qF?KJb&j0W^n@cX@5nVywF2NZ?dfPkl4vi;NtJ)=-+>>F^rW>ktm~!>O2|L@ zw71bYi3N9G@zmrRh?om;uITQRu9~Dl6v@VQaFQIQLIl{EqVTnMWK90Ry3NkSUdN?tm~dwQqhI}ln>*m$4TMO6yg2~lB#0aM(Nh) zV}IRfocZI0KXBNwa`nc#FqMgT(Gha_DLCNt7NQKCfH(`bt0Rb6ndY8&*p zDs|k^@N_4pN48Tv8Pw7Mt0DSk;o6SUMq+CNa+eCVzyYB-C$f|H~eIjcruG7%ru zwn%~WC&Cm1o*{YM$CP(&{w9Qd&rhia{N9@Qop~Q|ccqd1#N2hR!0vrdt3{A#w5fhl zSy~O83~VqNG8Pk)aqA(AaT<^$TwoBwbVlmUe%CVtZ^?D3RQ};?f9bsHU)4rX0*hrs z^VWlSb681;j@dAN)Lm9r`^>}`xTOZX#W`yPntVwsWB&I1iEhZDtQB0Y`G-HI{EL?l zKWBrl>?cN2{_xEMr*;oSJY)74vtkYV75y32*4DW*zJXlSA zO}}ORw}DD<$t=W@&71^cBo=xrx*}7mK|dHpke1D+MuQRuyY?|0HO@*ZnZ=XRxZ{AM z_Xd1R_nuSlsxoY6hn5j6;fQPS)f!S>bBU@XV|Y}?lQBK&v*%LBBUjoOkN8`ZWY+ih z4EXQgJ=CD4S=A0S=39t+uTw3FgAtY`V@FyTz)GHw^^>?d8EInHOHz`Iv{pt#DY=Uc zu^CXZ7X+;hMBfLu9rTX}dK*Ry)|7VEU^p;W7KIj&Xp_b!7!6F5Q;eFDao$b3%Whip zZOUEH{S~cD>jX9z22SMJNI~J`*gpI7Qe=mOcu;R`fKIh$fZ||MyQCX!m{w(?Ee*c> zZQt8e0ZHsFs2`_w5YEWu>uz3SHKXS%?wsys70R?bn)rU=yA%7<&VTm$(Ex_s zpGVMy9R(nkC7H0|i`=NetPc!A(1x)Gawxx^XQ{+1wwWk38U$g;zS!PeZD zuXLY`A7xL4xc_Rszt|bEI8qFm%qrJZm%*3}&9(*;V0Z=x(dk_{_xsMKhV2yG4yj1p Qu^XUIc&D~i{bpm}e`X0W%m4rY delta 48 zcmV-00MGx1jsxcB0 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 diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index ab886e2..f8a90d1 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -119,6 +119,19 @@ class DeckBuilder( # Modular reporting phase if hasattr(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 user opted out of owned-only, silently load all owned files for marking try: @@ -133,6 +146,25 @@ class DeckBuilder( 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 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 try: total_cards = sum(int(v.get('Count', 1)) for v in self.card_library.values()) diff --git a/code/deck_builder/enforcement.py b/code/deck_builder/enforcement.py new file mode 100644 index 0000000..0f0ef17 --- /dev/null +++ b/code/deck_builder/enforcement.py @@ -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 diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index d8c3dac..a17ff8e 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -380,6 +380,8 @@ class CreatureAdditionMixin: commander_name = getattr(self, 'commander', None) or getattr(self, 'commander_name', None) if commander_name and 'name' in creature_df.columns: 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: return None 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)) 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): """Add creatures for a single theme role ('primary'|'secondary'|'tertiary').""" df = getattr(self, '_combined_cards_df', None) diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index 0857b90..c972825 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -2,6 +2,7 @@ from __future__ import annotations import math from typing import List, Dict +import os from .. import builder_utils as bu 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. """ + 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 # --------------------------- @@ -56,7 +150,16 @@ class SpellAdditionMixin: commander_name = getattr(self, 'commander', None) if commander_name: work = work[work['name'] != commander_name] + work = self._apply_bracket_pre_filters(work) 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 if getattr(self, 'prefer_owned', False): owned_set = getattr(self, 'owned_card_names', None) @@ -97,10 +200,24 @@ class SpellAdditionMixin: return added_now 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: add_from_pool(rocks_pool, rocks_target, added_rocks, 'Rocks') 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: add_from_pool(dorks_pool, dorks_target, added_dorks, 'Dorks') @@ -108,6 +225,13 @@ class SpellAdditionMixin: remaining = target_total - current_total if remaining > 0: 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') total_added_now = len(added_rocks)+len(added_dorks)+len(added_general) @@ -148,7 +272,15 @@ class SpellAdditionMixin: commander_name = getattr(self, 'commander', None) if commander_name: pool = pool[pool['name'] != commander_name] + pool = self._apply_bracket_pre_filters(pool) 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): owned_set = getattr(self, 'owned_card_names', None) if owned_set: @@ -210,7 +342,15 @@ class SpellAdditionMixin: commander_name = getattr(self, 'commander', None) if commander_name: pool = pool[pool['name'] != commander_name] + pool = self._apply_bracket_pre_filters(pool) 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): owned_set = getattr(self, 'owned_card_names', None) if owned_set: @@ -278,6 +418,7 @@ class SpellAdditionMixin: def is_draw(tags): return any(('draw' in t) or ('card advantage' in t) for t in tags) 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)] commander_name = getattr(self, 'commander', None) if commander_name: @@ -291,6 +432,19 @@ class SpellAdditionMixin: return bu.sort_by_priority(d, ['edhrecRank','manaValue']) conditional_df = sortit(conditional_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): owned_set = getattr(self, 'owned_card_names', None) if owned_set: @@ -368,7 +522,15 @@ class SpellAdditionMixin: commander_name = getattr(self, 'commander', None) if commander_name: pool = pool[pool['name'] != commander_name] + pool = self._apply_bracket_pre_filters(pool) 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): owned_set = getattr(self, 'owned_card_names', None) if owned_set: @@ -467,6 +629,7 @@ class SpellAdditionMixin: ~df['type'].str.contains('Land', case=False, na=False) & ~df['type'].str.contains('Creature', case=False, na=False) ].copy() + spells_df = self._apply_bracket_pre_filters(spells_df) if spells_df.empty: return selected_tags_lower = [t.lower() for _r, t in themes_ordered] @@ -521,6 +684,7 @@ class SpellAdditionMixin: if owned_set: subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set}) pool = subset.head(top_n).copy() + pool = self._apply_bracket_pre_filters(pool) pool = pool[~pool['name'].isin(self.card_library.keys())] if pool.empty: continue @@ -563,6 +727,7 @@ class SpellAdditionMixin: if total_added < remaining: need = remaining - total_added 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: prioritized = multi_pool[multi_pool['_multiMatch'] >= 2] if prioritized.empty: @@ -607,6 +772,7 @@ class SpellAdditionMixin: if total_added < remaining: extra_needed = remaining - total_added leftover = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy() + leftover = self._apply_bracket_pre_filters(leftover) if not leftover.empty: if '_normTags' not in leftover.columns: leftover['_normTags'] = leftover['themeTags'].apply( diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 7bd3058..c1a632b 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -26,6 +26,176 @@ class ReportingMixin: self.print_card_library(table=True) """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: """Wraps a string to a specified width for table display. Used for pretty-printing card names, roles, and tags in tabular output. diff --git a/code/tagging/bracket_policy_applier.py b/code/tagging/bracket_policy_applier.py new file mode 100644 index 0000000..29de35f --- /dev/null +++ b/code/tagging/bracket_policy_applier.py @@ -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).') diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index ed7e2fb..c7f04e4 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -11,6 +11,7 @@ import pandas as pd # Local application imports from . import tag_utils from . import tag_constants +from .bracket_policy_applier import apply_bracket_policy_tags from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS, COLORS import logging_util from file_setup import setup @@ -163,6 +164,10 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: tag_for_interaction(df, color) 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 df = sort_theme_tags(df, color) df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False) diff --git a/code/tests/test_bracket_policy_applier.py b/code/tests/test_bracket_policy_applier.py new file mode 100644 index 0000000..7bb69d6 --- /dev/null +++ b/code/tests/test_bracket_policy_applier.py @@ -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'] diff --git a/code/tests/test_brackets_compliance.py b/code/tests/test_brackets_compliance.py new file mode 100644 index 0000000..f5c7a34 --- /dev/null +++ b/code/tests/test_brackets_compliance.py @@ -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" diff --git a/code/type_definitions.py b/code/type_definitions.py index 4b3812b..4c2654f 100644 --- a/code/type_definitions.py +++ b/code/type_definitions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Dict, List, TypedDict, Union +from typing import Dict, List, TypedDict, Union, Optional, Literal import pandas as pd class CardDict(TypedDict): @@ -47,4 +47,25 @@ EnchantmentDF = pd.DataFrame InstantDF = pd.DataFrame PlaneswalkerDF = pd.DataFrame NonPlaneswalkerDF = pd.DataFrame -SorceryDF = pd.DataFrame \ No newline at end of file +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] \ No newline at end of file diff --git a/code/web/routes/build.py b/code/web/routes/build.py index ff28969..399158e 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -133,7 +133,11 @@ async def build_index(request: Request) -> HTMLResponse: return resp -# --- Multi-copy archetype suggestion modal (Web-first flow) --- +# Support /build without trailing slash +@router.get("", response_class=HTMLResponse) +async def build_index_alias(request: Request) -> HTMLResponse: + return await build_index(request) + @router.get("/multicopy/check", response_class=HTMLResponse) async def multicopy_check(request: Request) -> HTMLResponse: @@ -322,12 +326,20 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} # Render tags slot content and OOB commander preview simultaneously + # Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer) + is_gc = False + try: + is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False ctx = { "request": request, "commander": {"name": info["name"]}, "tags": tags, "recommended": recommended, "recommended_reasons": recommended_reasons, + "gc_commander": is_gc, + "brackets": orch.bracket_options(), } return templates.TemplateResponse("build/_new_deck_tags.html", ctx) @@ -455,6 +467,17 @@ async def build_new_submit( resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp + # Enforce GC bracket restriction before saving session (silently coerce to 3) + try: + is_gc = bool((sel.get("name") or commander) in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + if is_gc: + try: + if int(bracket) < 3: + bracket = 3 + except Exception: + bracket = 3 # Save to session sess["commander"] = sel.get("name") or commander tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] @@ -654,6 +677,8 @@ async def build_step1_search( "recommended": orch.recommended_tags_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), + "gc_commander": (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])), + "selected_bracket": (3 if (res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) else None), "clear_persisted": True, }, ) @@ -712,6 +737,12 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe except Exception: pass sess["last_step"] = 2 + # Determine if commander is a Game Changer to drive bracket UI hiding + is_gc = False + try: + is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False resp = templates.TemplateResponse( "build/_step2.html", { @@ -721,6 +752,8 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe "recommended": orch.recommended_tags_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), + "gc_commander": is_gc, + "selected_bracket": (3 if is_gc else None), # Signal that this navigation came from a fresh commander confirmation, # so the Step 2 UI should clear any localStorage theme persistence. "clear_persisted": True, @@ -830,6 +863,20 @@ async def build_step2_get(request: Request) -> HTMLResponse: return resp tags = orch.tags_for_commander(commander) selected = sess.get("tags", []) + # Determine if the selected commander is considered a Game Changer (affects bracket choices) + is_gc = False + try: + is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + # Selected bracket: if GC commander and bracket < 3 or missing, default to 3 + sel_br = sess.get("bracket") + try: + sel_br = int(sel_br) if sel_br is not None else None + except Exception: + sel_br = None + if is_gc and (sel_br is None or int(sel_br) < 3): + sel_br = 3 resp = templates.TemplateResponse( "build/_step2.html", { @@ -842,8 +889,9 @@ async def build_step2_get(request: Request) -> HTMLResponse: "primary_tag": selected[0] if len(selected) > 0 else "", "secondary_tag": selected[1] if len(selected) > 1 else "", "tertiary_tag": selected[2] if len(selected) > 2 else "", - "selected_bracket": sess.get("bracket"), + "selected_bracket": sel_br, "tag_mode": sess.get("tag_mode", "AND"), + "gc_commander": is_gc, # If there are no server-side tags for this commander, let the client clear any persisted ones # to avoid themes sticking between fresh runs. "clear_persisted": False if selected else True, @@ -869,6 +917,18 @@ async def build_step2_submit( sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 2 + # Compute GC flag to hide disallowed brackets on error + is_gc = False + try: + is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + try: + sel_br = int(bracket) if bracket is not None else None + except Exception: + sel_br = None + if is_gc and (sel_br is None or sel_br < 3): + sel_br = 3 resp = templates.TemplateResponse( "build/_step2.html", { @@ -882,13 +942,26 @@ async def build_step2_submit( "primary_tag": primary_tag or "", "secondary_tag": secondary_tag or "", "tertiary_tag": tertiary_tag or "", - "selected_bracket": int(bracket) if bracket is not None else None, + "selected_bracket": sel_br, "tag_mode": (tag_mode or "AND"), + "gc_commander": is_gc, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp + # Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed) + try: + is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + if is_gc: + try: + if int(bracket) < 3: + bracket = 3 # coerce silently + except Exception: + bracket = 3 + # Save selection to session (basic MVP; real build will use this later) sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) @@ -1339,6 +1412,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse: sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" except Exception: pass + # Note: no redirect; the inline compliance panel will render inside Step 5 sess["last_step"] = 5 ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx2) @@ -1443,6 +1517,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" except Exception: pass + # Note: no redirect; the inline compliance panel will render inside Step 5 sess["last_step"] = 5 ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx) @@ -1590,9 +1665,17 @@ async def build_lock_toggle(request: Request, name: str = Form(...), locked: str @router.get("/alternatives", response_class=HTMLResponse) async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> HTMLResponse: - """Suggest alternative cards for a given card name using tag overlap and availability. + """Suggest alternative cards for a given card name, preferring role-specific pools. - Returns a small HTML snippet listing up to ~10 alternatives with Replace buttons. + Strategy: + 1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint. + 2) Build a candidate pool from the combined DataFrame using the same filters as the build phase + for that role (ramp/removal/wipes/card_advantage/protection). + 3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort + by edhrecRank/manaValue. Apply owned-only filter if requested. + 4) Fall back to tag-overlap similarity when role cannot be determined or data is missing. + + Returns an HTML partial listing up to ~10 alternatives with Replace buttons. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) @@ -1606,45 +1689,212 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No html = '
Start the build to see alternatives.
' return HTMLResponse(html) try: - name_l = str(name).strip().lower() + name_disp = str(name).strip() + name_l = name_disp.lower() commander_l = str((sess.get("commander") or "")).strip().lower() locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} - # Check cache: key = (seed, commander, require_owned) - cache_key = (name_l, commander_l, require_owned) + # Exclusions from prior inline replacements + alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} + alts_exclude_v = int(sess.get("alts_exclude_v") or 0) + + # Resolve role from stage hint or current library entry + stage_hint = (stage or "").strip().lower() + stage_map = { + "ramp": "ramp", + "removal": "removal", + "wipes": "wipe", + "wipe": "wipe", + "board_wipe": "wipe", + "card_advantage": "card_advantage", + "draw": "card_advantage", + "protection": "protection", + # Additional mappings for creature stages + "creature": "creature", + "creatures": "creature", + "primary": "creature", + "secondary": "creature", + } + hinted_role = stage_map.get(stage_hint) if stage_hint else None + lib = getattr(b, "card_library", {}) or {} + # Case-insensitive lookup in deck library + lib_key = None + try: + if name_disp in lib: + lib_key = name_disp + else: + lm = {str(k).strip().lower(): k for k in lib.keys()} + lib_key = lm.get(name_l) + except Exception: + lib_key = None + entry = lib.get(lib_key) if lib_key else None + role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None) + if isinstance(role, str): + role = role.strip().lower() + + # Build role-specific pool from combined DataFrame + items: list[dict] = [] + used_role = role if isinstance(role, str) and role else None + df = getattr(b, "_combined_cards_df", None) + + # Compute current deck fingerprint to avoid stale cached alternatives after stage changes + in_deck: set[str] = builder_present_names(b) + try: + import hashlib as _hl + deck_fp = _hl.md5( + ("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8") + ).hexdigest()[:8] + except Exception: + deck_fp = str(len(in_deck)) + + # Use a cache key that includes the exclusions version and deck fingerprint + cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp) cached = _alts_get_cached(cache_key) if cached is not None: return HTMLResponse(cached) - # Tags index provides quick similarity candidates + + def _render_and_cache(_items: list[dict]): + html_str = templates.get_template("build/_alternatives.html").render({ + "request": request, + "name": name_disp, + "require_owned": require_owned, + "items": _items, + }) + try: + _alts_set_cached(cache_key, html_str) + except Exception: + pass + return HTMLResponse(html_str) + + # Helper: map display names + def _display_map_for(lower_pool: set[str]) -> dict[str, str]: + try: + return builder_display_map(b, lower_pool) # type: ignore[arg-type] + except Exception: + return {nm: nm for nm in lower_pool} + + # Common exclusions + # in_deck already computed above + + def _exclude(df0): + out = df0.copy() + if "name" in out.columns: + out["_lname"] = out["name"].astype(str).str.strip().str.lower() + mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set())) + out = out[mask] + return out + + # If we have data and a recognized role, mirror the phase logic + if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature"}): + pool = df.copy() + try: + pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell) + except Exception: + # best-effort normalize + pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else []) + # Exclude lands for all these roles + if "type" in pool.columns: + pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] + # Exclude commander explicitly + if "name" in pool.columns and commander_l: + pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l] + # Role-specific filter + def _is_wipe(tags: list[str]) -> bool: + return any(("board wipe" in t) or ("mass removal" in t) for t in tags) + def _is_removal(tags: list[str]) -> bool: + return any(("removal" in t) or ("spot removal" in t) for t in tags) + def _is_draw(tags: list[str]) -> bool: + return any(("draw" in t) or ("card advantage" in t) for t in tags) + def _matches_selected(tags: list[str]) -> bool: + try: + sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()] + if not sel: + return True + st = set(sel) + return any(any(s in t for s in st) for t in tags) + except Exception: + return True + if used_role == "ramp": + pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))] + elif used_role == "removal": + pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)] + elif used_role == "wipe": + pool = pool[pool["_ltags"].apply(_is_wipe)] + elif used_role == "card_advantage": + pool = pool[pool["_ltags"].apply(_is_draw)] + elif used_role == "protection": + pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))] + elif used_role == "creature": + # Keep only creatures; bias toward selected theme tags when available + if "type" in pool.columns: + pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)] + try: + pool = pool[pool["_ltags"].apply(_matches_selected)] + except Exception: + pass + # Sort by priority like the builder + try: + pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] + except Exception: + pass + # Exclusions and ownership + pool = _exclude(pool) + # Prefer-owned bias: stable reorder to put owned first if user prefers owned + try: + if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None): + pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())}) + except Exception: + pass + # Build final items + lower_pool: list[str] = [] + try: + lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist() + except Exception: + lower_pool = [] + display_map = _display_map_for(set(lower_pool)) + for nm_l in lower_pool: + is_owned = (nm_l in owned_set) + if require_owned and not is_owned: + continue + # Extra safety: exclude the seed card or anything already in deck + if nm_l == name_l or (in_deck and nm_l in in_deck): + continue + items.append({ + "name": display_map.get(nm_l, nm_l), + "name_lower": nm_l, + "owned": is_owned, + "tags": [], # can be filled from index below if needed + }) + if len(items) >= 10: + break + # If we collected role-aware items, render + if items: + return _render_and_cache(items) + + # Fallback: tag-similarity suggestions (previous behavior) tags_idx = getattr(b, "_card_name_tags_index", {}) or {} seed_tags = set(tags_idx.get(name_l) or []) - # Fallback: use the card's role/sub-role from current library if available - lib = getattr(b, "card_library", {}) or {} - lib_entry = lib.get(name) or lib.get(name_l) - # Best-effort set of names currently in the deck to avoid duplicates - in_deck: set[str] = builder_present_names(b) - # Build candidate pool from tags overlap all_names = set(tags_idx.keys()) candidates: list[tuple[str, int]] = [] # (name, score) for nm in all_names: if nm == name_l: continue - # Exclude commander and any names we believe are already in the current deck if commander_l and nm == commander_l: continue if in_deck and nm in in_deck: continue - # Also exclude any card currently locked (these are intended to be kept) if locked_set and nm in locked_set: continue + if nm in alts_exclude: + continue tgs = set(tags_idx.get(nm) or []) score = len(seed_tags & tgs) if score <= 0: continue candidates.append((nm, score)) - # If no tag-based candidates, try using same trigger tag if present - if not candidates and isinstance(lib_entry, dict): + # If no tag-based candidates, try shared trigger tag from library entry + if not candidates and isinstance(entry, dict): try: - trig = str(lib_entry.get("TriggerTag") or "").strip().lower() + trig = str(entry.get("TriggerTag") or "").strip().lower() except Exception: trig = "" if trig: @@ -1655,15 +1905,11 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No continue if trig in {str(t).strip().lower() for t in (tglist or [])}: candidates.append((nm, 1)) - # Sort by score desc, then owned-first, then name asc def _owned(nm: str) -> bool: return nm in owned_set candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) - # Map back to display names using combined DF when possible for proper casing pool_lower = {nm for (nm, _s) in candidates} - display_map: dict[str, str] = builder_display_map(b, pool_lower) - # Build structured items for the partial - items: list[dict] = [] + display_map = _display_map_for(pool_lower) seen = set() for nm, score in candidates: if nm in seen: @@ -1672,36 +1918,34 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No is_owned = (nm in owned_set) if require_owned and not is_owned: continue - disp = display_map.get(nm, nm) items.append({ - "name": disp, + "name": display_map.get(nm, nm), "name_lower": nm, "owned": is_owned, "tags": list(tags_idx.get(nm) or []), }) if len(items) >= 10: break - # Render partial via Jinja template and cache it - ctx2 = {"request": request, "name": name, "require_owned": require_owned, "items": items} - html_str = templates.get_template("build/_alternatives.html").render(ctx2) - _alts_set_cached(cache_key, html_str) - return HTMLResponse(html_str) + return _render_and_cache(items) except Exception as e: return HTMLResponse(f'
No alternatives: {e}
') @router.post("/replace", response_class=HTMLResponse) async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> HTMLResponse: - """Update locks to prefer `new` over `old` and prompt the user to rerun the stage with Replace enabled. + """Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives. - This does not immediately mutate the builder; users should click Rerun Stage (Replace: On) to apply. + Falls back to lock-and-rerun guidance if no active builder is present. """ sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + o_disp = str(old).strip() + n_disp = str(new).strip() + o = o_disp.lower() + n = n_disp.lower() + + # Maintain locks to bias future picks and enforcement locks = set(sess.get("locks", [])) - o = str(old).strip().lower() - n = str(new).strip().lower() - # Always ensure new is locked and old is unlocked locks.discard(o) locks.add(n) sess["locks"] = list(locks) @@ -1710,36 +1954,213 @@ async def build_replace(request: Request, old: str = Form(...), new: str = Form( sess["last_replace"] = {"old": o, "new": n} except Exception: pass - if sess.get("build_ctx"): + ctx = sess.get("build_ctx") or {} + try: + ctx["locks"] = {str(x) for x in locks} + except Exception: + pass + # Record preferred replacements + try: + pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None + if not isinstance(pref, dict): + pref = {} + ctx["preferred_replacements"] = pref + pref[o] = n + except Exception: + pass + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if b is not None: try: - sess["build_ctx"]["locks"] = {str(x) for x in locks} + lib = getattr(b, "card_library", {}) or {} + # Find the exact key for `old` in a case-insensitive manner + old_key = None + if o_disp in lib: + old_key = o_disp + else: + for k in list(lib.keys()): + if str(k).strip().lower() == o: + old_key = k + break + if old_key is None: + raise KeyError("old card not in deck") + old_info = dict(lib.get(old_key) or {}) + role = str(old_info.get("Role") or "").strip() + subrole = str(old_info.get("SubRole") or "").strip() + try: + count = int(old_info.get("Count", 1)) + except Exception: + count = 1 + # Remove old entry + try: + del lib[old_key] + except Exception: + pass + # Resolve canonical name and info for new + df = getattr(b, "_combined_cards_df", None) + new_key = n_disp + card_type = "" + mana_cost = "" + trigger_tag = str(old_info.get("TriggerTag") or "") + if df is not None: + try: + row = df[df["name"].astype(str).str.strip().str.lower() == n] + if not row.empty: + new_key = str(row.iloc[0]["name"]) or n_disp + card_type = str(row.iloc[0].get("type", row.iloc[0].get("type_line", "")) or "") + mana_cost = str(row.iloc[0].get("mana_cost", row.iloc[0].get("manaCost", "")) or "") + except Exception: + pass + lib[new_key] = { + "Count": count, + "Card Type": card_type, + "Mana Cost": mana_cost, + "Role": role, + "SubRole": subrole, + "AddedBy": "Replace", + "TriggerTag": trigger_tag, + } + # Mirror preferred replacements onto the builder for enforcement + try: + cur = getattr(b, "preferred_replacements", {}) or {} + cur[str(o)] = str(n) + setattr(b, "preferred_replacements", cur) + except Exception: + pass + # Update alternatives exclusion set and bump version to invalidate caches + try: + ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} + ex.add(o) + sess["alts_exclude"] = list(ex) + sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 + except Exception: + pass + # Success panel and OOB updates (refresh compliance panel) + # Compute ownership of the new card for UI badge update + is_owned = (n in owned_set_helper()) + html = ( + '
' + f'
Replaced {o_disp} with {new_key}.
' + '
Compliance panel will refresh.
' + '
' + '' + '
' + '
' + ) + # Inline mutate the nearest card tile to reflect the new card without a rerun + mutator = """ + +""" + chip = ( + f'
' + f'Replaced {o_disp}{new_key}' + f'
' + ) + # OOB fetch to refresh compliance panel + refresher = ( + '
' + ) + # 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 = '
' f'
Locked {new} and unlocked {old}.
' - '
Now click Rerun Stage with Replace: On to apply this change.
' + '
Now click Rerun Stage with Replace: On to apply this change.
' '
' '
' '' '' '
' - '
' - f'' - f'' - '' - '
' + '
' + f'' + f'' + '' + '
' '' '
' '
' ) - # Also emit an OOB last-action chip chip = ( f'
' f'Replaced {old}{new}' f'
' ) + # Also add old to exclusions and bump version for future alt calls + try: + ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} + ex.add(o) + sess["alts_exclude"] = list(ex) + sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 + except Exception: + pass return HTMLResponse(hint + chip) @@ -1804,6 +2225,288 @@ async def build_compare(runA: str, runB: str): return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) +@router.get("/compliance", response_class=HTMLResponse) +async def build_compliance_panel(request: Request) -> HTMLResponse: + """Render a live Bracket compliance panel with manual enforcement controls. + + Computes compliance against the current builder state without exporting, attaches a non-destructive + enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial. + Returns empty content when no active build context exists. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") or {} + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if not b: + return HTMLResponse("") + # Compute compliance snapshot in-memory and attach planning preview + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + except Exception: + comp = None + try: + if comp: + from ..services import orchestrator as orch + comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] + except Exception: + pass + if not comp: + return HTMLResponse("") + # Build flagged metadata (role, owned) for visual tiles and role-aware alternatives + # For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced. + flagged_meta: list[dict] = [] + try: + cats = comp.get('categories') or {} + owned_lower = owned_set_helper() + lib = getattr(b, 'card_library', {}) or {} + commander_l = str((sess.get('commander') or '')).strip().lower() + # map category key -> display label + labels = { + 'game_changers': 'Game Changers', + 'extra_turns': 'Extra Turns', + 'mass_land_denial': 'Mass Land Denial', + 'tutors_nonland': 'Nonland Tutors', + 'two_card_combos': 'Two-Card Combos', + } + seen_lower: set[str] = set() + for key, cat in cats.items(): + try: + lim = cat.get('limit') + cnt = int(cat.get('count', 0) or 0) + if lim is None or cnt <= int(lim): + continue + # For two-card combos, split pairs into individual cards and skip commander + if key == 'two_card_combos': + # Prefer the structured combos list to ensure we only expand counted pairs + pairs = [] + try: + for p in (comp.get('combos') or []): + if p.get('cheap_early'): + pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip())) + except Exception: + pairs = [] + # Fallback to parsing flagged strings like "A + B" + if not pairs: + try: + for s in (cat.get('flagged') or []): + if not isinstance(s, str): + continue + parts = [x.strip() for x in s.split('+') if x and x.strip()] + if len(parts) == 2: + pairs.append((parts[0], parts[1])) + except Exception: + pass + for a, bname in pairs: + for nm in (a, bname): + if not nm: + continue + nm_l = nm.strip().lower() + if nm_l == commander_l: + # Don't prompt replacing the commander + continue + if nm_l in seen_lower: + continue + seen_lower.add(nm_l) + entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {} + role = entry.get('Role') or '' + flagged_meta.append({ + 'name': nm, + 'category': labels.get(key, key.replace('_',' ').title()), + 'role': role, + 'owned': (nm_l in owned_lower), + }) + continue + # Default handling for list/tag categories + names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)] + for nm in names: + nm_l = str(nm).strip().lower() + if nm_l in seen_lower: + continue + seen_lower.add(nm_l) + entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {} + role = entry.get('Role') or '' + flagged_meta.append({ + 'name': nm, + 'category': labels.get(key, key.replace('_',' ').title()), + 'role': role, + 'owned': (nm_l in owned_lower), + }) + except Exception: + continue + except Exception: + flagged_meta = [] + # Render partial + ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta} + return templates.TemplateResponse("build/_compliance_panel.html", ctx2) + + +@router.post("/enforce/apply", response_class=HTMLResponse) +async def build_enforce_apply(request: Request) -> HTMLResponse: + """Apply bracket enforcement now using current locks as user guidance. + + This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + # Ensure build context exists + ctx = sess.get("build_ctx") or {} + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if not b: + # No active build: show Step 5 with an error + err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Ensure we have a CSV base stem for consistent re-exports + base_stem = None + try: + csv_path = ctx.get("csv_path") + if isinstance(csv_path, str) and csv_path: + import os as _os + base_stem = _os.path.splitext(_os.path.basename(csv_path))[0] + except Exception: + base_stem = None + # If missing, export once to establish base + if not base_stem: + try: + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] + import os as _os + base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] + # Also produce a text export for completeness + ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] + except Exception: + base_stem = None + # Add lock placeholders into the library before enforcement so user choices are present + try: + locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + if locks: + df = getattr(b, "_combined_cards_df", None) + lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} + for lname in locks: + if lname in lib_l: + continue + target_name = None + card_type = '' + mana_cost = '' + try: + if df is not None and not df.empty: + row = df[df['name'].astype(str).str.lower() == lname] + if not row.empty: + target_name = str(row.iloc[0]['name']) + card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') + mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') + except Exception: + target_name = None + if target_name: + b.card_library[target_name] = { + 'Count': 1, + 'Card Type': card_type, + 'Mana Cost': mana_cost, + 'Role': 'Locked', + 'SubRole': '', + 'AddedBy': 'Lock', + 'TriggerTag': '', + } + except Exception: + pass + # Thread preferred replacements from context onto builder so enforcement can honor them + try: + pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None + if isinstance(pref, dict): + setattr(b, 'preferred_replacements', dict(pref)) + except Exception: + pass + # Run enforcement + re-exports (tops up to 100 internally) + try: + rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] + except Exception as e: + err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Reload compliance JSON and summary + compliance = None + try: + if base_stem: + import os as _os + import json as _json + comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") + if _os.path.exists(comp_path): + with open(comp_path, 'r', encoding='utf-8') as _cf: + compliance = _json.load(_cf) + except Exception: + compliance = None + # Rebuild Step 5 context (done state) + # Ensure csv/txt paths on ctx reflect current base + try: + import os as _os + ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path") + ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path") + except Exception: + pass + # Compute total_cards + try: + total_cards = 0 + for _n, _e in getattr(b, 'card_library', {}).items(): + try: + total_cards += int(_e.get('Count', 1)) + except Exception: + total_cards += 1 + except Exception: + total_cards = None + res = { + "done": True, + "label": "Complete", + "log_delta": "", + "idx": len(ctx.get("stages", []) or []), + "total": len(ctx.get("stages", []) or []), + "csv_path": ctx.get("csv_path"), + "txt_path": ctx.get("txt_path"), + "summary": getattr(b, 'build_deck_summary', lambda: None)(), + "total_cards": total_cards, + "added_total": 0, + "compliance": compliance or rep, + } + page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True) + resp = templates.TemplateResponse("build/_step5.html", page_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.get("/enforcement", response_class=HTMLResponse) +async def build_enforcement_fullpage(request: Request) -> HTMLResponse: + """Full-page enforcement review: show compliance panel with swaps and controls.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") or {} + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if not b: + # No active build + base = step5_empty_ctx(request, sess) + resp = templates.TemplateResponse("build/_step5.html", base) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Compute compliance snapshot and attach planning preview + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + except Exception: + comp = None + try: + if comp: + from ..services import orchestrator as orch + comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] + except Exception: + pass + ctx2 = {"request": request, "compliance": comp} + resp = templates.TemplateResponse("build/enforcement.html", ctx2) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + @router.get("/permalink") async def build_permalink(request: Request): """Return a URL-safe JSON payload representing current run config (basic).""" diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 68c4d44..3d0883b 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -118,6 +118,7 @@ def step5_ctx_from_result( "csv_path": res.get("csv_path") if done else None, "txt_path": res.get("txt_path") if done else None, "summary": res.get("summary") if done else None, + "compliance": res.get("compliance") if done else None, "show_skipped": bool(show_skipped), "total_cards": res.get("total_cards"), "added_total": res.get("added_total"), @@ -125,6 +126,7 @@ def step5_ctx_from_result( "clamped_overflow": res.get("clamped_overflow"), "mc_summary": res.get("mc_summary"), "skipped": bool(res.get("skipped")), + "gated": bool(res.get("gated")), } if extras: ctx.update(extras) @@ -238,6 +240,15 @@ def builder_present_names(builder: Any) -> set[str]: 'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck', ): _add_names(getattr(builder, attr, None)) + # Also include names present in the library itself, which is the authoritative deck source post-build + try: + lib = getattr(builder, 'card_library', None) + if isinstance(lib, dict) and lib: + for k in lib.keys(): + if isinstance(k, str) and k.strip(): + present.add(k.strip().lower()) + except Exception: + pass for attr in ('current_names', 'deck_names', 'final_names'): val = getattr(builder, attr, None) if isinstance(val, (list, tuple, set)): diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 32bcef2..5c26ec0 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -14,6 +14,163 @@ import unicodedata from glob import glob +def _global_prune_disallowed_pool(b: DeckBuilder) -> None: + """Hard-prune disallowed categories from the working pool based on bracket limits. + + This is a defensive, pool-level filter to ensure headless/JSON builds also + honor hard bans (e.g., no Game Changers in brackets 1–2). It complements + per-stage pre-filters and is safe to apply immediately after dataframes are + set up. + """ + try: + limits = getattr(b, 'bracket_limits', {}) or {} + + def _prune_df(df): + try: + if df is None or getattr(df, 'empty', True): + return df + cols = getattr(df, 'columns', []) + name_col = 'name' if 'name' in cols else ('Card Name' if 'Card Name' in cols else None) + if name_col is None: + return df + work = df + # 1) Game Changers: filter by authoritative name list regardless of tag presence + try: + lim_gc = limits.get('game_changers') + if lim_gc is not None and int(lim_gc) == 0 and getattr(bc, 'GAME_CHANGERS', None): + gc_lower = {str(n).strip().lower() for n in getattr(bc, 'GAME_CHANGERS', [])} + work = work[~work[name_col].astype(str).str.lower().isin(gc_lower)] + except Exception: + pass + # 2) Additional categories rely on tags if present; skip if tag column missing + try: + if 'themeTags' in getattr(work, 'columns', []): + # Normalize a lowercase tag list column + from deck_builder import builder_utils as _bu + if '_ltags' not in work.columns: + work = work.copy() + work['_ltags'] = work['themeTags'].apply(_bu.normalize_tag_cell) + + def _has_any(lst, needles): + try: + return any(any(nd in t for nd in needles) for t in (lst or [])) + except Exception: + return False + + # Build disallow map + disallow = { + 'extra_turns': (limits.get('extra_turns') is not None and int(limits.get('extra_turns')) == 0), + 'mass_land_denial': (limits.get('mass_land_denial') is not None and int(limits.get('mass_land_denial')) == 0), + 'tutors_nonland': (limits.get('tutors_nonland') is not None and int(limits.get('tutors_nonland')) == 0), + } + syn = { + 'extra_turns': {'bracket:extraturn', 'extra turn', 'extra turns', 'extraturn'}, + 'mass_land_denial': {'bracket:masslanddenial', 'mass land denial', 'mld', 'masslanddenial'}, + 'tutors_nonland': {'bracket:tutornonland', 'tutor', 'tutors', 'nonland tutor', 'non-land tutor'}, + } + if any(disallow.values()): + mask_keep = [True] * len(work) + tags_series = work['_ltags'] + for key, dis in disallow.items(): + if not dis: + continue + needles = syn.get(key, set()) + drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd)) + mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] + try: + import pandas as _pd # type: ignore + mask_keep = _pd.Series(mask_keep, index=work.index) + except Exception: + pass + work = work[mask_keep] + except Exception: + pass + + return work + except Exception: + return df + + # Apply to both pools used by phases + try: + b._combined_cards_df = _prune_df(getattr(b, '_combined_cards_df', None)) + except Exception: + pass + try: + b._full_cards_df = _prune_df(getattr(b, '_full_cards_df', None)) + except Exception: + pass + except Exception: + return + + +def _attach_enforcement_plan(b: DeckBuilder, comp: Dict[str, Any] | None) -> Dict[str, Any] | None: + """When compliance FAILs, attach a non-destructive enforcement plan to show swaps in UI. + + Builds a list of candidate removals per over-limit category (no mutations), then + attaches comp['enforcement'] with 'swaps' entries of the form + {removed: name, added: None, role: role} and summaries of removed names. + """ + try: + if not isinstance(comp, dict): + return comp + if str(comp.get('overall', 'PASS')).upper() != 'FAIL': + return comp + cats = comp.get('categories') or {} + lib = getattr(b, 'card_library', {}) or {} + # Case-insensitive lookup for library names + lib_lower_to_orig = {str(k).strip().lower(): k for k in lib.keys()} + # Scoring helper mirroring enforcement: worse (higher rank) trimmed first + df = getattr(b, '_combined_cards_df', None) + def _score(name: str) -> tuple[int, float, str]: + try: + if df is not None and not getattr(df, 'empty', True) and 'name' in getattr(df, 'columns', []): + r = df[df['name'].astype(str) == str(name)] + if not r.empty: + rank = int(r.iloc[0].get('edhrecRank') or 10**9) + mv = float(r.iloc[0].get('manaValue') or r.iloc[0].get('cmc') or 0.0) + return (rank, mv, str(name)) + except Exception: + pass + return (10**9, 99.0, str(name)) + to_remove: list[str] = [] + for key in ('game_changers', 'extra_turns', 'mass_land_denial', 'tutors_nonland'): + cat = cats.get(key) or {} + lim = cat.get('limit') + cnt = int(cat.get('count', 0) or 0) + if lim is None or cnt <= int(lim): + continue + flagged = [n for n in (cat.get('flagged') or []) if isinstance(n, str)] + # Map flagged names to the canonical in-deck key (case-insensitive) + present_mapped: list[str] = [] + for n in flagged: + n_key = str(n).strip() + if n_key in lib: + present_mapped.append(n_key) + continue + lk = n_key.lower() + if lk in lib_lower_to_orig: + present_mapped.append(lib_lower_to_orig[lk]) + present = present_mapped + if not present: + continue + over = cnt - int(lim) + present_sorted = sorted(present, key=_score, reverse=True) + to_remove.extend(present_sorted[:over]) + if not to_remove: + return comp + swaps = [] + for nm in to_remove: + entry = lib.get(nm) or {} + swaps.append({"removed": nm, "added": None, "role": entry.get('Role')}) + enf = comp.setdefault('enforcement', {}) + enf['removed'] = list(dict.fromkeys(to_remove)) + enf['added'] = [] + enf['swaps'] = swaps + return comp + except Exception: + return comp + + def commander_names() -> List[str]: tmp = DeckBuilder() df = tmp.load_commander_data() @@ -777,6 +934,12 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception: pass + # Defaults for return payload + csv_path = None + txt_path = None + summary = None + compliance_obj = None + try: # Provide a no-op input function so any leftover prompts auto-accept defaults b = DeckBuilder(output_func=out, input_func=lambda _prompt: "", headless=True) @@ -844,6 +1007,8 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i try: b.determine_color_identity() b.setup_dataframes() + # Global safety prune of disallowed categories (e.g., Game Changers) for headless builds + _global_prune_disallowed_pool(b) except Exception as e: out(f"Failed to load color identity/card pool: {e}") @@ -1001,9 +1166,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception as e: out(f"Post-spell land adjust failed: {e}") - # Reporting/exports - csv_path = None - txt_path = None + # Reporting/exports try: if hasattr(b, 'run_reporting_phase'): b.run_reporting_phase() @@ -1031,11 +1194,38 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i b._display_txt_contents(txt_path) except Exception: pass + # Compute bracket compliance and save JSON alongside exports + try: + if hasattr(b, 'compute_and_print_compliance'): + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + # Attach planning preview (no mutation) and only auto-enforce if explicitly enabled + rep0 = _attach_enforcement_plan(b, rep0) + try: + import os as __os + _auto = str(__os.getenv('WEB_AUTO_ENFORCE', '0')).strip().lower() in {"1","true","yes","on"} + except Exception: + _auto = False + if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] + except Exception: + pass + # Load compliance JSON for UI consumption + try: + # Prefer the in-memory report (with enforcement plan) when available + if rep0 is not None: + compliance_obj = rep0 + else: + import json as _json + comp_path = _os.path.join('deck_files', f"{base}_compliance.json") + if _os.path.exists(comp_path): + with open(comp_path, 'r', encoding='utf-8') as _cf: + compliance_obj = _json.load(_cf) + except Exception: + compliance_obj = None except Exception as e: out(f"Text export failed: {e}") # Build structured summary for UI - summary = None try: if hasattr(b, 'build_deck_summary'): summary = b.build_deck_summary() # type: ignore[attr-defined] @@ -1067,7 +1257,8 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i _json.dump(payload, f, ensure_ascii=False, indent=2) except Exception: pass - return {"ok": True, "log": "\n".join(logs), "csv_path": csv_path, "txt_path": txt_path, "summary": summary} + # Success return + return {"ok": True, "log": "\n".join(logs), "csv_path": csv_path, "txt_path": txt_path, "summary": summary, "compliance": compliance_obj} except Exception as e: logs.append(f"Build failed: {e}") return {"ok": False, "error": str(e), "log": "\n".join(logs)} @@ -1131,7 +1322,15 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: prefer_c = bool(getattr(b, 'prefer_combos', False)) except Exception: prefer_c = False - if prefer_c: + # Respect bracket limits: if two-card combos are disallowed (limit == 0), skip auto-combos stage + allow_combos = True + try: + lim = getattr(b, 'bracket_limits', {}).get('two_card_combos') + if lim is not None and int(lim) == 0: + allow_combos = False + except Exception: + allow_combos = True + if prefer_c and allow_combos: stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) # Ensure we include the theme filler step to top up to 100 cards if callable(getattr(b, 'fill_remaining_theme_spells', None)): @@ -1139,7 +1338,15 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: elif hasattr(b, 'add_spells_phase'): # For monolithic spells, insert combos BEFORE the big spells stage so additions aren't clamped away try: - if bool(getattr(b, 'prefer_combos', False)): + prefer_c = bool(getattr(b, 'prefer_combos', False)) + allow_combos = True + try: + lim = getattr(b, 'bracket_limits', {}).get('two_card_combos') + if lim is not None and int(lim) == 0: + allow_combos = False + except Exception: + allow_combos = True + if prefer_c and allow_combos: stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) except Exception: pass @@ -1240,6 +1447,8 @@ def start_build_ctx( # Data load b.determine_color_identity() b.setup_dataframes() + # Apply the same global pool pruning in interactive builds for consistency + _global_prune_disallowed_pool(b) # Thread multi-copy selection onto builder for stage generation/runner try: b._web_multi_copy = (multi_copy or None) @@ -1339,7 +1548,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal setattr(b, 'custom_export_base', str(custom_base)) except Exception: pass - if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'): + if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] except Exception as e: @@ -1354,6 +1563,33 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] except Exception: pass + # Compute bracket compliance and save JSON alongside exports + try: + if hasattr(b, 'compute_and_print_compliance'): + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + rep0 = _attach_enforcement_plan(b, rep0) + try: + import os as __os + _auto = str(__os.getenv('WEB_AUTO_ENFORCE', '0')).strip().lower() in {"1","true","yes","on"} + except Exception: + _auto = False + if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] + except Exception: + pass + # Load compliance JSON for UI consumption + try: + # Prefer in-memory report if available + if rep0 is not None: + ctx["compliance"] = rep0 + else: + import json as _json + comp_path = _os.path.join('deck_files', f"{base}_compliance.json") + if _os.path.exists(comp_path): + with open(comp_path, 'r', encoding='utf-8') as _cf: + ctx["compliance"] = _json.load(_cf) + except Exception: + ctx["compliance"] = None except Exception as e: logs.append(f"Text export failed: {e}") # Final lock enforcement before finishing @@ -1428,6 +1664,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "csv_path": ctx.get("csv_path"), "txt_path": ctx.get("txt_path"), "summary": summary, + "compliance": ctx.get("compliance"), } # Determine which stage index to run (rerun last visible, else current) @@ -1436,6 +1673,52 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal else: i = ctx["idx"] + # If compliance gating is active for the current stage, do not rerun the stage; block advancement until PASS + try: + gating = ctx.get('gating') or None + if gating and isinstance(gating, dict) and int(gating.get('stage_idx', -1)) == int(i): + # Recompute compliance snapshot + comp_now = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + except Exception: + comp_now = None + try: + if comp_now: + comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined] + except Exception: + pass + # If still FAIL, return the saved result without advancing or rerunning + try: + if comp_now and str(comp_now.get('overall', 'PASS')).upper() == 'FAIL': + # Update total_cards live before returning saved result + try: + total_cards = 0 + for _n, _e in getattr(b, 'card_library', {}).items(): + try: + total_cards += int(_e.get('Count', 1)) + except Exception: + total_cards += 1 + except Exception: + total_cards = None + saved = gating.get('res') or {} + saved['total_cards'] = total_cards + saved['gated'] = True + return saved + except Exception: + pass + # Gating cleared: advance to the next stage without rerunning the gated one + try: + del ctx['gating'] + except Exception: + ctx['gating'] = None + i = i + 1 + ctx['idx'] = i + # continue into loop with advanced index + except Exception: + pass + # Iterate forward until we find a stage that adds cards, skipping no-ops while i < len(stages): stage = stages[i] @@ -1866,6 +2149,45 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: clamped_overflow = 0 + # Compute compliance after this stage and apply gating when FAIL + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + except Exception: + comp = None + try: + if comp: + comp = _attach_enforcement_plan(b, comp) + except Exception: + pass + + # If FAIL, do not advance; save gating state and return current stage results + try: + if comp and str(comp.get('overall', 'PASS')).upper() == 'FAIL': + # Save a snapshot of the response to reuse while gated + res_hold = { + "done": False, + "label": label, + "log_delta": delta_log, + "added_cards": added_cards, + "idx": i + 1, + "total": len(stages), + "total_cards": total_cards, + "added_total": sum(int(c.get('count', 0) or 0) for c in added_cards) if added_cards else 0, + "mc_adjustments": ctx.get('mc_adjustments'), + "clamped_overflow": clamped_overflow, + "mc_summary": ctx.get('mc_summary'), + "gated": True, + } + ctx['gating'] = {"stage_idx": i, "label": label, "res": res_hold} + # Keep current index (do not advance) + ctx["snapshot"] = snap_before + ctx["last_visible_idx"] = i + 1 + return res_hold + except Exception: + pass + # If this stage added cards, present it and advance idx if added_cards: # Progress counts @@ -1911,6 +2233,38 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal # No cards added: either skip or surface as a 'skipped' stage if show_skipped: + # Compute compliance even when skipped; gate progression if FAIL + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + except Exception: + comp = None + try: + if comp: + comp = _attach_enforcement_plan(b, comp) + except Exception: + pass + try: + if comp and str(comp.get('overall', 'PASS')).upper() == 'FAIL': + res_hold = { + "done": False, + "label": label, + "log_delta": delta_log, + "added_cards": [], + "skipped": True, + "idx": i + 1, + "total": len(stages), + "total_cards": total_cards, + "added_total": 0, + "gated": True, + } + ctx['gating'] = {"stage_idx": i, "label": label, "res": res_hold} + ctx["snapshot"] = snap_before + ctx["last_visible_idx"] = i + 1 + return res_hold + except Exception: + pass # Progress counts even when skipped try: total_cards = 0 @@ -1945,7 +2299,39 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "added_total": 0, } - # No cards added and not showing skipped: advance to next stage and continue loop + # No cards added and not showing skipped: advance to next stage unless compliance FAIL gates progression + try: + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] + except Exception: + comp = None + try: + if comp: + comp = _attach_enforcement_plan(b, comp) + except Exception: + pass + if comp and str(comp.get('overall', 'PASS')).upper() == 'FAIL': + # Gate here with a skipped stage result + res_hold = { + "done": False, + "label": label, + "log_delta": delta_log, + "added_cards": [], + "skipped": True, + "idx": i + 1, + "total": len(stages), + "total_cards": total_cards, + "added_total": 0, + "gated": True, + } + ctx['gating'] = {"stage_idx": i, "label": label, "res": res_hold} + ctx["snapshot"] = snap_before + ctx["last_visible_idx"] = i + 1 + return res_hold + except Exception: + pass i += 1 # Continue loop to auto-advance @@ -1973,6 +2359,32 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] except Exception: pass + # Compute bracket compliance and save JSON alongside exports + try: + if hasattr(b, 'compute_and_print_compliance'): + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] + rep0 = _attach_enforcement_plan(b, rep0) + try: + import os as __os + _auto = str(__os.getenv('WEB_AUTO_ENFORCE', '0')).strip().lower() in {"1","true","yes","on"} + except Exception: + _auto = False + if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] + except Exception: + pass + # Load compliance JSON for UI consumption + try: + if rep0 is not None: + ctx["compliance"] = rep0 + else: + import json as _json + comp_path = _os.path.join('deck_files', f"{base}_compliance.json") + if _os.path.exists(comp_path): + with open(comp_path, 'r', encoding='utf-8') as _cf: + ctx["compliance"] = _json.load(_cf) + except Exception: + ctx["compliance"] = None except Exception as e: logs.append(f"Text export failed: {e}") # Build structured summary for UI @@ -2029,4 +2441,5 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "summary": summary, "total_cards": total_cards, "added_total": 0, + "compliance": ctx.get("compliance"), } diff --git a/code/web/templates/build/_compliance_panel.html b/code/web/templates/build/_compliance_panel.html new file mode 100644 index 0000000..1ef1db9 --- /dev/null +++ b/code/web/templates/build/_compliance_panel.html @@ -0,0 +1,46 @@ +{% if compliance %} +
+ Bracket compliance +
Overall: {{ compliance.overall }} (Bracket: {{ compliance.bracket|title }}{{ ' #' ~ compliance.level if compliance.level is defined }})
+ {% if compliance.messages and compliance.messages|length > 0 %} +
    + {% for m in compliance.messages %} +
  • {{ m }}
  • + {% endfor %} +
+ {% endif %} + + {# Flagged tiles by category, in the same card grid style #} + {% if flagged_meta and flagged_meta|length > 0 %} +
Flagged cards
+
+ {% for f in flagged_meta %} +
+ + {{ f.name }} image + +
{% if f.owned %}✔{% else %}✖{% endif %}
+
{{ f.name }}
+
{{ f.category }}{% if f.role %} • {{ f.role }}{% endif %}
+
+ {# Role-aware alternatives: pass the flagged name; server will infer role and exclude in-deck/locked #} + +
+
+
+ {% endfor %} +
+ {% endif %} + + {% if compliance.enforcement %} +
+
+ +
+
Tip: pick replacements first; your choices are honored during enforcement.
+
+ {% endif %} +
+{% endif %} diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index bf0cc18..369d62b 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -40,11 +40,13 @@
-
+
diff --git a/code/web/templates/build/_new_deck_tags.html b/code/web/templates/build/_new_deck_tags.html index 5328182..dbfc79a 100644 --- a/code/web/templates/build/_new_deck_tags.html +++ b/code/web/templates/build/_new_deck_tags.html @@ -62,6 +62,22 @@
+{# Always update the bracket dropdown on commander change; hide 1–2 only when gc_commander is true #} +
+ + {% if gc_commander %} +
Commander is a Game Changer; brackets 1–2 are unavailable.
+ {% endif %} +
+ +{% endblock %} diff --git a/config/brackets.yml b/config/brackets.yml new file mode 100644 index 0000000..77ce1ae --- /dev/null +++ b/config/brackets.yml @@ -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 diff --git a/config/card_lists/combos.json b/config/card_lists/combos.json index 785c352..8e4726a 100644 --- a/config/card_lists/combos.json +++ b/config/card_lists/combos.json @@ -1,6 +1,6 @@ { "list_version": "0.3.0", - "generated_at": null, + "generated_at": "2025-09-03T15:30:32+00:00", "pairs": [ { "a": "Thassa's Oracle", "b": "Demonic Consultation", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }, { "a": "Thassa's Oracle", "b": "Tainted Pact", "cheap_early": true, "setup_dependent": false, "tags": ["wincon"] }, diff --git a/config/card_lists/extra_turns.json b/config/card_lists/extra_turns.json new file mode 100644 index 0000000..89bef63 --- /dev/null +++ b/config/card_lists/extra_turns.json @@ -0,0 +1 @@ +{"source_url": "test", "generated_at": "now", "cards": ["Time Warp"]} \ No newline at end of file diff --git a/config/card_lists/game_changers.json b/config/card_lists/game_changers.json new file mode 100644 index 0000000..2eccace --- /dev/null +++ b/config/card_lists/game_changers.json @@ -0,0 +1 @@ +{"source_url": "test", "generated_at": "now", "cards": []} \ No newline at end of file diff --git a/config/card_lists/mass_land_denial.json b/config/card_lists/mass_land_denial.json new file mode 100644 index 0000000..3f6ed23 --- /dev/null +++ b/config/card_lists/mass_land_denial.json @@ -0,0 +1 @@ +{"source_url": "test", "generated_at": "now", "cards": ["Armageddon"]} \ No newline at end of file diff --git a/config/card_lists/synergies.json b/config/card_lists/synergies.json index 90670f2..38beea4 100644 --- a/config/card_lists/synergies.json +++ b/config/card_lists/synergies.json @@ -1,6 +1,6 @@ { "list_version": "0.4.0", - "generated_at": null, + "generated_at": "2025-09-03T15:30:40+00:00", "pairs": [ { "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" } diff --git a/config/card_lists/tutors_nonland.json b/config/card_lists/tutors_nonland.json new file mode 100644 index 0000000..f45e402 --- /dev/null +++ b/config/card_lists/tutors_nonland.json @@ -0,0 +1 @@ +{"source_url": "test", "generated_at": "now", "cards": ["Demonic Tutor"]} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a0f3a02..ef7d6bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,4 @@ 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: build: . container_name: mtg-deckbuilder-web @@ -33,21 +8,66 @@ services: PYTHONUNBUFFERED: "1" TERM: "xterm-256color" 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 - # ENABLE_THEMES: "1" - THEME: "dark" - # Logging and error utilities - SHOW_LOGS: "1" - SHOW_DIAGNOSTICS: "1" - # ENABLE_PWA: "1" - # Speed up setup/tagging in Web UI via parallel workers - WEB_TAG_PARALLEL: "1" - WEB_TAG_WORKERS: "4" - # Enable virtualization + lazy image tweaks in Step 5 - WEB_VIRTUALIZE: "1" - # Version label (optional; shown in footer/diagnostics) - APP_VERSION: "v2.2.3" + + # UI features/flags + SHOW_LOGS: "1" # 1=enable /logs page; 0=hide + SHOW_SETUP: "1" # 1=show Setup/Tagging card; 0=hide (still runs if WEB_AUTO_SETUP=1) + SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide + ENABLE_PWA: "0" # 1=serve manifest/service worker (experimental) + ENABLE_THEMES: "1" # 1=expose theme selector; 0=hide (THEME still applied) + ENABLE_PRESETS: "0" # 1=show presets section + WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5 + + # Theming + THEME: "dark" # system|light|dark + + # Setup/Tagging performance + 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" # 1–5 + # 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: - ${PWD}/deck_files:/app/deck_files - ${PWD}/logs:/app/logs diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml index 1add3d4..48a90d1 100644 --- a/dockerhub-docker-compose.yml +++ b/dockerhub-docker-compose.yml @@ -1,34 +1,77 @@ services: web: image: mwisnowski/mtg-python-deckbuilder:latest - # Tip: pin to a specific tag when available, e.g. :2.2.2 container_name: mtg-deckbuilder-web ports: - "8080:8080" # Host:Container — open http://localhost:8080 environment: - # UI features/flags (all optional) - SHOW_LOGS: "1" # 1=enable /logs page; 0=hide (default off if unset) - SHOW_DIAGNOSTICS: "1" # 1=enable /diagnostics & /diagnostics/perf; 0=hide (default off) - 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) + PYTHONUNBUFFERED: "1" + TERM: "xterm-256color" + DEBIAN_FRONTEND: "noninteractive" - # Theming (optional) - THEME: "system" # Default theme for first-time visitors: system|light|dark - # 'light' maps to the consolidated Light (Blend) palette - ENABLE_THEMES: "1" # 1=show theme selector in header; 0=hide selector - # Note: THEME still applies as the default even if selector is hidden + # UI features/flags + SHOW_LOGS: "1" + SHOW_SETUP: "1" + SHOW_DIAGNOSTICS: "1" + ENABLE_PWA: "0" + ENABLE_THEMES: "1" + ENABLE_PRESETS: "0" + WEB_VIRTUALIZE: "1" - # Version label (optional; shown in footer/diagnostics) - APP_VERSION: "v2.2.3" + # Theming + 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: - # Persist app data locally; ensure these directories exist next to this compose file - ${PWD}/deck_files:/app/deck_files - ${PWD}/logs:/app/logs - ${PWD}/csv_files:/app/csv_files - ${PWD}/config:/app/config - ${PWD}/owned_cards:/app/owned_cards - restart: unless-stopped \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 214a061..5e89230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mtg-deckbuilder" -version = "2.2.4" +version = "2.2.5" description = "A command-line tool for building and analyzing Magic: The Gathering decks" readme = "README.md" license = {file = "LICENSE"}