Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update

This commit is contained in:
mwisnowski 2025-08-26 16:25:34 -07:00
parent fd7fc01071
commit 625f6abb13
26 changed files with 1618 additions and 229 deletions

View file

@ -309,6 +309,8 @@ class DeckBuilder(
use_owned_only: bool = False
owned_card_names: set[str] = field(default_factory=set)
owned_files_selected: List[str] = field(default_factory=list)
# Soft preference: bias selection toward owned names without excluding others
prefer_owned: bool = False
# Deck library (cards added so far) mapping name->record
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
@ -986,6 +988,7 @@ class DeckBuilder(
self.output_func("Owned-only mode: no recognizable name column to filter on; skipping filter.")
except Exception as _e:
self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}")
# Soft prefer-owned does not filter the pool; biasing is applied later at selection time
self._combined_cards_df = combined
# Preserve original snapshot for enrichment across subsequent removals
if self._full_cards_df is None:

View file

@ -158,6 +158,7 @@ __all__ = [
'compute_spell_pip_weights',
'parse_theme_tags',
'normalize_theme_list',
'prefer_owned_first',
'compute_adjusted_target',
'normalize_tag_cell',
'sort_by_priority',
@ -418,6 +419,32 @@ def sort_by_priority(df, columns: list[str]):
return df.sort_values(by=present, ascending=[True]*len(present), na_position='last')
def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
"""Stable-reorder DataFrame to put owned names first while preserving prior sort.
- Adds a temporary column to flag ownership, sorts by it desc with mergesort, then drops it.
- If the name column is missing or owned_names_lower empty, returns df unchanged.
"""
try:
if df is None or getattr(df, 'empty', True):
return df
if not owned_names_lower:
return df
if name_col not in df.columns:
return df
tmp_col = '_ownedPref'
# Avoid clobbering if already present
while tmp_col in df.columns:
tmp_col = tmp_col + '_x'
ser = df[name_col].astype(str).str.lower().isin(owned_names_lower).astype(int)
df = df.assign(**{tmp_col: ser})
df = df.sort_values(by=[tmp_col], ascending=[False], kind='mergesort')
df = df.drop(columns=[tmp_col])
return df
except Exception:
return df
# ---------------------------------------------------------------------------
# Tag-driven land suggestion helpers
# ---------------------------------------------------------------------------

View file

@ -85,13 +85,74 @@ 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)
total_added = 0
added_names: List[str] = []
# AND pre-pass: pick creatures that hit all selected themes first (if 2+ themes)
all_theme_added: List[tuple[str, List[str]]] = []
if combine_mode == 'AND' and len(selected_tags_lower) >= 2:
all_cnt = len(selected_tags_lower)
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
remaining_capacity = max(0, desired_total - total_added)
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
if target_cap > 0:
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
subset_all = subset_all[~subset_all['name'].isin(added_names)]
if not subset_all.empty:
if 'edhrecRank' in subset_all.columns:
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset_all.columns:
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
# Bias owned names ahead before weighting
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
for nm in subset_all['name'].tolist():
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
for nm in chosen_all:
if commander_name and nm == commander_name:
continue
row = subset_all[subset_all['name'] == nm].iloc[0]
# Which selected themes does this card hit?
selected_display_tags = [t for _r, t in themes_ordered]
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
try:
hits = [t for t in selected_display_tags if str(t).lower() in norm_tags]
except Exception:
hits = selected_display_tags
self.add_card(
nm,
card_type=row.get('type','Creature'),
mana_cost=row.get('manaCost',''),
mana_value=row.get('manaValue', row.get('cmc','')),
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='creature',
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
)
added_names.append(nm)
all_theme_added.append((nm, hits))
total_added += 1
if total_added >= desired_total:
break
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
# Per-theme distribution
per_theme_added: Dict[str, List[str]] = {r: [] for r,_t in themes_ordered}
for role, tag in themes_ordered:
w = weights.get(role, 0.0)
@ -107,7 +168,6 @@ class CreatureAdditionMixin:
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:
@ -117,15 +177,30 @@ class CreatureAdditionMixin:
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
pool = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(added_names)]
if pool.empty:
continue
# In AND mode, boost weights more aggressively for 2+ tag matches
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
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'])]
weighted_pool = []
for nm, mm in zip(pool['name'], pool['_multiMatch']):
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
else:
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
weighted_pool = []
for nm, mm in zip(pool['name'], pool['_multiMatch']):
base_w = (synergy_bonus if mm >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen:
if commander_name and nm == commander_name:
@ -152,11 +227,11 @@ class CreatureAdditionMixin:
self.output_func(f"Added {len(per_theme_added[role])} creatures for {role} theme '{tag}' (target {target}).")
if total_added >= desired_total:
break
# Fill remaining if still short
if total_added < desired_total:
need = desired_total - total_added
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
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]
@ -168,6 +243,10 @@ class CreatureAdditionMixin:
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
elif 'manaValue' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
fill = multi_pool['name'].tolist()[:need]
for nm in fill:
if commander_name and nm == commander_name:
@ -190,7 +269,15 @@ class CreatureAdditionMixin:
if total_added >= desired_total:
break
self.output_func(f"Fill pass added {min(need, len(fill))} extra creatures (shortfall compensation).")
# Summary output
self.output_func("\nCreatures Added:")
if all_theme_added:
self.output_func(f" All-Theme overlap: {len(all_theme_added)}")
for nm, hits in all_theme_added:
if hits:
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
else:
self.output_func(f" - {nm}")
for role, tag in themes_ordered:
lst = per_theme_added.get(role, [])
if lst:
@ -401,3 +488,73 @@ class CreatureAdditionMixin:
def add_creatures_fill_phase(self):
return self._add_creatures_fill()
def add_creatures_all_theme_phase(self):
"""Staged pre-pass: when AND mode and 2+ tags, add creatures matching all selected themes first."""
combine_mode = getattr(self, 'tag_mode', 'AND')
tags = [t for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)] if t]
if combine_mode != 'AND' or len(tags) < 2:
return
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
current_added = self._creature_count_in_library()
remaining_capacity = max(0, desired_total - current_added)
if remaining_capacity <= 0:
return
creature_df = self._prepare_creature_pool()
if creature_df is None or creature_df.empty:
return
all_cnt = len(tags)
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
existing_names = set(getattr(self, 'card_library', {}).keys())
subset_all = subset_all[~subset_all['name'].isin(existing_names)]
if subset_all.empty or target_cap <= 0:
return
if 'edhrecRank' in subset_all.columns:
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset_all.columns:
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
weighted_pool = []
for nm in subset_all['name'].tolist():
w = weight_strong
if owned_lower and str(nm).lower() in owned_lower:
w *= owned_mult
weighted_pool.append((nm, w))
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
added = 0
for nm in chosen_all:
row = subset_all[subset_all['name'] == nm].iloc[0]
# Determine which selected themes this card hits for display
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
hits: List[str] = []
try:
hits = [t for t in tags if str(t).lower() in norm_tags]
except Exception:
hits = list(tags)
self.add_card(
nm,
card_type=row.get('type','Creature'),
mana_cost=row.get('manaCost',''),
mana_value=row.get('manaValue', row.get('cmc','')),
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
role='creature',
sub_role='all_theme',
added_by='creature_all_theme',
trigger_tag=", ".join(hits) if hits else None,
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
)
added += 1
if added >= target_cap:
break
if added:
self.output_func(f"All-Theme AND Pre-Pass: added {added}/{target_cap} creatures (matching all {all_cnt} themes)")

View file

