From 625f6abb13553c48fdfd670e1642644e821e17a4 Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Tue, 26 Aug 2025 16:25:34 -0700 Subject: [PATCH] Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update --- CHANGELOG.md | 6 + DOCKER.md | 24 +- README.md | Bin 16486 -> 19348 bytes RELEASE_NOTES_TEMPLATE.md | 63 ++- WINDOWS_DOCKER_GUIDE.md | 19 + code/deck_builder/builder.py | 3 + code/deck_builder/builder_utils.py | 27 ++ code/deck_builder/phases/phase3_creatures.py | 169 ++++++- code/deck_builder/phases/phase4_spells.py | 54 ++- code/web/app.py | 2 + code/web/routes/build.py | 95 +++- code/web/routes/configs.py | 34 +- code/web/routes/decks.py | 2 + code/web/routes/owned.py | 179 ++++++++ code/web/services/orchestrator.py | 81 +++- code/web/services/owned_store.py | 424 ++++++++++++++++++ code/web/static/styles.css | 24 + code/web/templates/base.html | 3 +- code/web/templates/build/_step4.html | 11 + code/web/templates/build/_step5.html | 36 +- code/web/templates/configs/run_result.html | 2 +- code/web/templates/configs/view.html | 6 +- code/web/templates/decks/index.html | 131 +++--- code/web/templates/decks/view.html | 23 + code/web/templates/owned/index.html | 211 +++++++++ code/web/templates/partials/deck_summary.html | 218 ++++----- 26 files changed, 1618 insertions(+), 229 deletions(-) create mode 100644 code/web/routes/owned.py create mode 100644 code/web/services/owned_store.py create mode 100644 code/web/templates/owned/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 977379d..c96079f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added +- Web UI: FastAPI + Jinja front-end for the builder; staged build view with per-stage reasons +- Theme combine mode (AND/OR) with tooltips and selection-order display in the Web UI +- AND-mode creatures pre-pass: select "all selected themes" creatures first, then fill by weighted overlap; staged reasons show matched themes +- Scryfall attribution footer in the Web UI - Owned-cards workflow: - Prompt (only if lists exist) to "Use only owned cards?" - Support multiple file selection; parse `.txt` (1 per line) and `.csv` (any `name` column) @@ -27,6 +31,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config` - Compose and helper scripts updated accordingly - Release notes source is `RELEASE_NOTES_TEMPLATE.md`; `RELEASE_NOTES.md` ignored +- README/DOCKER/WINDOWS_DOCKER_GUIDE updated for Web UI, headless examples, and PowerShell-friendly commands +- Headless: tag_mode (AND/OR) accepted from JSON and environment and exported in interactive run-config JSON ### Fixed - Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest` diff --git a/DOCKER.md b/DOCKER.md index c6d8f3f..37bb011 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,6 +1,6 @@ -# Docker Guide (concise) +# Docker Guide -Run the MTG Deckbuilder in Docker with persistent volumes and optional headless mode. +Run the MTG Deckbuilder (CLI and Web UI) in Docker with persistent volumes and optional headless mode. ## Quick start @@ -17,6 +17,10 @@ docker run -it --rm ` -v "${PWD}/logs:/app/logs" ` -v "${PWD}/csv_files:/app/csv_files" ` -v "${PWD}/owned_cards:/app/owned_cards" ` + -v "${PWD}/config:/app/config" ` + mwisnowski/mtg-python-deckbuilder:latest +``` + ## Web UI (new) The web UI runs the same deckbuilding logic behind a browser-based interface. @@ -45,11 +49,6 @@ docker run --rm ` bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" ``` ---- - -v "${PWD}/config:/app/config" ` - mwisnowski/mtg-python-deckbuilder:latest -``` - ## Volumes - `/app/deck_files` ↔ `./deck_files` - `/app/logs` ↔ `./logs` @@ -76,6 +75,7 @@ docker run --rm ` - DECK_CONFIG=/app/config/deck.json - DECK_COMMANDER, DECK_PRIMARY_CHOICE - DECK_ADD_LANDS, DECK_FETCH_COUNT + - DECK_TAG_MODE=AND|OR (combine mode used by the builder) ## Manual build/run ```powershell @@ -89,11 +89,11 @@ docker run -it --rm ` mtg-deckbuilder ``` - ## Troubleshooting - - No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run` - - Files not saving? Verify volume mounts and that folders exist - - Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file - - Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards` +## Troubleshooting +- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run` +- Files not saving? Verify volume mounts and that folders exist +- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file +- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards` ## Tips - Use `docker compose run`, not `up`, for interactive mode diff --git a/README.md b/README.md index 9b7acb1dae58436b1d31edbfcdb7a7f5cbd31f77..9267d49c95aec1d4d6d2bd2bdb5ecb4d0cb54ddd 100644 GIT binary patch delta 2480 zcmaJ@O>YxP5UrR9mVgn+0t56YZuU}QY&i_yPUcK&n`!V5u zY}vKRlLe_t=)NV6WSbI5UG9n}dCb{pT>g|v%*;zgig^DeK2|-sBa?35hWBpf;&%6C z!{9{Ee9vyPK8~3scx=Kp#6v%Md5FJ^;p1I{|3!}6f63Il{(lprwCUm*lN)kLvN9#p zm@mNBB0L1ZMC(O-7h9hLDLjYR9 zR~bJ~hICIIvjGAvV}F1k6|B?%nMdK60J?_&1%Jfcw4QB#-XER&wEdqIlu1$s^qGdw zUfVn((Q6G@L&?C+ypBl~sK#H;6yzS(*P#z6GVbTFcs%Hutcye&zt{q_JXqsOm-5Kl(sq@2dC6YXadbKJpPhq~ho*6_Fk zTT`c|Fi#8~Jk;@{-z12 zTGq~D2AI;`0%ToVnHj%6aD?!{lCa$#im{4s&Z4>x=~5kznEM8n%9vrp(Cv@{@ZHm7 zDk7{h=7*6I)*Ac4$goCdTN94eZbAJpB>k-J0`|#TJ-|fLhqZ5@;}Q@B1B$=QDs!3K z?$*GzM}t0ltt7M7rm##S@gDxNnbX2XXBl|y=4R=5Vhw*5BXm{VRQMuRcIh&ZvjhW0 z4o|IzM_(qQ3!lFJ9UoZrM6 ziiWI^Ii`;90=`XsD0wUvW~hJ_%crqu*)*x7>W~M@aUhTIuyt9?4cLURXW?`+YzN6n zoqooG{OQDL^qrjffWQ{5t7MV4bDBeq9CZZRBaxG70F^DOv-7TDC#>_B5i&u z7wd_&$igQROBhqVIG={8mH+x&q_KB(XtoIjpbl^Z!98SykRm%9aakjjHK5%!R0sAf z)yIfmi%vF;2`;&yuH-t6bXzSM{3ISGWn}}6MJukU5V$D6RRz(#%!rxj*~J!w;`{)? zm{+=u^UdC)4SldX6F`3Ljw>?%D*SL{LJ`2Usnn!#E&iHCw&9qjBGVQL z79nYmFFB^G3C5T_7V-KC^9EtgTZ&uTErAiu>r!*KE~Z)onM&Vjwlz1A0(y+AXx;l= z{8n@1d;jin7_HIJZpYz0Q8!grp=MLa29L-Ptid@P?RQ#a9mJzQ#uIDPTqpIH!F2;G z4V*7juXFf^ env > JSON > defaults; `ideal_counts` in JSON are honored. -- Exports: CSV/TXT always; JSON run-config is exported for interactive runs. In headless, JSON export is opt-in via `HEADLESS_EXPORT_JSON`. -- Power bracket: set interactively or via `bracket_level` (env: `DECK_BRACKET_LEVEL`). -- Data freshness: auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`. -- Docker: mount `./owned_cards` to `/app/owned_cards` to enable owned-cards features; `./config` to `/app/config` for JSON configs. +- New Web UI: FastAPI + Jinja front-end with a staged build view and clear reasons per stage. Step 2 now includes AND/OR combine mode with tooltips and selection-order display. Footer includes Scryfall attribution per their guidelines. +- AND/OR combine mode: OR (default) recommends across any selected themes with overlap preference; AND prioritizes multi-theme intersections. In creatures, an AND pre-pass selects "all selected themes" creatures first, then fills by weighted overlap. Staged reasons show which selected themes each all-theme creature hits. +- Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config. +- Owned cards workflow: Prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. Owned-only builds filter the pool; if the deck can't reach 100, it remains incomplete and notes it. When not owned-only, owned cards are marked with an `Owned` column in the final CSV. +- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`). +- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`. + +## What’s new +- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel. +- Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode. +- Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`). ## Docker -- Single service; persistent volumes: +- CLI and Web UI in the same image. +- docker-compose includes a `web` service exposing port 8080 by default. +- Persistent volumes: - /app/deck_files - /app/logs - /app/csv_files - /app/owned_cards - - /app/config (mount `./config` for JSON configs) + - /app/config ### Quick Start ```powershell -# From Docker Hub +# CLI from Docker Hub docker run -it --rm ` -v "${PWD}/deck_files:/app/deck_files" ` -v "${PWD}/logs:/app/logs" ` @@ -31,30 +34,42 @@ docker run -it --rm ` -v "${PWD}/config:/app/config" ` mwisnowski/mtg-python-deckbuilder:latest -# From source with Compose +# Web UI from Docker Hub +docker run --rm ` + -p 8080:8080 ` + -v "${PWD}/deck_files:/app/deck_files" ` + -v "${PWD}/logs:/app/logs" ` + -v "${PWD}/csv_files:/app/csv_files" ` + -v "${PWD}/owned_cards:/app/owned_cards" ` + -v "${PWD}/config:/app/config" ` + mwisnowski/mtg-python-deckbuilder:latest ` + bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" + +# From source with Compose (CLI) docker compose build docker compose run --rm mtg-deckbuilder -# Headless (optional) -docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder -# With JSON config -docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder +# From source with Compose (Web) +docker compose build web +docker compose up --no-deps web ``` ## Changes -- Added owned-cards workflow, CSV Owned column, and recommendations export when owned-only builds are incomplete. -- Docker assets updated to include `/app/owned_cards` volume and mount examples. -- Windows release workflow now attaches a PyInstaller-built EXE to GitHub Releases. +- Web UI: staged view, Step 2 AND/OR radios with tips, selection order display, improved Why panel readability, and Scryfall attribution footer. +- Builder: AND-mode creatures pre-pass with matched-themes reasons; spells prefer overlap in AND mode. +- Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON. +- Docs: README, DOCKER, and Windows Docker guide updated; PowerShell-friendly examples. +- Docker: compose `web` service added; volumes clarified. ### Tagging updates -- Explore/Map: fixed a pattern issue by treating "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter. +- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter. - Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood. -- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters mapping; Energy enriched. +- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters; Energy enriched. - Spawn/Scion creators now map to Aristocrats and Ramp. ## Known Issues - First run downloads card data (takes a few minutes) -- Use `docker compose run --rm` (not `up`) for interactive sessions +- Use `docker compose run --rm` (not `up`) for interactive CLI sessions - Ensure volumes are mounted to persist files outside the container ## Links diff --git a/WINDOWS_DOCKER_GUIDE.md b/WINDOWS_DOCKER_GUIDE.md index edd38d1..345f484 100644 --- a/WINDOWS_DOCKER_GUIDE.md +++ b/WINDOWS_DOCKER_GUIDE.md @@ -41,6 +41,21 @@ docker run -it --rm ` mwisnowski/mtg-python-deckbuilder:latest ``` +### Optional: Web UI from Docker Hub +Run the browser UI by mapping a port and starting uvicorn: +```powershell +docker run --rm ` + -p 8080:8080 ` + -v "${PWD}/deck_files:/app/deck_files" ` + -v "${PWD}/logs:/app/logs" ` + -v "${PWD}/csv_files:/app/csv_files" ` + -v "${PWD}/owned_cards:/app/owned_cards" ` + -v "${PWD}/config:/app/config" ` + mwisnowski/mtg-python-deckbuilder:latest ` + bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" +``` +Then open http://localhost:8080 + ## Method 2: Command Prompt ```cmd REM Create and navigate to workspace @@ -151,3 +166,7 @@ C:\mtg-decks\ ├── deck_files\ # Your completed decks (.csv and .txt files) │ ├── Atraxa_Superfriends_20250821.csv │ ├── Atraxa_Superfriends_20250821.txt +├── logs\\ +├── csv_files\\ +├── owned_cards\\ +└── config\\ diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index d3c5750..7efe2a4 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -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: diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index 845736c..d30e3f0 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -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 # --------------------------------------------------------------------------- diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index cc486ce..4646714 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -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)") diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index 168b5c8..0857b90 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -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 diff --git a/code/web/app.py b/code/web/app.py index 781fc91..a2147d5 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -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") diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 5fdd5c9..52163fa 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -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}", diff --git a/code/web/routes/configs.py b/code/web/routes/configs.py index 1f2fdcc..b3849d7 100644 --- a/code/web/routes/configs.py +++ b/code/web/routes/configs.py @@ -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, }, ) diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 120719c..194cc5c 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -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) diff --git a/code/web/routes/owned.py b/code/web/routes/owned.py new file mode 100644 index 0000000..7f20f43 --- /dev/null +++ b/code/web/routes/owned.py @@ -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"}, + ) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 54025c5..a6319a2 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -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 diff --git a/code/web/services/owned_store.py b/code/web/services/owned_store.py new file mode 100644 index 0000000..809beb0 --- /dev/null +++ b/code/web/services/owned_store.py @@ -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 diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 64fe3ac..60903ef 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -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; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 2108f6d..6fcd50a 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -5,7 +5,7 @@ MTG Deckbuilder - +
@@ -30,6 +30,7 @@ Build Build from JSON {% if show_setup %}Setup/Tag{% endif %} + Owned Library Finished Decks {% if show_logs %}Logs{% endif %} diff --git a/code/web/templates/build/_step4.html b/code/web/templates/build/_step4.html index 493ce9a..8a8b866 100644 --- a/code/web/templates/build/_step4.html +++ b/code/web/templates/build/_step4.html @@ -14,6 +14,17 @@
  • {{ label }}: {{ values[key] }}
  • {% endfor %} +
    + + + Manage Owned Library +
    diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index bdd244e..22c8c29 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -27,6 +27,14 @@

    Commander: {{ commander }}

    Tags: {{ tags|default([])|join(', ') }}

    +
    + Owned-only: {{ 'On' if owned_only else 'Off' }} +
    + +
    Prefer-owned: {{ 'On' if prefer_owned else 'Off' }}
    +
    + Manage Owned Library +

    Bracket: {{ bracket }}

    {% if i and n %} @@ -41,20 +49,36 @@
    - + + -
    + +
    -
    + +
    +
    - {% if added_cards %} + {% if added_cards is not none %}

    Cards added this stage

    + {% if skipped and (not added_cards or added_cards|length == 0) %} +
    No cards added in this stage.
    + {% endif %} +
    + Owned + Not owned +
    + {% if stage_label and stage_label.startswith('Creatures') %} {% set groups = added_cards|groupby('sub_role') %} {% for g in groups %} @@ -67,10 +91,12 @@
    {{ heading }}
    {% for c in g.list %} + {% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
    {{ c.name }} image +
    {% if owned %}✔{% else %}✖{% endif %}
    {{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}
    {% if c.reason %}
    {{ c.reason }}
    {% endif %}
    @@ -80,10 +106,12 @@ {% else %}
    {% for c in added_cards %} + {% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
    {{ c.name }} image +
    {% if owned %}✔{% else %}✖{% endif %}
    {{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}
    {% if c.reason %}
    {{ c.reason }}
    {% endif %}
    diff --git a/code/web/templates/configs/run_result.html b/code/web/templates/configs/run_result.html index 9dcbd66..4b442dd 100644 --- a/code/web/templates/configs/run_result.html +++ b/code/web/templates/configs/run_result.html @@ -3,7 +3,7 @@

    Build from JSON: {{ cfg_name }}

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

    {% if commander %} -
    Commander: {{ commander }}{% if tag_mode %} · Combine: {{ tag_mode }}{% endif %}
    +
    Commander: {{ commander }}{% if tag_mode %} · Combine: {{ tag_mode }}{% endif %}{% if use_owned_only %} · Owned-only{% endif %}
    {% endif %}
    diff --git a/code/web/templates/configs/view.html b/code/web/templates/configs/view.html index a76e215..c4797db 100644 --- a/code/web/templates/configs/view.html +++ b/code/web/templates/configs/view.html @@ -15,8 +15,12 @@ Ideal Counts
    {{ data.ideal_counts | tojson(indent=2) }}
    -
    + +
    diff --git a/code/web/templates/decks/index.html b/code/web/templates/decks/index.html index fadd906..50ac7bd 100644 --- a/code/web/templates/decks/index.html +++ b/code/web/templates/decks/index.html @@ -16,18 +16,20 @@ + - +
    -
    Theme filters
    -
    + {% if items %}
    @@ -82,7 +84,7 @@
  • Enter/Space opens a focused deck; Ctrl/Shift+Enter opens in a new tab
  • Arrow ↑/↓, Home, End navigate rows
  • Esc clears the filter (when focused)
  • -
  • R resets all filters, sort, and tags
  • +
  • R resets all filters, sort, and theme
  • Use “TXT only” to show only decks that have a TXT export
  • Share copies a link with your current filters
  • @@ -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 = ''; + 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 @@ })(); +{% endblock %} diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html index 808b168..640f274 100644 --- a/code/web/templates/partials/deck_summary.html +++ b/code/web/templates/partials/deck_summary.html @@ -1,9 +1,9 @@

    Deck Summary

    -
    - Legend: Game Changer - (green highlight) - +
    + Legend: + Game Changer (green highlight) + Owned • Not owned
    @@ -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; }
    {% for t in tb.order %} @@ -42,9 +44,11 @@ {% 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)) %} {{ cnt }}x {{ c.name }} + {% if owned %}✔{% else %}✖{% endif %}
    {% endfor %}
    @@ -64,9 +68,11 @@
    {% 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)) %}
    {{ c.name }} image
    {{ cnt }}x
    +
    {% if owned %}✔{% else %}✖{% endif %}
    {% endfor %}
    @@ -133,110 +139,115 @@ })(); - +
    -
    Mana Pip Distribution (non-lands)
    - {% set pd = summary.pip_distribution %} +
    Mana Overview
    {% set deck_colors = summary.colors or [] %} - {% if pd %} - {% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %} -
    - {% for color in colors %} - {% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %} - {% set pct = (w * 100) | int %} -
    - - {% 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 %} - - {% set h = (pct * 1.0) | int %} - {% set bar_h = (h if h>2 else 2) %} - {% set y = 118 - bar_h %} - - -
    {{ color }}
    +
    + +
    +
    Mana Pips (non-lands)
    + {% set pd = summary.pip_distribution %} + {% if pd %} + {% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %} +
    + {% for color in colors %} + {% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %} + {% set pct = (w * 100) | int %} +
    + + {% 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 %} + + {% set h = (pct * 1.0) | int %} + {% set bar_h = (h if h>2 else 2) %} + {% set y = 118 - bar_h %} + + +
    {{ color }}
    +
    + {% endfor %}
    - {% endfor %} + {% else %} +
    No pip data.
    + {% endif %}
    - {% else %} -
    No pip data.
    - {% endif %} -
    - -
    -
    Mana Generation (Color Sources)
    - {% 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) %} -
    - {% for color in colors %} - {% set val = mg.get(color, 0) %} - {% set pct = (val * 100 / denom) | int %} -
    - - {% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %} - - {% set bar_h = (pct if pct>2 else 2) %} - {% set y = 118 - bar_h %} - - -
    {{ color }}
    + +
    +
    Mana Sources
    + {% 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) %} +
    + {% for color in colors %} + {% set val = mg.get(color, 0) %} + {% set pct = (val * 100 / denom) | int %} +
    + + {% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %} + + {% set bar_h = (pct if pct>2 else 2) %} + {% set y = 118 - bar_h %} + + +
    {{ color }}
    +
    + {% endfor %}
    - {% endfor %} +
    Total sources: {{ mg.total_sources or 0 }}
    + {% else %} +
    No mana source data.
    + {% endif %}
    -
    Total sources: {{ mg.total_sources or 0 }}
    - {% else %} -
    No mana source data.
    - {% endif %} -
    - -
    -
    Mana Curve (non-lands)
    - {% set mc = summary.mana_curve %} - {% if mc %} - {% set ts = mc.total_spells or 0 %} - {% set denom = (ts if ts and ts > 0 else 1) %} -
    - {% for label in ['0','1','2','3','4','5','6+'] %} - {% set val = mc.get(label, 0) %} - {% set pct = (val * 100 / denom) | int %} -
    - - {% 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)) %} - - {% set bar_h = (pct if pct>2 else 2) %} - {% set y = 118 - bar_h %} - - -
    {{ label }}
    + +
    +
    Mana Curve (non-lands)
    + {% set mc = summary.mana_curve %} + {% if mc %} + {% set ts = mc.total_spells or 0 %} + {% set denom = (ts if ts and ts > 0 else 1) %} +
    + {% for label in ['0','1','2','3','4','5','6+'] %} + {% set val = mc.get(label, 0) %} + {% set pct = (val * 100 / denom) | int %} +
    + + {% 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)) %} + + {% set bar_h = (pct if pct>2 else 2) %} + {% set y = 118 - bar_h %} + + +
    {{ label }}
    +
    + {% endfor %}
    - {% endfor %} +
    Total spells: {{ mc.total_spells or 0 }}
    + {% else %} +
    No curve data.
    + {% endif %}
    -
    Total spells: {{ mc.total_spells or 0 }}
    - {% else %} -
    No curve data.
    - {% endif %} +
    @@ -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 = ( - '' + it.name + '' + - '
    ' + it.count + 'x
    ' + '' + name + '' ); grid.appendChild(div); });