From fd7fc0107117f04750f8e2ffd14c2ba48883c5aa Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Tue, 26 Aug 2025 11:34:42 -0700 Subject: [PATCH] Web + backend: propagate tag_mode (AND/OR) end-to-end; AND-mode overlap prioritization for creatures and theme spells; headless configs support tag_mode; add Scryfall attribution footer and configs UI indicators; minor polish. (#and-overlap-pass) --- code/deck_builder/builder.py | 2 + code/deck_builder/phases/phase3_creatures.py | 21 +- code/deck_builder/phases/phase4_spells.py | 17 +- code/deck_builder/phases/phase6_reporting.py | 1 + code/tagging/tagger.py | 2 +- code/web/routes/build.py | 34 +- code/web/routes/configs.py | 11 +- code/web/services/orchestrator.py | 302 ++++++++++- code/web/static/styles.css | 2 + code/web/templates/base.html | 9 +- code/web/templates/build/_step1.html | 202 ++++++- code/web/templates/build/_step2.html | 283 +++++++++- code/web/templates/configs/run_result.html | 2 +- code/web/templates/configs/view.html | 1 + code/web/templates/decks/index.html | 525 ++++++++++++++++++- 15 files changed, 1339 insertions(+), 75 deletions(-) diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 27a069f..d3c5750 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -281,6 +281,8 @@ class DeckBuilder( secondary_tag: Optional[str] = None tertiary_tag: Optional[str] = None selected_tags: List[str] = field(default_factory=list) + # How to combine multiple selected tags when prioritizing cards: 'AND' or 'OR' + tag_mode: str = 'AND' # Future deck config placeholders color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G'] diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index d8957ca..cc486ce 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -85,6 +85,8 @@ class CreatureAdditionMixin: creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell) creature_df['_normTags'] = creature_df['_parsedThemeTags'] creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst)) + # In AND mode, prefer intersections: create a hard filter order 3 -> 2 -> 1 matches + combine_mode = getattr(self, 'tag_mode', 'AND') base_top = 30 top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0)) synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2) @@ -104,6 +106,10 @@ class CreatureAdditionMixin: continue tnorm = tag.lower() subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))] + if combine_mode == 'AND' and len(selected_tags_lower) > 1: + # Constrain to multi-tag overlap first if available + if (creature_df['_multiMatch'] >= 2).any(): + subset = subset[subset['_multiMatch'] >= 2] if subset.empty: self.output_func(f"Theme '{tag}' produced no creature candidates.") continue @@ -115,7 +121,11 @@ class CreatureAdditionMixin: pool = pool[~pool['name'].isin(added_names)] if pool.empty: continue - weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])] + # In AND mode, boost weights more aggressively for 2+ tag matches + if combine_mode == 'AND': + weighted_pool = [(nm, (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))) for nm, mm in zip(pool['name'], pool['_multiMatch'])] + else: + weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])] chosen = bu.weighted_sample_without_replacement(weighted_pool, target) for nm in chosen: if commander_name and nm == commander_name: @@ -145,7 +155,14 @@ class CreatureAdditionMixin: if total_added < desired_total: need = desired_total - total_added multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy() - multi_pool = multi_pool[multi_pool['_multiMatch'] > 0] + if combine_mode == 'AND' and len(selected_tags_lower) > 1: + # First prefer 3+ then 2, finally 1 + prioritized = multi_pool[multi_pool['_multiMatch'] >= 2] + if prioritized.empty: + prioritized = multi_pool[multi_pool['_multiMatch'] > 0] + multi_pool = prioritized + else: + multi_pool = multi_pool[multi_pool['_multiMatch'] > 0] if not multi_pool.empty: if 'edhrecRank' in multi_pool.columns: multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last') diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index 89d1b4f..168b5c8 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -452,6 +452,7 @@ class SpellAdditionMixin: spells_df['_multiMatch'] = spells_df['_normTags'].apply( lambda lst: sum(1 for t in selected_tags_lower if t in lst) ) + combine_mode = getattr(self, 'tag_mode', 'AND') base_top = 40 top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0)) synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2) @@ -473,6 +474,9 @@ class SpellAdditionMixin: lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst) ) ] + if combine_mode == 'AND' and len(selected_tags_lower) > 1: + if (spells_df['_multiMatch'] >= 2).any(): + subset = subset[subset['_multiMatch'] >= 2] if subset.empty: continue if 'edhrecRank' in subset.columns: @@ -491,7 +495,10 @@ class SpellAdditionMixin: pool = pool[~pool['name'].isin(self.card_library.keys())] if pool.empty: continue - weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ] + if combine_mode == 'AND': + weighted_pool = [ (nm, (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))) for nm, mm in zip(pool['name'], pool['_multiMatch']) ] + else: + weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ] chosen = bu.weighted_sample_without_replacement(weighted_pool, target) for nm in chosen: row = pool[pool['name'] == nm].iloc[0] @@ -514,7 +521,13 @@ 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 = multi_pool[multi_pool['_multiMatch'] > 0] + if combine_mode == 'AND' and len(selected_tags_lower) > 1: + prioritized = multi_pool[multi_pool['_multiMatch'] >= 2] + if prioritized.empty: + prioritized = multi_pool[multi_pool['_multiMatch'] > 0] + multi_pool = prioritized + else: + multi_pool = multi_pool[multi_pool['_multiMatch'] > 0] if not multi_pool.empty: if 'edhrecRank' in multi_pool.columns: multi_pool = multi_pool.sort_values( diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index fced821..f0e6bc7 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -639,6 +639,7 @@ class ReportingMixin: "secondary_tag": getattr(self, 'secondary_tag', None), "tertiary_tag": getattr(self, 'tertiary_tag', None), "bracket_level": getattr(self, 'bracket_level', None), + "tag_mode": (getattr(self, 'tag_mode', 'AND') or 'AND'), "use_multi_theme": True, "add_lands": True, "add_creatures": True, diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index 2a3317f..4d9bb3b 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -4501,7 +4501,7 @@ def tag_for_bending(df: pd.DataFrame, color: str) -> None: rules = [] air_mask = tag_utils.create_text_mask(df, 'airbend') if air_mask.any(): - rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] }) + rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters', 'Leave the Battlefield'] }) water_mask = tag_utils.create_text_mask(df, 'waterbend') if water_mask.any(): diff --git a/code/web/routes/build.py b/code/web/routes/build.py index afe57c3..5fdd5c9 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -28,7 +28,12 @@ async def build_step1(request: Request) -> HTMLResponse: @router.post("/step1", response_class=HTMLResponse) -async def build_step1_search(request: Request, query: str = Form(""), auto: str | None = Form(None)) -> HTMLResponse: +async def build_step1_search( + request: Request, + query: str = Form(""), + auto: str | None = Form(None), + active: str | None = Form(None), +) -> HTMLResponse: query = (query or "").strip() auto_enabled = True if (auto == "1") else False candidates = [] @@ -45,10 +50,22 @@ async def build_step1_search(request: Request, query: str = Form(""), auto: str "request": request, "commander": res, "tags": orch.tags_for_commander(res["name"]), + "recommended": orch.recommended_tags_for_commander(res["name"]), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), }, ) - return templates.TemplateResponse("build/_step1.html", {"request": request, "query": query, "candidates": candidates, "auto": auto_enabled}) + return templates.TemplateResponse( + "build/_step1.html", + { + "request": request, + "query": query, + "candidates": candidates, + "auto": auto_enabled, + "active": active, + "count": len(candidates) if candidates else 0, + }, + ) @router.post("/step1/inspect", response_class=HTMLResponse) @@ -72,6 +89,8 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe "request": request, "commander": res, "tags": orch.tags_for_commander(res["name"]), + "recommended": orch.recommended_tags_for_commander(res["name"]), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), }, ) @@ -93,11 +112,14 @@ async def build_step2_get(request: Request) -> HTMLResponse: "request": request, "commander": {"name": commander}, "tags": tags, + "recommended": orch.recommended_tags_for_commander(commander), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "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"), + "tag_mode": sess.get("tag_mode", "AND"), }, ) @@ -109,6 +131,7 @@ async def build_step2_submit( primary_tag: str | None = Form(None), secondary_tag: str | None = Form(None), tertiary_tag: str | None = Form(None), + tag_mode: str | None = Form("AND"), bracket: int = Form(...), ) -> HTMLResponse: # Validate primary tag selection if tags are available @@ -120,12 +143,15 @@ async def build_step2_submit( "request": request, "commander": {"name": commander}, "tags": available_tags, + "recommended": orch.recommended_tags_for_commander(commander), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "error": "Please choose a primary theme.", "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, + "tag_mode": (tag_mode or "AND"), }, ) @@ -134,6 +160,7 @@ async def build_step2_submit( sess = get_session(sid) sess["commander"] = commander sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] + sess["tag_mode"] = (tag_mode or "AND").upper() sess["bracket"] = int(bracket) # Proceed to Step 3 placeholder for now return templates.TemplateResponse( @@ -309,6 +336,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse: tags=sess.get("tags", []), bracket=safe_bracket, ideals=ideals_val, + tag_mode=sess.get("tag_mode", "AND"), ) res = orch.run_stage(sess["build_ctx"], rerun=False) status = "Build complete" if res.get("done") else "Stage complete" @@ -367,6 +395,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: tags=sess.get("tags", []), bracket=safe_bracket, ideals=ideals_val, + tag_mode=sess.get("tag_mode", "AND"), ) res = orch.run_stage(sess["build_ctx"], rerun=True) status = "Stage rerun complete" if not res.get("done") else "Build complete" @@ -430,6 +459,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: tags=sess.get("tags", []), bracket=safe_bracket, ideals=ideals_val, + tag_mode=sess.get("tag_mode", "AND"), ) res = orch.run_stage(sess["build_ctx"], rerun=False) status = "Stage complete" if not res.get("done") else "Build complete" diff --git a/code/web/routes/configs.py b/code/web/routes/configs.py index 823131d..1f2fdcc 100644 --- a/code/web/routes/configs.py +++ b/code/web/routes/configs.py @@ -117,9 +117,16 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse: tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t] bracket = int(cfg.get("bracket_level") or 0) ideals = cfg.get("ideal_counts", {}) or {} + # Optional combine mode for tags (AND/OR); support a few aliases + try: + tag_mode = (str(cfg.get("tag_mode") or cfg.get("combine_mode") or cfg.get("mode") or "AND").upper()) + if tag_mode not in ("AND", "OR"): + tag_mode = "AND" + except Exception: + tag_mode = "AND" # Run build headlessly with orchestrator - res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals) + res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals, tag_mode=tag_mode) if not res.get("ok"): return templates.TemplateResponse( "configs/run_result.html", @@ -130,6 +137,7 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse: "log": res.get("log", ""), "cfg_name": p.name, "commander": commander, + "tag_mode": tag_mode, }, ) return templates.TemplateResponse( @@ -143,6 +151,7 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse: "summary": res.get("summary"), "cfg_name": p.name, "commander": commander, + "tag_mode": tag_mode, "game_changers": bc.GAME_CHANGERS, }, ) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index acf719c..54025c5 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -10,6 +10,8 @@ import time import json from datetime import datetime as _dt import re +import unicodedata +from glob import glob def commander_names() -> List[str]: @@ -19,6 +21,22 @@ def commander_names() -> List[str]: def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, List[str]]]: + def _strip_accents(s: str) -> str: + try: + return ''.join(ch for ch in unicodedata.normalize('NFKD', str(s)) if not unicodedata.combining(ch)) + except Exception: + return str(s) + + def _simplify(s: str) -> str: + try: + s2 = _strip_accents(str(s)) + s2 = s2.lower() + # remove punctuation/symbols, keep letters/numbers/spaces + s2 = re.sub(r"[^a-z0-9\s]", " ", s2) + s2 = re.sub(r"\s+", " ", s2).strip() + return s2 + except Exception: + return str(s).lower().strip() # Normalize query similar to CLI to reduce case sensitivity surprises tmp = DeckBuilder() try: @@ -64,15 +82,21 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li # to avoid missing obvious matches like 'Inti, Seneschal of the Sun' for 'inti'. try: q_raw = (query or "").strip().lower() + q_norm = _simplify(query) if q_raw: have = {n for (n, _s) in pool} # Map original scores for reuse base_scores = {n: int(s) for (n, s) in scored_raw} for n in names: nl = str(n).lower() - if q_raw in nl and n not in have: + nn = _simplify(n) + if (q_raw in nl or (q_norm and q_norm in nn)) and n not in have: # Assign a reasonable base score if not present; favor prefixes - approx = base_scores.get(n, 90 if nl.startswith(q_raw) else 80) + approx_base = base_scores.get(n) + if approx_base is None: + starts = nl.startswith(q_raw) or (q_norm and nn.startswith(q_norm)) + approx_base = 90 if starts else 80 + approx = approx_base pool.append((n, approx)) except Exception: pass @@ -82,7 +106,9 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li except Exception: df = None q = (query or "").strip().lower() + qn = _simplify(query) tokens = [t for t in re.split(r"[\s,]+", q) if t] + tokens_norm = [t for t in (qn.split(" ") if qn else []) if t] def _color_list_for(name: str) -> List[str]: colors: List[str] = [] try: @@ -106,42 +132,60 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li colors: List[str] = [] colors = _color_list_for(name) nl = str(name).lower() + nnorm = _simplify(name) bonus = 0 pos = nl.find(q) if q else -1 + pos_norm = nnorm.find(qn) if qn else -1 + pos_final = pos if pos >= 0 else pos_norm # Extract first word (letters only) for exact first-word preference try: m_first = re.match(r"^[a-z0-9']+", nl) first_word = m_first.group(0) if m_first else "" except Exception: first_word = nl.split(" ", 1)[0] if nl else "" - exact_first = 1 if (q and first_word == q) else 0 + # Normalized first word + try: + m_first_n = re.match(r"^[a-z0-9']+", nnorm) + first_word_n = m_first_n.group(0) if m_first_n else "" + except Exception: + first_word_n = nnorm.split(" ", 1)[0] if nnorm else "" + exact_first = 1 if ((q and first_word == q) or (qn and first_word_n == qn)) else 0 # Base heuristics - if q: - if nl == q: + if q or qn: + if q and nl == q: bonus += 100 - if nl.startswith(q): + elif qn and nnorm == qn: + bonus += 85 + if (q and nl.startswith(q)) or (qn and nnorm.startswith(qn)): bonus += 60 - if re.search(r"\b" + re.escape(q), nl): + if q and re.search(r"\b" + re.escape(q), nl): bonus += 40 - if q in nl: + if (q and q in nl) or (qn and qn in nnorm): bonus += 30 # Strongly prefer exact first-word equality over general prefix if exact_first: bonus += 140 # Multi-token bonuses - if tokens: - present = sum(1 for t in tokens if t in nl) - all_present = 1 if all(t in nl for t in tokens) else 0 + if tokens_norm or tokens: + present = 0 + all_present = 0 + if tokens_norm: + present = sum(1 for t in tokens_norm if t in nnorm) + all_present = 1 if all(t in nnorm for t in tokens_norm) else 0 + elif tokens: + present = sum(1 for t in tokens if t in nl) + all_present = 1 if all(t in nl for t in tokens) else 0 bonus += present * 10 + all_present * 40 # Extra if first token is a prefix - if nl.startswith(tokens[0]): + t0 = (tokens_norm[0] if tokens_norm else (tokens[0] if tokens else None)) + if t0 and (nnorm.startswith(t0) or nl.startswith(t0)): bonus += 15 # Favor shorter names slightly and earlier positions bonus += max(0, 20 - len(nl)) - if pos >= 0: - bonus += max(0, 20 - pos) + if pos_final >= 0: + bonus += max(0, 20 - pos_final) rank_score = int(score) + bonus - rescored.append((name, int(score), colors, rank_score, pos if pos >= 0 else 10**6, exact_first)) + rescored.append((name, int(score), colors, rank_score, pos_final if pos_final >= 0 else 10**6, exact_first)) # Sort: exact first-word matches first, then by rank score desc, then earliest position, then original score desc, then name asc rescored.sort(key=lambda x: (-x[5], -x[3], x[4], -x[1], x[0])) @@ -200,6 +244,214 @@ def tags_for_commander(name: str) -> List[str]: return [] +def _recommended_scored(name: str, max_items: int = 5) -> List[Tuple[str, int, List[str]]]: + """Internal: return list of (tag, score, reasons[]) for top recommendations.""" + available_list = list(tags_for_commander(name) or []) + if not available_list: + return [] + # Case-insensitive map: normalized -> original display tag + def _norm(s: str) -> str: + try: + return re.sub(r"\s+", " ", str(s).strip().lower()) + except Exception: + return str(s).strip().lower() + norm_map: Dict[str, str] = { _norm(t): t for t in available_list } + available_norm = set(norm_map.keys()) + available_norm_list = list(available_norm) + + def _best_match_norm(tn: str) -> str | None: + """Return the best available normalized tag matching tn by exact or substring.""" + if tn in available_norm: + return tn + try: + # prefer contains matches with minimal length difference + candidates = [] + for an in available_norm_list: + if tn in an or an in tn: + candidates.append((abs(len(an) - len(tn)), an)) + if candidates: + candidates.sort(key=lambda x: (x[0], x[1])) + return candidates[0][1] + except Exception: + return None + return None + try: + tmp = DeckBuilder() + df = tmp.load_commander_data() + except Exception: + df = None + # Gather commander text and colors + text = "" + colors: List[str] = [] + if df is not None: + try: + row = df[df["name"].astype(str) == str(name)] + if not row.empty: + r0 = row.iloc[0] + text = str(r0.get("text", r0.get("oracleText", "")) or "").lower() + ci = r0.get("colorIdentity") + if isinstance(ci, list): + colors = [str(c).upper() for c in ci if str(c).strip()] + elif isinstance(ci, str) and ci.strip(): + parts = [p.strip().upper() for p in ci.replace('[', '').replace(']', '').replace("'", '').split(',') if p.strip()] + colors = parts if parts else list(ci) + except Exception: + pass + if not colors: + colors = ["C"] + + score: Dict[str, int] = {t: 0 for t in available_list} + reasons: Dict[str, List[str]] = {t: [] for t in available_list} + order_index = {t: i for i, t in enumerate(list(available_list))} + + # Anchor weight; omit reason to keep tooltip focused + for t in list(available_list): + score[t] += 30 + + # Keyword patterns -> tags with labeled reasons + patterns: List[Tuple[str, List[str], List[str], int]] = [ + ("Oracle mentions treasure/tokens", [r"\btreasure\b"], ["treasure", "tokens"], 8), + ("Oracle mentions tokens", [r"\btoken\b", r"create .* token"], ["tokens"], 10), + ("Oracle mentions sacrifice/death", [r"\bsacrifice\b", r"whenever .* dies"], ["sacrifice", "aristocrats"], 9), + ("Oracle mentions graveyard/recursion", [r"graveyard", r"from your graveyard", r"return .* from graveyard"], ["graveyard"], 9), + ("Oracle mentions lifegain/lifelink", [r"\bgain life\b", r"lifelink"], ["lifegain"], 9), + ("Oracle mentions instants/sorceries", [r"instant or sorcery", r"whenever you cast an instant", r"prowess"], ["spellslinger", "spells"], 9), + ("Oracle mentions artifacts/equipment", [r"\bartifact\b", r"equipment"], ["artifacts", "equipment"], 8), + ("Oracle mentions enchantments/auras", [r"\benchant\b", r"aura"], ["enchantments", "auras"], 7), + ("Oracle mentions +1/+1 counters", [r"\+1/\+1 counter", r"put .* counters?"], ["+1/+1 counters", "counters"], 8), + ("Oracle suggests blink/flicker", [r"exile .* return .* battlefield", r"blink"], ["blink"], 7), + ("Oracle mentions vehicles/crew", [r"vehicle", r"crew"], ["vehicles"], 6), + ("Oracle references legendary/legends", [r"\blegendary\b", r"legend(ary)?\b"], ["legends matter", "legends", "legendary matters"], 8), + ("Oracle references historic", [r"\bhistoric(s)?\b"], ["historics matter", "historic"], 7), + ("Oracle suggests aggressive attacks/haste", [r"\bhaste\b", r"attacks? each combat", r"whenever .* attacks"], ["aggro"], 6), + ("Oracle references direct damage", [r"deal \d+ damage", r"damage to any target", r"noncombat damage"], ["burn"], 6), + ] + for label, pats, tags_out, w in patterns: + try: + if any(re.search(p, text) for p in pats): + for tg in tags_out: + tn = _norm(tg) + bm = _best_match_norm(tn) + if bm is None: + continue + orig = norm_map[bm] + score[orig] = score.get(orig, 0) + w + if len(reasons[orig]) < 3 and label not in reasons[orig]: + reasons[orig].append(label) + except Exception: + continue + + # Color identity mapped defaults + ci_key_sorted = ''.join(sorted(colors)) + color_map: Dict[str, List[Tuple[str, int]]] = { + 'GW': [("tokens", 5), ("enchantments", 4), ("+1/+1 counters", 4)], + 'WU': [("blink", 5), ("control", 4)], + 'UB': [("graveyard", 5), ("control", 4)], + 'BR': [("sacrifice", 5), ("aristocrats", 4)], + 'RG': [("landfall", 4), ("tokens", 3)], + 'UR': [("spells", 5), ("artifacts", 4)], + 'WB': [("lifegain", 5), ("aristocrats", 4)], + 'BG': [("graveyard", 5), ("counters", 4)], + 'WR': [("equipment", 5), ("tokens", 4)], + 'UG': [("+1/+1 counters", 5), ("ramp", 4)], + 'WUB': [("blink", 4), ("control", 4)], + 'WBR': [("lifegain", 4), ("aristocrats", 4)], + 'UBR': [("spells", 4), ("artifacts", 3)], + 'BRG': [("sacrifice", 4), ("graveyard", 4)], + 'RGW': [("tokens", 4), ("counters", 3)], + 'GWU': [("blink", 4), ("enchantments", 3)], + 'WUBR': [("control", 4), ("spells", 3)], + 'UBRG': [("graveyard", 4), ("spells", 3)], + 'BRGW': [("tokens", 3), ("sacrifice", 3)], + 'RGWU': [("counters", 3), ("tokens", 3)], + 'WUBRG': [("artifacts", 3), ("tokens", 3)], + } + # Build lookup keyed by sorted color string to be order-agnostic + try: + color_map_lookup: Dict[str, List[Tuple[str, int]]] = { ''.join(sorted(list(k))): v for k, v in color_map.items() } + except Exception: + color_map_lookup = color_map + if ci_key_sorted in color_map_lookup: + for tg, w in color_map_lookup[ci_key_sorted]: + tn = _norm(tg) + bm = _best_match_norm(tn) + if bm is None: + continue + orig = norm_map[bm] + score[orig] = score.get(orig, 0) + w + cr = f"Fits your colors ({ci_key_sorted})" + if len(reasons[orig]) < 3 and cr not in reasons[orig]: + reasons[orig].append(cr) + + # Past builds history + try: + for path in glob(os.path.join('deck_files', '*.summary.json')): + try: + st = os.stat(path) + age_days = max(0, (time.time() - st.st_mtime) / 86400.0) + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) or {} + meta = data.get('meta') or {} + if str(meta.get('commander', '')).strip() != str(name).strip(): + continue + tags_list = meta.get('tags') or [] + for tg in tags_list: + tn = _norm(str(tg)) + if tn in available_norm: + orig = norm_map[tn] + inc = 2 + recent = False + if age_days <= 30: + inc += 2 + recent = True + elif age_days <= 90: + inc += 1 + score[orig] = score.get(orig, 0) + inc + lbl = "Popular in your past builds" + (" (recent)" if recent else "") + if len(reasons[orig]) < 3 and lbl not in reasons[orig]: + reasons[orig].append(lbl) + except Exception: + continue + except Exception: + pass + + items = [(k, score.get(k, 0), reasons.get(k, [])) for k in available_list] + items.sort(key=lambda x: (-x[1], order_index.get(x[0], 10**6), x[0])) + # Trim reasons to at most two concise bullets and format as needed later + top = items[:max_items] + return top + + +def recommended_tags_for_commander(name: str, max_items: int = 5) -> List[str]: + """Suggest up to `max_items` theme tags for a commander (tags only).""" + try: + return [tag for (tag, _s, _r) in _recommended_scored(name, max_items=max_items)] + except Exception: + return [] + + +def recommended_tag_reasons_for_commander(name: str, max_items: int = 5) -> Dict[str, str]: + """Return a mapping of tag -> short reason for why it was recommended.""" + try: + res: Dict[str, str] = {} + for tag, _score, rs in _recommended_scored(name, max_items=max_items): + # Build a concise reason string + if not rs: + res[tag] = "From this commander's theme list" + else: + # Take up to two distinct reasons + uniq: List[str] = [] + for r in rs: + if r and r not in uniq: + uniq.append(r) + if len(uniq) >= 2: + break + res[tag] = "; ".join(uniq) + return res + except Exception: + return {} + + def bracket_options() -> List[Dict[str, Any]]: return [{"level": b.level, "name": b.name, "desc": b.short_desc} for b in BRACKET_DEFINITIONS] @@ -379,7 +631,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: _write_status({"running": False, "phase": "error", "message": "Setup check failed"}) -def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int]) -> Dict[str, Any]: +def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None) -> Dict[str, Any]: """Run the deck build end-to-end with provided selections and capture logs. Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] } @@ -426,6 +678,14 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Ideal counts b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()} + # Apply tag combine mode + try: + b.tag_mode = (str(tag_mode).upper() if tag_mode else b.tag_mode) + if b.tag_mode not in ('AND','OR'): + b.tag_mode = 'AND' + except Exception: + pass + # Load data and run phases try: b.determine_color_identity() @@ -562,7 +822,7 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: return stages -def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int]) -> Dict[str, Any]: +def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None) -> Dict[str, Any]: logs: List[str] = [] def out(msg: str) -> None: @@ -597,6 +857,14 @@ def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[ b.bracket_limits = dict(getattr(bd, 'limits', {})) # Ideals b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()} + # Apply tag combine mode + try: + b.tag_mode = (str(tag_mode).upper() if tag_mode else b.tag_mode) + if b.tag_mode not in ('AND','OR'): + b.tag_mode = 'AND' + except Exception: + pass + # Data load b.determine_color_identity() b.setup_dataframes() diff --git a/code/web/static/styles.css b/code/web/static/styles.css index ba447bf..64fe3ac 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -21,6 +21,8 @@ *{box-sizing:border-box} html,body{height:100%} body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); } +/* Honor HTML hidden attribute across the app */ +[hidden] { display: none !important; } /* Top banner */ .top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); } .top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; } diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 7cf568b..2108f6d 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -5,7 +5,7 @@ MTG Deckbuilder - +
@@ -38,6 +38,11 @@ {% block content %}{% endblock %} +
+ Card images and data provided by + Scryfall. + This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast. +
diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html index a5b5b9d..6d09470 100644 --- a/code/web/templates/build/_step2.html +++ b/code/web/templates/build/_step2.html @@ -18,30 +18,55 @@
Theme Tags {% if tags %} - - - + + + + +
Pick up to three themes. Toggle AND/OR to control how themes combine.
+
+ Combine +
+ + +
+ + +
+
Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.
+
+ {% if recommended and recommended|length %} +
+
Recommended
+ +
+ +
+ {% for r in recommended %} + {% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %} + {% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %} + + {% endfor %} + +
+ {% endif %} +
+ {% for t in tags %} + {% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %} + + {% endfor %} +
{% else %}

No theme tags available for this commander.

{% endif %} @@ -75,3 +100,213 @@ + + diff --git a/code/web/templates/configs/run_result.html b/code/web/templates/configs/run_result.html index c5ac14f..9dcbd66 100644 --- a/code/web/templates/configs/run_result.html +++ b/code/web/templates/configs/run_result.html @@ -3,7 +3,7 @@

Build from JSON: {{ cfg_name }}

This page shows the results of a non-interactive build from the selected JSON configuration.

{% if commander %} -
Commander: {{ commander }}
+
Commander: {{ commander }}{% if tag_mode %} · Combine: {{ tag_mode }}{% endif %}
{% endif %}
diff --git a/code/web/templates/configs/view.html b/code/web/templates/configs/view.html index 282f7b8..a76e215 100644 --- a/code/web/templates/configs/view.html +++ b/code/web/templates/configs/view.html @@ -7,6 +7,7 @@
Commander
{{ data.commander }}
Tags
{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}
+
Combine Mode
{{ (data.tag_mode or data.combine_mode or 'AND') | upper }}
Bracket
{{ data.bracket_level }}
diff --git a/code/web/templates/decks/index.html b/code/web/templates/decks/index.html index 8768408..fadd906 100644 --- a/code/web/templates/decks/index.html +++ b/code/web/templates/decks/index.html @@ -1,21 +1,38 @@ {% extends "base.html" %} {% block banner_subtitle %}Finished Decks{% endblock %} {% block content %} -

Finished Decks

+

Finished Decks

These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.

{% if error %}
{{ error }}
{% endif %} -
- +
+ + + + + + + + +
+
Theme filters
+
{% if items %} -
+
{% for it in items %} -
+
@@ -24,17 +41,54 @@ {% if it.tags and it.tags|length %}
Themes: {{ it.tags|join(', ') }}
{% endif %} +
+ {% if it.mtime is defined %} + {{ it.mtime | int }} + {% endif %} +
-
+
+
+ + +
+ {% if it.txt_path %} +
+ + +
+ {% endif %}
- +
{% endfor %}
+ + + {% else %}
No exports yet. Run a build to create one.
{% endif %} @@ -42,14 +96,459 @@ + {% endblock %}