@ -57,6 +57,12 @@ class SpellAdditionMixin:
if commander_name:
work = work[work['name'] != commander_name]
work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
# 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)
if owned_set:
owned_lower = {str(n).lower() for n in owned_set}
work = bu.prefer_owned_first(work, owned_lower)
rocks_target = min(target_total, math.ceil(target_total/3))
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
@ -143,6 +149,10 @@ class SpellAdditionMixin:
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
existing = 0
for name, entry in self.card_library.items():
lt = [str(t).lower() for t in entry.get('Tags', [])]
@ -201,6 +211,10 @@ class SpellAdditionMixin:
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
@ -277,6 +291,12 @@ class SpellAdditionMixin:
return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
conditional_df = sortit(conditional_df)
unconditional_df = sortit(unconditional_df)
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
owned_lower = {str(n).lower() for n in owned_set}
conditional_df = bu.prefer_owned_first(conditional_df, owned_lower)
unconditional_df = bu.prefer_owned_first(unconditional_df, owned_lower)
added_cond = 0
added_cond_names: List[str] = []
for _, r in conditional_df.iterrows():
@ -349,6 +369,10 @@ class SpellAdditionMixin:
if commander_name:
pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
existing = 0
for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])]
@ -491,14 +515,32 @@ class SpellAdditionMixin:
ascending=[False, True],
na_position='last',
)
# Prefer-owned: stable reorder before trimming to top_n
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
pool = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(self.card_library.keys())]
if pool.empty:
continue
# Build weighted pool with optional owned multiplier
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
weighted_pool: list[tuple[str, float]] = []
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']) ]
for nm, mm in base_pairs:
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
else:
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
for nm, mm in base_pairs:
base_w = (synergy_bonus if mm >= 2 else 1.0)
if owned_lower and str(nm).lower() in owned_lower:
base_w *= owned_mult
weighted_pool.append((nm, base_w))
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen:
row = pool[pool['name'] == nm].iloc[0]
@ -541,6 +583,10 @@ class SpellAdditionMixin:
ascending=[False, True],
na_position='last',
)
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
fill = multi_pool['name'].tolist()[:need]
for nm in fill:
row = multi_pool[multi_pool['name'] == nm].iloc[0]
@ -598,6 +644,10 @@ class SpellAdditionMixin:
subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last')
if getattr(self, 'prefer_owned', False):
owned_set = getattr(self, 'owned_card_names', None)
if owned_set:
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
row = subset.head(1)
if row.empty:
break

View file

@ -90,10 +90,12 @@ from .routes import build as build_routes # noqa: E402
from .routes import configs as config_routes # noqa: E402
from .routes import decks as decks_routes # noqa: E402
from .routes import setup as setup_routes # noqa: E402
from .routes import owned as owned_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
app.include_router(setup_routes.router)
app.include_router(owned_routes.router)
# Lightweight file download endpoint for exports
@app.get("/files")

View file

@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse
from ..app import templates
from deck_builder import builder_constants as bc
from ..services import orchestrator as orch
from ..services import owned_store
from ..services.tasks import get_session, new_sid
router = APIRouter(prefix="/build")
@ -286,10 +287,44 @@ async def build_step4_get(request: Request) -> HTMLResponse:
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
},
)
@router.post("/toggle-owned-review", response_class=HTMLResponse)
async def build_toggle_owned_review(
request: Request,
use_owned_only: str | None = Form(None),
prefer_owned: str | None = Form(None),
) -> HTMLResponse:
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
sess["use_owned_only"] = only_val
sess["prefer_owned"] = pref_val
# Do not touch build_ctx here; user hasn't started the build yet from review
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
resp = templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
"labels": labels,
"values": values,
"commander": commander,
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step5", response_class=HTMLResponse)
async def build_step5_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
@ -302,6 +337,9 @@ async def build_step5_get(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": None,
"stage_label": None,
"log": None,
@ -331,14 +369,27 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
# Owned-only integration for staged builds
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
)
res = orch.run_stage(sess["build_ctx"], rerun=False)
show_skipped = True if (request.query_params.get('show_skipped') == '1' or (await request.form().get('show_skipped', None) == '1') if hasattr(request, 'form') else False) else False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else show_skipped
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Build complete" if res.get("done") else "Stage complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
@ -357,6 +408,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
@ -367,6 +421,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -390,14 +445,26 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"),
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
)
res = orch.run_stage(sess["build_ctx"], rerun=True)
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped)
status = "Stage rerun complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
@ -415,6 +482,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
@ -425,6 +495,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -454,14 +525,26 @@ async def build_step5_start(request: Request) -> HTMLResponse:
except Exception:
safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults()
use_owned = bool(sess.get("use_owned_only"))
prefer = bool(sess.get("prefer_owned"))
owned_names = owned_store.get_names() if (use_owned or prefer) else None
sess["build_ctx"] = orch.start_build_ctx(
commander=commander,
tags=sess.get("tags", []),
bracket=safe_bracket,
ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
use_owned_only=use_owned,
prefer_owned=prefer,
owned_names=owned_names,
)
res = orch.run_stage(sess["build_ctx"], rerun=False)
show_skipped = False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else False
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
status = "Stage complete" if not res.get("done") else "Build complete"
stage_label = res.get("label")
log = res.get("log_delta", "")
@ -479,6 +562,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"prefer_owned": bool(sess.get("prefer_owned")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": status,
"stage_label": stage_label,
"log": log,
@ -489,6 +575,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"txt_path": txt_path,
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -503,6 +590,8 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []),
"bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()),
"owned_only": bool(sess.get("use_owned_only")),
"owned_set": {n.lower() for n in owned_store.get_names()},
"status": "Error",
"stage_label": None,
"log": f"Failed to start build: {e}",

View file

@ -6,6 +6,7 @@ from pathlib import Path
import os
import json
from ..app import templates
from ..services import owned_store
from ..services import orchestrator as orch
from deck_builder import builder_constants as bc
@ -92,7 +93,7 @@ async def configs_view(request: Request, name: str) -> HTMLResponse:
@router.post("/run", response_class=HTMLResponse)
async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
async def configs_run(request: Request, name: str = Form(...), use_owned_only: str | None = Form(None)) -> HTMLResponse:
base = _config_dir()
p = (base / name).resolve()
try:
@ -125,8 +126,33 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
except Exception:
tag_mode = "AND"
# Optional owned-only for headless runs via JSON flag or form override
owned_flag = False
try:
uo = cfg.get("use_owned_only")
if isinstance(uo, bool):
owned_flag = uo
elif isinstance(uo, str):
owned_flag = uo.strip().lower() in ("1","true","yes","on")
except Exception:
owned_flag = False
# Form override takes precedence if provided
if use_owned_only is not None:
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
owned_names = owned_store.get_names() if owned_flag else None
# Run build headlessly with orchestrator
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals, tag_mode=tag_mode)
res = orch.run_build(
commander=commander,
tags=tags,
bracket=bracket,
ideals=ideals,
tag_mode=tag_mode,
use_owned_only=owned_flag,
owned_names=owned_names,
)
if not res.get("ok"):
return templates.TemplateResponse(
"configs/run_result.html",
@ -138,6 +164,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
},
)
return templates.TemplateResponse(
@ -152,6 +180,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"cfg_name": p.name,
"commander": commander,
"tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
"game_changers": bc.GAME_CHANGERS,
},
)

View file

@ -8,6 +8,7 @@ import os
from typing import Dict, List, Tuple
from ..app import templates
from ..services import owned_store
from deck_builder import builder_constants as bc
@ -263,5 +264,6 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
"commander": commander_name,
"tags": tags,
"game_changers": bc.GAME_CHANGERS,
"owned_set": {n.lower() for n in owned_store.get_names()},
}
return templates.TemplateResponse("decks/view.html", ctx)

179
code/web/routes/owned.py Normal file
View file

