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 9b7acb1..9267d49 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 292d8f5..cdac315 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,28 +1,31 @@ # MTG Python Deckbuilder ${VERSION} ## Highlights -- Owned cards: prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. -- Owned-only builds filter the pool by your lists; if the deck can't reach 100, it remains incomplete and notes it. -- Recommendations: on incomplete owned-only builds, exports `deck_files/[stem]_recommendations.csv` and `.txt` with ~1.5× missing cards, and prints a short notice. -- Owned column: when not using owned-only, owned cards are marked with an `Owned` column in the final CSV. -- Headless support: run non-interactively or via the menu's headless submenu. -- Config precedence: CLI > 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 @@