@ -0,0 +1,179 @@
from __future__ import annotations
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import HTMLResponse, Response
from ..app import templates
from ..services import owned_store as store
# Session helpers are not required for owned routes
router = APIRouter(prefix="/owned")
def _canon_color_code(seq: list[str] | tuple[str, ...]) -> str:
"""Canonicalize a color identity sequence to a stable code (WUBRG order, no 'C' unless only color)."""
order = {'W':0,'U':1,'B':2,'R':3,'G':4,'C':5}
uniq: list[str] = []
seen: set[str] = set()
for c in (seq or []):
uc = (c or '').upper()
if uc in order and uc not in seen:
seen.add(uc)
uniq.append(uc)
uniq.sort(key=lambda x: order[x])
code = ''.join([c for c in uniq if c != 'C'])
return code or ('C' if 'C' in seen else '')
def _color_combo_label(code: str) -> str:
"""Return friendly label for a 2/3/4-color combo code; empty if unknown.
Uses standard names: Guilds, Shards/Wedges, and Nephilim-style for 4-color.
"""
two_map = {
'WU':'Azorius','UB':'Dimir','BR':'Rakdos','RG':'Gruul','WG':'Selesnya',
'WB':'Orzhov','UR':'Izzet','BG':'Golgari','WR':'Boros','UG':'Simic',
}
three_map = {
'WUB':'Esper','UBR':'Grixis','BRG':'Jund','WRG':'Naya','WUG':'Bant',
'WBR':'Mardu','WUR':'Jeskai','UBG':'Sultai','URG':'Temur','WBG':'Abzan',
}
four_map = {
'WUBR': 'Yore-Tiller', # no G
'WUBG': 'Witch-Maw', # no R
'WURG': 'Ink-Treader', # no B
'WBRG': 'Dune-Brood', # no U
'UBRG': 'Glint-Eye', # no W
}
if len(code) == 2:
return two_map.get(code, '')
if len(code) == 3:
return three_map.get(code, '')
if len(code) == 4:
return four_map.get(code, '')
return ''
def _build_color_combos(names_sorted: list[str], colors_by_name: dict[str, list[str]]) -> list[tuple[str, str]]:
"""Compute present color combos and return [(code, display)], ordered by length then code."""
combo_set: set[str] = set()
for n in names_sorted:
cols = (colors_by_name.get(n) or [])
code = _canon_color_code(cols)
if len(code) >= 2:
combo_set.add(code)
combos: list[tuple[str, str]] = []
for code in sorted(combo_set, key=lambda s: (len(s), s)):
label = _color_combo_label(code)
display = f"{label} ({code})" if label else code
combos.append((code, display))
return combos
def _build_owned_context(request: Request, notice: str | None = None, error: str | None = None) -> dict:
"""Build the template context for the Owned Library page, including
enrichment from csv_files and filter option lists.
"""
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
# Default sort by name (case-insensitive)
names_sorted = sorted(names, key=lambda s: s.lower())
# Build filter option sets
all_types = sorted({type_by_name.get(n) for n in names_sorted if type_by_name.get(n)}, key=lambda s: s.lower())
all_tags = sorted({t for n in names_sorted for t in (tags_by_name.get(n) or [])}, key=lambda s: s.lower())
all_colors = ['W','U','B','R','G','C']
# Build color combos displayed in the filter
combos = _build_color_combos(names_sorted, colors_by_name)
ctx = {
"request": request,
"names": names_sorted,
"count": len(names_sorted),
"tags_by_name": tags_by_name,
"type_by_name": type_by_name,
"colors_by_name": colors_by_name,
"all_types": all_types,
"all_tags": all_tags,
"all_colors": all_colors,
"color_combos": combos,
}
if notice:
ctx["notice"] = notice
if error:
ctx["error"] = error
return ctx
@router.get("/", response_class=HTMLResponse)
async def owned_index(request: Request) -> HTMLResponse:
ctx = _build_owned_context(request)
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/upload", response_class=HTMLResponse)
async def owned_upload(request: Request, file: UploadFile = File(...)) -> HTMLResponse:
try:
content = await file.read()
fname = (file.filename or "").lower()
if fname.endswith(".csv"):
names = store.parse_csv_bytes(content)
else:
names = store.parse_txt_bytes(content)
# Add and enrich immediately so the page doesn't need to parse CSVs
added, total = store.add_and_enrich(names)
notice = f"Added {added} new name(s). Total: {total}."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Upload failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/clear", response_class=HTMLResponse)
async def owned_clear(request: Request) -> HTMLResponse:
try:
store.clear()
ctx = _build_owned_context(request, notice="Library cleared.")
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Clear failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
@router.get("/export")
async def owned_export_txt() -> Response:
"""Download the owned library as a simple TXT (one name per line)."""
names, _, _, _ = store.get_enriched()
# Stable case-insensitive sort
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
return Response(
content=lines + ("\n" if lines else ""),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.txt"},
)
@router.get("/export.csv")
async def owned_export_csv() -> Response:
"""Download the owned library with enrichment as CSV (Name,Type,Colors,Tags)."""
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
# Prepare CSV content
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["Name", "Type", "Colors", "Tags"])
for n in sorted((names or []), key=lambda s: s.lower()):
tline = type_by_name.get(n, "")
cols = ''.join(colors_by_name.get(n, []) or [])
tags = '|'.join(tags_by_name.get(n, []) or [])
writer.writerow([n, tline, cols, tags])
content = buf.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
)

View file

@ -631,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], tag_mode: str | None = None) -> Dict[str, Any]:
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[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] }
@ -686,6 +686,27 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
except Exception:
pass
# Owned/Prefer-owned integration (optional for headless runs)
try:
if use_owned_only:
b.use_owned_only = True # type: ignore[attr-defined]
# Prefer explicit owned_names list if provided; else let builder discover from files
if owned_names:
try:
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
except Exception:
b.owned_card_names = set() # type: ignore[attr-defined]
# Soft preference flag does not filter; only biases selection order
if prefer_owned:
try:
b.prefer_owned = True # type: ignore[attr-defined]
if owned_names and not getattr(b, 'owned_card_names', None):
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
except Exception:
pass
except Exception:
pass
# Load data and run phases
try:
b.determine_color_identity()
@ -784,6 +805,14 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
if callable(fn):
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"})
# Creatures split into theme sub-stages for web confirm
# AND-mode pre-pass: add cards that match ALL selected themes first
try:
combine_mode = getattr(b, 'tag_mode', 'AND')
except Exception:
combine_mode = 'AND'
has_two_tags = bool(getattr(b, 'primary_tag', None) and getattr(b, 'secondary_tag', None))
if combine_mode == 'AND' and has_two_tags and hasattr(b, 'add_creatures_all_theme_phase'):
stages.append({"key": "creatures_all_theme", "label": "Creatures: All-Theme", "runner_name": "add_creatures_all_theme_phase"})
if getattr(b, 'primary_tag', None) and hasattr(b, 'add_creatures_primary_phase'):
stages.append({"key": "creatures_primary", "label": "Creatures: Primary", "runner_name": "add_creatures_primary_phase"})
if getattr(b, 'secondary_tag', None) and hasattr(b, 'add_creatures_secondary_phase'):
@ -822,7 +851,17 @@ 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], tag_mode: str | None = None) -> Dict[str, Any]:
def start_build_ctx(
commander: str,
tags: List[str],
bracket: int,
ideals: Dict[str, int],
tag_mode: str | None = None,
*,
use_owned_only: bool | None = None,
prefer_owned: bool | None = None,
owned_names: List[str] | None = None,
) -> Dict[str, Any]:
logs: List[str] = []
def out(msg: str) -> None:
@ -865,6 +904,25 @@ def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[
except Exception:
pass
# Owned-only / prefer-owned (if requested)
try:
if use_owned_only:
b.use_owned_only = True # type: ignore[attr-defined]
if owned_names:
try:
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
except Exception:
b.owned_card_names = set() # type: ignore[attr-defined]
if prefer_owned:
try:
b.prefer_owned = True # type: ignore[attr-defined]
if owned_names and not getattr(b, 'owned_card_names', None):
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
except Exception:
pass
except Exception:
pass
# Data load
b.determine_color_identity()
b.setup_dataframes()
@ -926,7 +984,7 @@ def _restore_builder(b: DeckBuilder, snap: Dict[str, Any]) -> None:
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
def run_stage(ctx: Dict[str, Any], rerun: bool = False) -> Dict[str, Any]:
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False) -> Dict[str, Any]:
b: DeckBuilder = ctx["builder"]
stages: List[Dict[str, Any]] = ctx["stages"]
logs: List[str] = ctx["logs"]
@ -1071,7 +1129,22 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False) -> Dict[str, Any]:
"total": len(stages),
}
# No cards added: skip showing this stage and advance to next
# No cards added: either skip or surface as a 'skipped' stage
if show_skipped:
ctx["snapshot"] = snap_before
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
return {
"done": False,
"label": label,
"log_delta": delta_log,
"added_cards": [],
"skipped": True,
"idx": i + 1,
"total": len(stages),
}
# No cards added and not showing skipped: advance to next
i += 1
# Continue loop to auto-advance

View file

@ -0,0 +1,424 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Tuple, Dict
import json
import os
def _owned_dir() -> Path:
"""Resolve the owned cards directory (shared with CLI) for persistence.
Precedence:
- OWNED_CARDS_DIR env var
- CARD_LIBRARY_DIR env var (back-compat)
- ./owned_cards (if exists)
- ./card_library (if exists)
- default ./owned_cards
"""
env_dir = os.getenv("OWNED_CARDS_DIR") or os.getenv("CARD_LIBRARY_DIR")
if env_dir:
return Path(env_dir).resolve()
for name in ("owned_cards", "card_library"):
p = Path(name)
if p.exists() and p.is_dir():
return p.resolve()
return Path("owned_cards").resolve()
def _db_path() -> Path:
d = _owned_dir()
try:
d.mkdir(parents=True, exist_ok=True)
except Exception:
pass
return (d / ".web_owned_db.json").resolve()
def _load_raw() -> dict:
p = _db_path()
if p.exists():
try:
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
# Back-compat defaults
if "names" not in data or not isinstance(data.get("names"), list):
data["names"] = []
if "meta" not in data or not isinstance(data.get("meta"), dict):
data["meta"] = {}
return data
except Exception:
return {"names": [], "meta": {}}
return {"names": [], "meta": {}}
def _save_raw(data: dict) -> None:
p = _db_path()
try:
with p.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
pass
def get_names() -> List[str]:
data = _load_raw()
names = data.get("names") or []
if not isinstance(names, list):
return []
# Normalize and dedupe while preserving stable ordering
seen = set()
out: List[str] = []
for n in names:
s = str(n).strip()
if not s:
continue
key = s.lower()
if key in seen:
continue
seen.add(key)
out.append(s)
return out
def clear() -> None:
_save_raw({"names": [], "meta": {}})
def add_names(names: Iterable[str]) -> Tuple[int, int]:
"""Add a batch of names; returns (added_count, total_after)."""
data = _load_raw()
cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
cur_set = {n.lower() for n in cur}
added = 0
for raw in names:
try:
s = str(raw).strip()
if not s:
continue
key = s.lower()
if key in cur_set:
continue
cur.append(s)
cur_set.add(key)
added += 1
except Exception:
continue
data["names"] = cur
if "meta" not in data or not isinstance(data.get("meta"), dict):
data["meta"] = {}
_save_raw(data)
return added, len(cur)
def _enrich_from_csvs(target_names: Iterable[str]) -> Dict[str, Dict[str, object]]:
"""Return metadata for target names by scanning csv_files/*_cards.csv.
Output: { Name: { 'tags': [..], 'type': str|None, 'colors': [..] } }
"""
from pathlib import Path
import json as _json
import csv as _csv
base = Path('csv_files')
meta: Dict[str, Dict[str, object]] = {}
want = {str(n).strip().lower() for n in target_names if str(n).strip()}
if not (base.exists() and want):
return meta
csv_files = [p for p in base.glob('*_cards.csv') if p.name.lower() not in ('cards.csv', 'commander_cards.csv')]
def _norm(s: str) -> str: return str(s or '').strip().lower()
for path in csv_files:
try:
with path.open('r', encoding='utf-8', errors='ignore') as f:
reader = _csv.DictReader(f)
headers = [h for h in (reader.fieldnames or [])]
name_key = None
tags_key = None
type_key = None
colors_key = None
for h in headers:
hn = _norm(h)
if hn in ('name', 'card', 'cardname', 'card_name'):
name_key = h
if hn in ('tags', 'theme_tags', 'themetags', 'themetagsjson') or hn == 'themetags' or hn == 'themetagsjson':
tags_key = h
if hn in ('type', 'type_line', 'typeline'):
type_key = h
if hn in ('colors', 'coloridentity', 'color_identity', 'color'):
colors_key = h
if not tags_key:
for h in headers:
if h.strip() in ('ThemeTags', 'themeTags'):
tags_key = h
break
if not colors_key:
for h in headers:
if h.strip() in ('ColorIdentity', 'colorIdentity'):
colors_key = h
break
if not name_key:
continue
for row in reader:
try:
nm = str(row.get(name_key) or '').strip()
if not nm:
continue
low = nm.lower()
if low not in want:
continue
entry = meta.setdefault(nm, {"tags": [], "type": None, "colors": []})
# Tags
if tags_key:
raw = (row.get(tags_key) or '').strip()
vals: List[str] = []
if raw:
if raw.startswith('['):
try:
arr = _json.loads(raw)
if isinstance(arr, list):
vals = [str(x).strip() for x in arr if str(x).strip()]
except Exception:
vals = []
if not vals:
parts = [p.strip() for p in raw.replace(';', ',').split(',')]
vals = [p for p in parts if p]
if vals:
existing = entry.get('tags') or []
seen = {str(t).lower() for t in existing}
for t in vals:
if str(t).lower() not in seen:
existing.append(str(t))
seen.add(str(t).lower())
entry['tags'] = existing
# Type
if type_key and not entry.get('type'):
t_raw = str(row.get(type_key) or '').strip()
if t_raw:
tline = t_raw.split('')[0].strip() if '' in t_raw else t_raw
prim = None
for cand in ['Creature','Instant','Sorcery','Artifact','Enchantment','Planeswalker','Land','Battle']:
if cand.lower() in tline.lower():
prim = cand
break
if not prim and tline:
prim = tline.split()[0]
if prim:
entry['type'] = prim
# Colors
if colors_key and not entry.get('colors'):
c_raw = str(row.get(colors_key) or '').strip()
cols: List[str] = []
if c_raw:
if c_raw.startswith('['):
try:
arr = _json.loads(c_raw)
if isinstance(arr, list):
cols = [str(x).strip().upper() for x in arr if str(x).strip()]
except Exception:
cols = []
if not cols:
parts = [p.strip().upper() for p in c_raw.replace(';', ',').replace('[','').replace(']','').replace("'",'').split(',') if p.strip()]
if parts:
cols = parts
if not cols:
for ch in c_raw:
if ch.upper() in ('W','U','B','R','G','C'):
cols.append(ch.upper())
if cols:
seen_c = set()
uniq = []
for c in cols:
if c not in seen_c:
uniq.append(c)
seen_c.add(c)
entry['colors'] = uniq
except Exception:
continue
except Exception:
continue
return meta
def add_and_enrich(names: Iterable[str]) -> Tuple[int, int]:
"""Add names and enrich their metadata from CSVs in one pass.
Returns (added_count, total_after).
"""
data = _load_raw()
current_names = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
cur_set = {n.lower() for n in current_names}
new_names: List[str] = []
for raw in names:
try:
s = str(raw).strip()
if not s:
continue
key = s.lower()
if key in cur_set:
continue
current_names.append(s)
cur_set.add(key)
new_names.append(s)
except Exception:
continue
# Enrich
meta = data.get("meta") or {}
if new_names:
enriched = _enrich_from_csvs(new_names)
for nm, info in enriched.items():
meta[nm] = info
data["names"] = current_names
data["meta"] = meta
_save_raw(data)
return len(new_names), len(current_names)
def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dict[str, List[str]]]:
"""Return names and metadata dicts (tags_by_name, type_by_name, colors_by_name).
If metadata missing, returns empty for those entries.
"""
data = _load_raw()
names = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
tags_by_name: Dict[str, List[str]] = {}
type_by_name: Dict[str, str] = {}
colors_by_name: Dict[str, List[str]] = {}
for n in names:
info = meta.get(n) or {}
tags = info.get('tags') or []
typ = info.get('type') or None
cols = info.get('colors') or []
if tags:
tags_by_name[n] = [str(x) for x in tags if str(x)]
if typ:
type_by_name[n] = str(typ)
if cols:
colors_by_name[n] = [str(x).upper() for x in cols if str(x)]
return names, tags_by_name, type_by_name, colors_by_name
def parse_txt_bytes(content: bytes) -> List[str]:
out: List[str] = []
try:
text = content.decode("utf-8", errors="ignore")
except Exception:
text = content.decode(errors="ignore")
for line in text.splitlines():
s = (line or "").strip()
if not s or s.startswith("#") or s.startswith("//"):
continue
parts = s.split()
if len(parts) >= 2 and (parts[0].isdigit() or (parts[0].lower().endswith('x') and parts[0][:-1].isdigit())):
s = ' '.join(parts[1:])
if s:
out.append(s)
return out
def parse_csv_bytes(content: bytes) -> List[str]:
names: List[str] = []
try:
import csv
from io import StringIO
import re
text = content.decode("utf-8", errors="ignore")
f = StringIO(text)
try:
reader = csv.DictReader(f)
headers = [h for h in (reader.fieldnames or []) if isinstance(h, str)]
# Normalize headers: lowercase and remove non-letters (spaces, underscores, dashes)
def norm(h: str) -> str:
return re.sub(r"[^a-z]", "", (h or "").lower())
# Map normalized -> original header
norm_map = {norm(h): h for h in headers}
# Preferred keys (exact normalized match)
preferred = ["name", "cardname"]
key = None
for k in preferred:
if k in norm_map:
key = norm_map[k]
break
# Fallback: allow plain 'card' but avoid 'cardnumber', 'cardid', etc.
if key is None:
if "card" in norm_map and all(x not in norm_map for x in ("cardnumber", "cardno", "cardid", "collectornumber", "collector", "multiverseid")):
key = norm_map["card"]
# Another fallback: try common variants if not strictly normalized
if key is None:
for h in headers:
h_clean = (h or "").strip().lower()
if h_clean in ("name", "card name", "card_name", "cardname"):
key = h
break
if key:
for row in reader:
val = str(row.get(key) or '').strip()
if not val:
continue
names.append(val)
else:
f.seek(0)
reader2 = csv.reader(f)
rows = list(reader2)
if not rows:
pass
else:
# Try to detect a likely name column from the first row
header = rows[0]
name_col = 0
if header:
# Look for header cells resembling name
for idx, cell in enumerate(header):
c = str(cell or '').strip()
cn = norm(c)
if cn in ("name", "cardname"):
name_col = idx
break
else:
# As a fallback, if any cell lower is exactly 'card', take it
for idx, cell in enumerate(header):
c = str(cell or '').strip().lower()
if c == 'card':
name_col = idx
break
# Iterate rows, skip header-like first row when it matches
for i, row in enumerate(rows):
if not row:
continue
if i == 0:
first = str(row[name_col] if len(row) > name_col else '').strip()
fn = norm(first)
if fn in ("name", "cardname") or first.lower() in ("name", "card name", "card", "card_name"):
continue # skip header
val = str(row[name_col] if len(row) > name_col else '').strip()
if not val:
continue
# Skip rows that look like header or counts
low = val.lower()
if low in ("name", "card name", "card", "card_name"):
continue
names.append(val)
except Exception:
# Fallback: one name per line
f.seek(0)
for line in f:
s = (line or '').strip()
if s and s.lower() not in ('name', 'card', 'card name'):
names.append(s)
except Exception:
pass
# Normalize, dedupe while preserving order
seen = set()
out: List[str] = []
for n in names:
s = str(n).strip()
if not s:
continue
k = s.lower()
if k in seen:
continue
seen.add(k)
out.append(s)
return out

View file

@ -61,6 +61,10 @@ body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text);
/* Buttons, inputs */
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
button:hover{ filter:brightness(1.05); }
/* Anchor-style buttons */
.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
.btn:hover{ filter:brightness(1.05); text-decoration:none; }
.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
select,input[type="text"],input[type="number"]{ background:#0f1115; color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
@ -87,6 +91,7 @@ small, .muted{ color: var(--muted); }
}
.card-tile{
width:170px;
position: relative;
background:#0f1115;
border:1px solid var(--border);
border-radius:6px;
@ -98,6 +103,25 @@ small, .muted{ color: var(--muted); }
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
/* Shared ownership badge for card tiles and stacked images */
.owned-badge{
position:absolute;
top:6px;
left:6px;
background:rgba(17,24,39,.9);
color:#e5e7eb;
border:1px solid var(--border);
border-radius:12px;
font-size:12px;
line-height:18px;
height:18px;
min-width:18px;
padding:0 6px;
text-align:center;
pointer-events:none;
z-index:2;
}
/* Step 1 candidate grid (200px-wide scaled images) */
.candidate-grid{
display:grid;

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<link rel="stylesheet" href="/static/styles.css?v=20250826-1" />
<link rel="stylesheet" href="/static/styles.css?v=20250826-3" />
</head>
<body>
<header class="top-banner">
@ -30,6 +30,7 @@
<a href="/build">Build</a>
<a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a>
<a href="/decks">Finished Decks</a>
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>

View file

@ -14,6 +14,17 @@
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
{% endfor %}
</ul>
<form hx-post="/build/toggle-owned-review" hx-target="#wizard" hx-swap="innerHTML" style="margin:.5rem 0; display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
<label style="display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" name="use_owned_only" value="1" {% if owned_only %}checked{% endif %} onchange="this.form.requestSubmit();" />
Use only owned cards
</label>
<label style="display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
Prefer owned cards (allow unowned fallback)
</label>
<a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a>
</form>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Build Deck</button>

View file

@ -27,6 +27,14 @@
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
<div style="display:flex;align-items:center;gap:1rem;">
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
</div>
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a></span>
</div>
<p>Bracket: {{ bracket }}</p>
{% if i and n %}
@ -41,20 +49,36 @@
<!-- Controls moved back above the cards as requested -->
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit">Start Build</button>
</form>
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
</form>
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
</form>
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
<input type="checkbox" name="__toggle_show_skipped" {% if show_skipped %}checked{% endif %}
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
Show skipped stages
</label>
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
</div>
{% if added_cards %}
{% if added_cards is not none %}
<h4 style="margin-top:1rem;">Cards added this stage</h4>
{% if skipped and (not added_cards or added_cards|length == 0) %}
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
{% endif %}
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;"></span> Owned</span>
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;"></span> Not owned</span>
</div>
{% if stage_label and stage_label.startswith('Creatures') %}
{% set groups = added_cards|groupby('sub_role') %}
{% for g in groups %}
@ -67,10 +91,12 @@
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
<div class="card-grid">
{% for c in g.list %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
</a>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
</div>
@ -80,10 +106,12 @@
{% else %}
<div class="card-grid">
{% for c in added_cards %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
</a>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
</div>

View file

@ -3,7 +3,7 @@
<h2>Build from JSON: {{ cfg_name }}</h2>
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
{% if commander %}
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}</div>
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}{% if use_owned_only %} · Owned-only{% endif %}</div>
{% endif %}
<div class="two-col two-col-left-rail">

View file

@ -15,8 +15,12 @@
<summary>Ideal Counts</summary>
<pre style="background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px;">{{ data.ideal_counts | tojson(indent=2) }}</pre>
</details>
<form method="post" action="/configs/run" style="margin-top:1rem;">
<form method="post" action="/configs/run" style="margin-top:1rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
<input type="hidden" name="name" value="{{ name }}" />
<label style="display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" name="use_owned_only" value="1" />
Use only owned cards
</label>
<button type="submit">Run Headless</button>
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
</form>

View file

@ -16,18 +16,20 @@
<option value="name-asc">Commander AZ</option>
<option value="name-desc">Commander ZA</option>
</select>
<select id="deck-theme" aria-label="Theme">
<option value="">All Themes</option>
</select>
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
<input type="checkbox" id="deck-txt-only" /> TXT only
</label>
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
<button id="deck-reset-all" type="button" title="Reset filter, sort, and tags">Reset all</button>
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
<span id="deck-count" class="muted" aria-live="polite"></span>
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
</div>
<div id="tag-label" class="muted" style="font-size:12px; margin:.15rem 0 .25rem 0;">Theme filters</div>
<div id="tag-chips" aria-labelledby="tag-label" style="display:flex; gap:.25rem; flex-wrap:wrap; margin:.25rem 0 .75rem 0;"></div>
{% if items %}
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
@ -82,7 +84,7 @@
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
<li><kbd>R</kbd> resets all filters, sort, and tags</li>
<li><kbd>R</kbd> resets all filters, sort, and theme</li>
<li>Use “TXT only” to show only decks that have a TXT export</li>
<li>Share copies a link with your current filters</li>
</ul>
@ -97,9 +99,9 @@
(function(){
var input = document.getElementById('deck-filter');
var sortSel = document.getElementById('deck-sort');
var themeSel = document.getElementById('deck-theme');
var clearBtn = document.getElementById('deck-clear');
var list = document.getElementById('deck-list');
var chips = document.getElementById('tag-chips');
var countEl = document.getElementById('deck-count');
var shareBtn = document.getElementById('deck-share');
var resetAllBtn = document.getElementById('deck-reset-all');
@ -112,15 +114,30 @@
var txtOnlyCb = document.getElementById('deck-txt-only');
if (!list) return;
// Build tag chips from data-tags-pipe
var tagSet = new Set();
// Panels and themes discovery from data-tags-pipe
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
panels.forEach(function(p){
var raw = p.dataset.tagsPipe || '';
raw.split('|').forEach(function(t){ if (t && t.trim()) tagSet.add(t.trim()); });
var themeSet = new Set();
panels.forEach(function(p){
var raw = p.dataset.tagsPipe || '';
raw.split('|').forEach(function(t){ t = (t||'').trim(); if (t) themeSet.add(t); });
});
var activeTags = new Set();
// Populate theme dropdown
if (themeSel) {
// Preserve current selection if any
var prev = themeSel.value || '';
// Reset to default option
themeSel.innerHTML = '<option value="">All Themes</option>';
Array.from(themeSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
var opt = document.createElement('option');
opt.value = t; opt.textContent = t; themeSel.appendChild(opt);
});
if (prev) {
// Re-apply previous selection if it exists
var has = Array.prototype.some.call(themeSel.options, function(o){ return o.value === prev; });
if (has) themeSel.value = prev;
}
}
// URL hash <-> state sync helpers
function parseHash(){
@ -130,22 +147,24 @@
var qp = new URLSearchParams(h);
var q = qp.get('q') || '';
var sort = qp.get('sort') || '';
var tag = qp.get('tag') || '';
var tagsStr = qp.get('tags') || '';
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
if (!tag && tags.length) { tag = tags[0]; }
var txt = qp.get('txt');
var txtOnly = (txt === '1' || txt === 'true');
return { q: q, sort: sort, tags: tags, txt: txtOnly };
return { q: q, sort: sort, tag: tag, txt: txtOnly };
} catch(_) { return null; }
}
function updateHashFromState(){
try {
var q = (input && input.value) ? input.value.trim() : '';
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
var tags = Array.from(activeTags);
var tag = (themeSel && themeSel.value) ? themeSel.value : '';
var qp = new URLSearchParams();
if (q) qp.set('q', q);
if (sort && sort !== 'newest') qp.set('sort', sort);
if (tags.length) qp.set('tags', tags.map(function(s){ return encodeURIComponent(s); }).join(','));
if (tag) qp.set('tag', tag);
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
var newHash = qp.toString();
var base = location.pathname + location.search;
@ -161,45 +180,16 @@
var changed = false;
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
if (Array.isArray(s.tags)) { activeTags = new Set(s.tags); changed = true; }
if (typeof s.tag === 'string' && themeSel) {
// If the tag isn't present in options, add it for back-compat
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === s.tag; });
if (s.tag && !exists) { var opt = document.createElement('option'); opt.value = s.tag; opt.textContent = s.tag; themeSel.appendChild(opt); }
themeSel.value = s.tag; changed = true;
}
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
renderChips();
applyAll();
return changed;
}
function renderChips(){
if (!chips) return;
chips.innerHTML = '';
Array.from(tagSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip chip-filter' + (activeTags.has(t) ? ' active' : '');
btn.textContent = t;
btn.setAttribute('aria-pressed', activeTags.has(t) ? 'true' : 'false');
btn.addEventListener('click', function(){
if (activeTags.has(t)) activeTags.delete(t); else activeTags.add(t);
renderChips();
applyAll();
});
chips.appendChild(btn);
});
// Reset tags control appears only when any tags are active
if (activeTags.size > 0) {
var reset = document.createElement('button');
reset.type = 'button';
reset.id = 'reset-tags';
reset.className = 'chip';
reset.textContent = 'Reset tags';
reset.title = 'Clear selected theme tags';
reset.addEventListener('click', function(){
activeTags.clear();
renderChips();
applyAll();
if (liveEl) liveEl.textContent = 'Theme tags cleared';
});
chips.appendChild(reset);
}
}
function updateCount(){
if (!countEl) return;
@ -218,13 +208,13 @@
function applyFilter(){
var q = (input && input.value || '').toLowerCase();
var selTag = (themeSel && themeSel.value) ? themeSel.value : '';
panels.forEach(function(row){
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
var textMatch = hay.indexOf(q) >= 0;
var tagsPipe = row.dataset.tagsPipe || '';
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
var tagMatch = true;
activeTags.forEach(function(t){ if (tags.indexOf(t) === -1) tagMatch = false; });
var tagMatch = selTag ? (tags.indexOf(selTag) !== -1) : true;
var txtOk = true;
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
@ -312,7 +302,7 @@
try {
if (input) localStorage.setItem('decks-filter', input.value || '');
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
localStorage.setItem('decks-tags', JSON.stringify(Array.from(activeTags)));
if (themeSel) localStorage.setItem('decks-theme', themeSel.value || '');
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
} catch(_){ }
// Update URL hash for shareable state
@ -332,13 +322,13 @@
var debouncedApply = debounce(applyAll, 150);
if (input) input.addEventListener('input', debouncedApply);
if (sortSel) sortSel.addEventListener('change', applyAll);
if (themeSel) themeSel.addEventListener('change', applyAll);
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
if (clearBtn) clearBtn.addEventListener('click', function(){
if (input) input.value = '';
activeTags.clear();
if (themeSel) themeSel.value = '';
if (sortSel) sortSel.value = 'newest';
if (txtOnlyCb) txtOnlyCb.checked = false;
renderChips();
applyAll();
});
@ -348,19 +338,18 @@
if (input) input.value = '';
if (sortSel) sortSel.value = 'newest';
if (txtOnlyCb) txtOnlyCb.checked = false;
activeTags.clear();
renderChips();
if (themeSel) themeSel.value = '';
// Clear persistence
localStorage.removeItem('decks-filter');
localStorage.removeItem('decks-sort');
localStorage.removeItem('decks-tags');
localStorage.removeItem('decks-theme');
localStorage.removeItem('decks-txt');
// Clear URL hash
var base = location.pathname + location.search;
history.replaceState(null, '', base);
} catch(_){ }
applyAll();
if (liveEl) liveEl.textContent = 'Filters, sort, and tags reset';
if (liveEl) liveEl.textContent = 'Filters, sort, and theme reset';
});
if (shareBtn) shareBtn.addEventListener('click', function(){
@ -385,7 +374,6 @@
var hadHash = false;
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
if (hadHash) {
renderChips();
if (!applyStateFromHash()) { applyAll(); }
} else {
// Load persisted state
@ -394,11 +382,26 @@
if (input) input.value = savedFilter;
var savedSort = localStorage.getItem('decks-sort') || 'newest';
if (sortSel) sortSel.value = savedSort;
var savedTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
if (Array.isArray(savedTags)) savedTags.forEach(function(t){ activeTags.add(t); });
var savedTheme = localStorage.getItem('decks-theme') || '';
if (themeSel && savedTheme) {
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === savedTheme; });
if (!exists) { var opt = document.createElement('option'); opt.value = savedTheme; opt.textContent = savedTheme; themeSel.appendChild(opt); }
themeSel.value = savedTheme;
}
// Back-compat: if no savedTheme, try first of old saved tags
if (themeSel && !savedTheme) {
try {
var oldTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
if (Array.isArray(oldTags) && oldTags.length > 0) {
var ot = oldTags[0];
var ex2 = Array.prototype.some.call(themeSel.options, function(o){ return o.value === ot; });
if (!ex2) { var o2 = document.createElement('option'); o2.value = ot; o2.textContent = ot; themeSel.appendChild(o2); }
themeSel.value = ot;
}
} catch(_e){}
}
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
} catch(_){ }
renderChips();
applyAll();
}
@ -544,8 +547,6 @@
})();
</script>
<style>
.chip-filter { cursor:pointer; user-select:none; }
.chip-filter.active { background:#2563eb; color:#fff; border-color:#1d4ed8; }
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }

View file

@ -31,6 +31,29 @@
</div>
<div>
{% if summary %}
{% if owned_set %}
{% set ns = namespace(owned=0, total=0) %}
{% set tb = summary.type_breakdown %}
{% if tb and tb.cards %}
{% for t, clist in tb.cards.items() %}
{% for c in clist %}
{% set cnt = c.count if c.count else 1 %}
{% set ns.total = ns.total + cnt %}
{% if c.name and (c.name|lower in owned_set) %}
{% set ns.owned = ns.owned + cnt %}
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
{% set not_owned = (ns.total - ns.owned) %}
{% set pct = ( (ns.owned * 100.0 / (ns.total or 1)) | round(1) ) %}
<div class="panel" style="margin-bottom:.75rem;">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<div><strong>Ownership</strong></div>
<div class="muted">Owned: {{ ns.owned }} • Not owned: {{ not_owned }} • Total: {{ ns.total }} ({{ pct }}%)</div>
</div>
</div>
{% endif %}
{% include "partials/deck_summary.html" %}
{% else %}
<div class="muted">No summary available.</div>

View file

@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block content %}
<section>
<h3>Owned Cards Library</h3>
<p class="muted">Upload .txt or .csv lists. Well extract names and keep a de-duplicated library for the web UI.</p>
{% if error %}
<div class="error" style="margin:.5rem 0;">{{ error }}</div>
{% endif %}
{% if notice %}
<div class="notice" style="margin:.5rem 0;">{{ notice }}</div>
{% endif %}
<form action="/owned/upload" method="post" enctype="multipart/form-data" style="margin:.5rem 0 1rem 0;">
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload TXT/CSV</button>
<input id="upload-owned" type="file" name="file" accept=".txt,.csv" style="display:none" onchange="this.form.requestSubmit();" />
</form>
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.75rem;">
<form action="/owned/clear" method="post" style="display:inline;">
<button type="submit" {% if count == 0 %}disabled{% endif %}>Clear Library</button>
</form>
<a href="/owned/export" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export TXT</a>
<a href="/owned/export.csv" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export CSV</a>
<span class="muted">{{ count }} unique name{{ '' if count == 1 else 's' }} <span id="shown-count" style="margin-left:.25rem;">{% if count %}• {{ count }} shown{% endif %}</span></span>
</div>
{% if names and names|length %}
<div class="filters" style="display:flex; flex-wrap:wrap; gap:8px; margin:.25rem 0 .5rem 0;">
<select id="sort-by" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="name">Sort: A → Z</option>
<option value="type">Sort: Type</option>
<option value="color">Sort: Color</option>
<option value="tags">Sort: Tags</option>
</select>
<select id="filter-type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Types</option>
{% for t in all_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
<option value="">All Themes</option>
{% for t in all_tags %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Colors</option>
{% for c in all_colors %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
{% if color_combos and color_combos|length %}
<option value="" disabled>──────────</option>
{% for code, label in color_combos %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
{% endif %}
</select>
<input id="filter-text" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
<button type="button" id="clear-filters">Clear</button>
</div>
{% endif %}
{% if names and names|length %}
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;">
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
{% for n in names %}
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}">
<span data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
{% if cols and cols|length %}
<span class="mana-group" aria-hidden="true" style="margin-left:.35rem; display:inline-flex; gap:4px; vertical-align:middle;">
{% for c in cols %}
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
{% endfor %}
</span>
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="muted">No names yet. Upload a file to get started.</p>
{% endif %}
</section>
<script>
(function(){
var grid = document.getElementById('owned-grid');
if (!grid) return;
var box = document.getElementById('owned-box');
var fSort = document.getElementById('sort-by');
var fType = document.getElementById('filter-type');
var fTag = document.getElementById('filter-tag');
var fColor = document.getElementById('filter-color');
var fText = document.getElementById('filter-text');
var btnClear = document.getElementById('clear-filters');
var shownCount = document.getElementById('shown-count');
// Resize the container to fill the viewport height
function sizeBox(){
if (!box) return;
try {
var rect = box.getBoundingClientRect();
var margin = 16; // breathing room at bottom
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
var h = Math.max(240, Math.floor(vh - rect.top - margin));
box.style.height = h + 'px';
} catch(_){}
}
function debounce(fn, delay){ var t=null; return function(){ var a=arguments, c=this; if(t) clearTimeout(t); t=setTimeout(function(){ fn.apply(c,a); }, delay); }; }
var debouncedSize = debounce(sizeBox, 100);
sizeBox();
window.addEventListener('resize', debouncedSize);
function passType(li, val){ if (!val) return true; var t=(li.getAttribute('data-type')||'').toLowerCase(); return t.indexOf(val.toLowerCase())!==-1; }
function passTag(li, val){ if (!val) return true; var ts=(li.getAttribute('data-tags')||''); if (!ts) return false; var parts=ts.split('|'); return parts.some(function(x){return x.toLowerCase()===val.toLowerCase();}); }
function canonCode(raw){
var s = (raw||'').toUpperCase();
var order = ['W','U','B','R','G'];
var out = [];
for (var i=0;i<order.length;i++){
var ch = order[i];
if (s.indexOf(ch) !== -1) out.push(ch);
}
if (out.length === 0){
// Treat empty or explicit C as colorless
if (s.indexOf('C') !== -1 || s === '') return 'C';
return '';
}
return out.join('');
}
function passColor(li, val){
if (!val) return true;
var cs=(li.getAttribute('data-colors')||'');
var vcode = canonCode(val);
var ccode = canonCode(cs);
if (!vcode) return true; // if somehow invalid selection
return ccode === vcode;
}
function passText(li, val){ if (!val) return true; var txt=(li.textContent||'').toLowerCase(); return txt.indexOf(val.toLowerCase())!==-1; }
function updateShownCount(){
if (!shownCount) return;
var total = 0;
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display !== 'none') total++; });
shownCount.textContent = (total > 0 ? '• ' + total + ' shown' : '');
}
function apply(){
var vt = fType ? fType.value : '';
var vtag = fTag ? fTag.value : '';
var vc = fColor ? fColor.value : '';
var vx = fText ? fText.value.trim() : '';
Array.prototype.forEach.call(grid.children, function(li){
var ok = passType(li, vt) && passTag(li, vtag) && passColor(li, vc) && passText(li, vx);
li.style.display = ok ? '' : 'none';
});
resort();
updateShownCount();
}
function resort(){
if (!fSort) return;
var mode = fSort.value || 'name';
var lis = Array.prototype.slice.call(grid.children);
// Only consider visible items, but keep hidden in place after visible ones to avoid DOM thrash
var visible = lis.filter(function(li){ return li.style.display !== 'none'; });
var hidden = lis.filter(function(li){ return li.style.display === 'none'; });
function byName(a,b){ return (a.textContent||'').toLowerCase().localeCompare((b.textContent||'').toLowerCase()); }
function byType(a,b){ return (a.getAttribute('data-type')||'').toLowerCase().localeCompare((b.getAttribute('data-type')||'').toLowerCase()); }
function byColor(a,b){ return (a.getAttribute('data-colors')||'').localeCompare((b.getAttribute('data-colors')||'')); }
function byTags(a,b){ var ac=(a.getAttribute('data-tags')||'').split('|').filter(Boolean).length; var bc=(b.getAttribute('data-tags')||'').split('|').filter(Boolean).length; return ac-bc || byName(a,b); }
var cmp = byName;
if (mode === 'type') cmp = byType;
else if (mode === 'color') cmp = byColor;
else if (mode === 'tags') cmp = byTags;
visible.sort(cmp);
// Re-append in new order
var frag = document.createDocumentFragment();
visible.forEach(function(li){ frag.appendChild(li); });
hidden.forEach(function(li){ frag.appendChild(li); });
grid.appendChild(frag);
}
if (fSort) fSort.addEventListener('change', function(){ resort(); });
if (fType) fType.addEventListener('change', apply);
if (fTag) fTag.addEventListener('change', apply);
if (fColor) fColor.addEventListener('change', apply);
if (fText) fText.addEventListener('input', apply);
if (btnClear) btnClear.addEventListener('click', function(){ if(fSort)fSort.value='name'; if(fType)fType.value=''; if(fTag)fTag.value=''; if(fColor)fColor.value=''; if(fText)fText.value=''; apply(); });
// Initial state
updateShownCount();
})();
</script>
<style>
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.mana{ display:inline-block; width:14px; height:14px; border-radius:50%; box-sizing:border-box; }
.mana-W{ background:#f9fafb; border:1px solid #d1d5db; }
.mana-U{ background:#3b82f6; }
.mana-B{ background:#111827; }
.mana-R{ background:#ef4444; }
.mana-G{ background:#10b981; }
.mana-C{ background:#9ca3af; border:1px solid #6b7280; }
/* Subtle scrollbar styling for the owned list box */
#owned-box{ scrollbar-width: thin; scrollbar-color: rgba(148,163,184,.35) transparent; }
#owned-box:hover{ scrollbar-color: rgba(148,163,184,.6) transparent; }
#owned-box::-webkit-scrollbar{ width:8px; height:8px; }
#owned-box::-webkit-scrollbar-track{ background: transparent; }
#owned-box::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.35); border-radius:8px; }
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
</style>
{% endblock %}

View file

@ -1,9 +1,9 @@
<hr style="margin:1.25rem 0; border-color: var(--border);" />
<h4>Deck Summary</h4>
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0;">
Legend: <span class="game-changer" style="font-weight:600;">Game Changer</span>
<span class="muted" style="opacity:.8;">(green highlight)</span>
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
<span>Legend:</span>
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;"></span>Not owned</span>
</div>
<!-- Card Type Breakdown with names-only list and hover preview -->
@ -29,7 +29,9 @@
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
.owned-flag { font-size:.95rem; opacity:.9; }
</style>
<div id="typeview-list" class="typeview">
{% for t in tb.order %}
@ -42,9 +44,11 @@
{% for c in clist %}
<div class="{% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
{% set cnt = c.count if c.count else 1 %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<span data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
{{ cnt }}x {{ c.name }}
</span>
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
</div>
{% endfor %}
</div>
@ -64,9 +68,11 @@
<div class="stack-grid">
{% for c in clist %}
{% set cnt = c.count if c.count else 1 %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" />
<div class="count-badge">{{ cnt }}x</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
</div>
{% endfor %}
</div>
@ -133,110 +139,115 @@
})();
</script>
<!-- Mana Pip Distribution (vertical bars; only deck colors) -->
<!-- Mana Overview Row: Pips • Sources • Curve -->
<section style="margin-top:1rem;">
<h5>Mana Pip Distribution (non-lands)</h5>
{% set pd = summary.pip_distribution %}
<h5>Mana Overview</h5>
{% set deck_colors = summary.colors or [] %}
{% if pd %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
{% set pct = (w * 100) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
<!-- Pips Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
{% set pd = summary.pip_distribution %}
{% if pd %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
{% set pct = (w * 100) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
{% else %}
<div class="muted">No pip data.</div>
{% endif %}
</div>
{% else %}
<div class="muted">No pip data.</div>
{% endif %}
</section>
<!-- Mana Generation (color sources from lands, vertical bars; only deck colors) -->
<section style="margin-top:1rem;">
<h5>Mana Generation (Color Sources)</h5>
{% set mg = summary.mana_generation %}
{% set deck_colors = summary.colors or [] %}
{% if mg %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
{% set ns = namespace(max_src=0) %}
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
{% endfor %}
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
<!-- Sources Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Sources</div>
{% set mg = summary.mana_generation %}
{% if mg %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
{% set ns = namespace(max_src=0) %}
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
{% endfor %}
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
{% else %}
<div class="muted">No mana source data.</div>
{% endif %}
</div>
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
{% else %}
<div class="muted">No mana source data.</div>
{% endif %}
</section>
<!-- Mana Curve (vertical bars) -->
<section style="margin-top:1rem;">
<h5>Mana Curve (non-lands)</h5>
{% set mc = summary.mana_curve %}
{% if mc %}
{% set ts = mc.total_spells or 0 %}
{% set denom = (ts if ts and ts > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for label in ['0','1','2','3','4','5','6+'] %}
{% set val = mc.get(label, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
{% set parts = [] %}
{% for c in cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
<!-- Curve Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
{% set mc = summary.mana_curve %}
{% if mc %}
{% set ts = mc.total_spells or 0 %}
{% set denom = (ts if ts and ts > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for label in ['0','1','2','3','4','5','6+'] %}
{% set val = mc.get(label, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
{% set parts = [] %}
{% for c in cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (100.0 * (val / denom)) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
{% else %}
<div class="muted">No curve data.</div>
{% endif %}
</div>
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
{% else %}
<div class="muted">No curve data.</div>
{% endif %}
</div>
</section>
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
@ -318,16 +329,15 @@
var grid = document.getElementById('test-hand-grid');
if (!grid) return;
grid.innerHTML = '';
var unique = compress(hand);
unique.forEach(function(it){
hand.forEach(function(name){
if (!name) return;
var div = document.createElement('div');
div.className = 'stack-card';
if (GC_SET && GC_SET.has(it.name)) {
if (GC_SET && GC_SET.has(name)) {
div.className += ' game-changer';
}
div.innerHTML = (
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(it.name) + '&format=image&version=normal" alt="' + it.name + '" data-card-name="' + it.name + '" />' +
'<div class="count-badge">' + it.count + 'x</div>'
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
);
grid.appendChild(div);
});