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

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

View file

@ -13,6 +13,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### 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: - Owned-cards workflow:
- Prompt (only if lists exist) to "Use only owned cards?" - Prompt (only if lists exist) to "Use only owned cards?"
- Support multiple file selection; parse `.txt` (1 per line) and `.csv` (any `name` column) - 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` - New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
- Compose and helper scripts updated accordingly - Compose and helper scripts updated accordingly
- Release notes source is `RELEASE_NOTES_TEMPLATE.md`; `RELEASE_NOTES.md` ignored - 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 ### 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` - Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest`

View file

@ -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 ## Quick start
@ -17,6 +17,10 @@ docker run -it --rm `
-v "${PWD}/logs:/app/logs" ` -v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" ` -v "${PWD}/csv_files:/app/csv_files" `
-v "${PWD}/owned_cards:/app/owned_cards" ` -v "${PWD}/owned_cards:/app/owned_cards" `
-v "${PWD}/config:/app/config" `
mwisnowski/mtg-python-deckbuilder:latest
```
## Web UI (new) ## Web UI (new)
The web UI runs the same deckbuilding logic behind a browser-based interface. 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" 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 ## Volumes
- `/app/deck_files``./deck_files` - `/app/deck_files``./deck_files`
- `/app/logs``./logs` - `/app/logs``./logs`
@ -76,6 +75,7 @@ docker run --rm `
- DECK_CONFIG=/app/config/deck.json - DECK_CONFIG=/app/config/deck.json
- DECK_COMMANDER, DECK_PRIMARY_CHOICE - DECK_COMMANDER, DECK_PRIMARY_CHOICE
- DECK_ADD_LANDS, DECK_FETCH_COUNT - DECK_ADD_LANDS, DECK_FETCH_COUNT
- DECK_TAG_MODE=AND|OR (combine mode used by the builder)
## Manual build/run ## Manual build/run
```powershell ```powershell
@ -89,11 +89,11 @@ docker run -it --rm `
mtg-deckbuilder mtg-deckbuilder
``` ```
## Troubleshooting ## Troubleshooting
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run` - 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 - 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 - 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` - Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
## Tips ## Tips
- Use `docker compose run`, not `up`, for interactive mode - Use `docker compose run`, not `up`, for interactive mode

BIN
README.md

Binary file not shown.

View file

@ -1,28 +1,31 @@
# MTG Python Deckbuilder ${VERSION} # MTG Python Deckbuilder ${VERSION}
## Highlights ## Highlights
- Owned cards: prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. - 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.
- Owned-only builds filter the pool by your lists; if the deck can't reach 100, it remains incomplete and notes it. - 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.
- Recommendations: on incomplete owned-only builds, exports `deck_files/[stem]_recommendations.csv` and `.txt` with ~1.5× missing cards, and prints a short notice. - Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config.
- Owned column: when not using owned-only, owned cards are marked with an `Owned` column in the final CSV. - 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.
- Headless support: run non-interactively or via the menu's headless submenu. - Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
- Config precedence: CLI > env > JSON > defaults; `ideal_counts` in JSON are honored. - Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
- 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`). ## Whats new
- Data freshness: auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`. - 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.
- Docker: mount `./owned_cards` to `/app/owned_cards` to enable owned-cards features; `./config` to `/app/config` for JSON configs. - 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 ## 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/deck_files
- /app/logs - /app/logs
- /app/csv_files - /app/csv_files
- /app/owned_cards - /app/owned_cards
- /app/config (mount `./config` for JSON configs) - /app/config
### Quick Start ### Quick Start
```powershell ```powershell
# From Docker Hub # CLI from Docker Hub
docker run -it --rm ` docker run -it --rm `
-v "${PWD}/deck_files:/app/deck_files" ` -v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" ` -v "${PWD}/logs:/app/logs" `
@ -31,30 +34,42 @@ docker run -it --rm `
-v "${PWD}/config:/app/config" ` -v "${PWD}/config:/app/config" `
mwisnowski/mtg-python-deckbuilder:latest 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 build
docker compose run --rm mtg-deckbuilder docker compose run --rm mtg-deckbuilder
# Headless (optional) # From source with Compose (Web)
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder docker compose build web
# With JSON config docker compose up --no-deps web
docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder
``` ```
## Changes ## Changes
- Added owned-cards workflow, CSV Owned column, and recommendations export when owned-only builds are incomplete. - Web UI: staged view, Step 2 AND/OR radios with tips, selection order display, improved Why panel readability, and Scryfall attribution footer.
- Docker assets updated to include `/app/owned_cards` volume and mount examples. - Builder: AND-mode creatures pre-pass with matched-themes reasons; spells prefer overlap in AND mode.
- Windows release workflow now attaches a PyInstaller-built EXE to GitHub Releases. - 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 ### 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. - 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. - Spawn/Scion creators now map to Aristocrats and Ramp.
## Known Issues ## Known Issues
- First run downloads card data (takes a few minutes) - 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 - Ensure volumes are mounted to persist files outside the container
## Links ## Links

View file

@ -41,6 +41,21 @@ docker run -it --rm `
mwisnowski/mtg-python-deckbuilder:latest 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 ## Method 2: Command Prompt
```cmd ```cmd
REM Create and navigate to workspace REM Create and navigate to workspace
@ -151,3 +166,7 @@ C:\mtg-decks\
├── deck_files\ # Your completed decks (.csv and .txt files) ├── deck_files\ # Your completed decks (.csv and .txt files)
│ ├── Atraxa_Superfriends_20250821.csv │ ├── Atraxa_Superfriends_20250821.csv
│ ├── Atraxa_Superfriends_20250821.txt │ ├── Atraxa_Superfriends_20250821.txt
├── logs\\
├── csv_files\\
├── owned_cards\\
└── config\\

View file

@ -309,6 +309,8 @@ class DeckBuilder(
use_owned_only: bool = False use_owned_only: bool = False
owned_card_names: set[str] = field(default_factory=set) owned_card_names: set[str] = field(default_factory=set)
owned_files_selected: List[str] = field(default_factory=list) 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 # Deck library (cards added so far) mapping name->record
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict) 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.") self.output_func("Owned-only mode: no recognizable name column to filter on; skipping filter.")
except Exception as _e: except Exception as _e:
self.output_func(f"Owned-only mode: failed to filter combined pool: {_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 self._combined_cards_df = combined
# Preserve original snapshot for enrichment across subsequent removals # Preserve original snapshot for enrichment across subsequent removals
if self._full_cards_df is None: if self._full_cards_df is None:

View file

@ -158,6 +158,7 @@ __all__ = [
'compute_spell_pip_weights', 'compute_spell_pip_weights',
'parse_theme_tags', 'parse_theme_tags',
'normalize_theme_list', 'normalize_theme_list',
'prefer_owned_first',
'compute_adjusted_target', 'compute_adjusted_target',
'normalize_tag_cell', 'normalize_tag_cell',
'sort_by_priority', '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') 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 # Tag-driven land suggestion helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

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

View file

@ -57,6 +57,12 @@ class SpellAdditionMixin:
if commander_name: if commander_name:
work = work[work['name'] != commander_name] work = work[work['name'] != commander_name]
work = bu.sort_by_priority(work, ['edhrecRank','manaValue']) 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)) rocks_target = min(target_total, math.ceil(target_total/3))
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4)) dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
@ -143,6 +149,10 @@ class SpellAdditionMixin:
if commander_name: if commander_name:
pool = pool[pool['name'] != commander_name] pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue']) 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 existing = 0
for name, entry in self.card_library.items(): for name, entry in self.card_library.items():
lt = [str(t).lower() for t in entry.get('Tags', [])] lt = [str(t).lower() for t in entry.get('Tags', [])]
@ -201,6 +211,10 @@ class SpellAdditionMixin:
if commander_name: if commander_name:
pool = pool[pool['name'] != commander_name] pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue']) 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 existing = 0
for name, entry in self.card_library.items(): for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])] tags = [str(t).lower() for t in entry.get('Tags', [])]
@ -277,6 +291,12 @@ class SpellAdditionMixin:
return bu.sort_by_priority(d, ['edhrecRank','manaValue']) return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
conditional_df = sortit(conditional_df) conditional_df = sortit(conditional_df)
unconditional_df = sortit(unconditional_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 = 0
added_cond_names: List[str] = [] added_cond_names: List[str] = []
for _, r in conditional_df.iterrows(): for _, r in conditional_df.iterrows():
@ -349,6 +369,10 @@ class SpellAdditionMixin:
if commander_name: if commander_name:
pool = pool[pool['name'] != commander_name] pool = pool[pool['name'] != commander_name]
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue']) 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 existing = 0
for name, entry in self.card_library.items(): for name, entry in self.card_library.items():
tags = [str(t).lower() for t in entry.get('Tags', [])] tags = [str(t).lower() for t in entry.get('Tags', [])]
@ -491,14 +515,32 @@ class SpellAdditionMixin:
ascending=[False, True], ascending=[False, True],
na_position='last', 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 = subset.head(top_n).copy()
pool = pool[~pool['name'].isin(self.card_library.keys())] pool = pool[~pool['name'].isin(self.card_library.keys())]
if pool.empty: if pool.empty:
continue 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': 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: 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) chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen: for nm in chosen:
row = pool[pool['name'] == nm].iloc[0] row = pool[pool['name'] == nm].iloc[0]
@ -541,6 +583,10 @@ class SpellAdditionMixin:
ascending=[False, True], ascending=[False, True],
na_position='last', 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] fill = multi_pool['name'].tolist()[:need]
for nm in fill: for nm in fill:
row = multi_pool[multi_pool['name'] == nm].iloc[0] 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') subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
elif 'manaValue' in subset.columns: elif 'manaValue' in subset.columns:
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last') 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) row = subset.head(1)
if row.empty: if row.empty:
break break

View file

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

View file

@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse
from ..app import templates from ..app import templates
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from ..services import orchestrator as orch from ..services import orchestrator as orch
from ..services import owned_store
from ..services.tasks import get_session, new_sid from ..services.tasks import get_session, new_sid
router = APIRouter(prefix="/build") router = APIRouter(prefix="/build")
@ -286,10 +287,44 @@ async def build_step4_get(request: Request) -> HTMLResponse:
"labels": labels, "labels": labels,
"values": values, "values": values,
"commander": commander, "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) @router.get("/step5", response_class=HTMLResponse)
async def build_step5_get(request: Request) -> HTMLResponse: async def build_step5_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid() sid = request.cookies.get("sid") or new_sid()
@ -302,6 +337,9 @@ async def build_step5_get(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []), "tags": sess.get("tags", []),
"bracket": sess.get("bracket"), "bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()), "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, "status": None,
"stage_label": None, "stage_label": None,
"log": None, "log": None,
@ -331,14 +369,27 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
except Exception: except Exception:
safe_bracket = int(default_bracket) safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults() 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( sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"), commander=sess.get("commander"),
tags=sess.get("tags", []), tags=sess.get("tags", []),
bracket=safe_bracket, bracket=safe_bracket,
ideals=ideals_val, ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"), 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" status = "Build complete" if res.get("done") else "Stage complete"
stage_label = res.get("label") stage_label = res.get("label")
log = res.get("log_delta", "") log = res.get("log_delta", "")
@ -357,6 +408,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []), "tags": sess.get("tags", []),
"bracket": sess.get("bracket"), "bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()), "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, "status": status,
"stage_label": stage_label, "stage_label": stage_label,
"log": log, "log": log,
@ -367,6 +421,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"txt_path": txt_path, "txt_path": txt_path,
"summary": summary, "summary": summary,
"game_changers": bc.GAME_CHANGERS, "game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
}, },
) )
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -390,14 +445,26 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
except Exception: except Exception:
safe_bracket = int(default_bracket) safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults() 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( sess["build_ctx"] = orch.start_build_ctx(
commander=sess.get("commander"), commander=sess.get("commander"),
tags=sess.get("tags", []), tags=sess.get("tags", []),
bracket=safe_bracket, bracket=safe_bracket,
ideals=ideals_val, ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"), 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" status = "Stage rerun complete" if not res.get("done") else "Build complete"
stage_label = res.get("label") stage_label = res.get("label")
log = res.get("log_delta", "") log = res.get("log_delta", "")
@ -415,6 +482,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []), "tags": sess.get("tags", []),
"bracket": sess.get("bracket"), "bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()), "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, "status": status,
"stage_label": stage_label, "stage_label": stage_label,
"log": log, "log": log,
@ -425,6 +495,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"txt_path": txt_path, "txt_path": txt_path,
"summary": summary, "summary": summary,
"game_changers": bc.GAME_CHANGERS, "game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
}, },
) )
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -454,14 +525,26 @@ async def build_step5_start(request: Request) -> HTMLResponse:
except Exception: except Exception:
safe_bracket = int(default_bracket) safe_bracket = int(default_bracket)
ideals_val = sess.get("ideals") or orch.ideal_defaults() 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( sess["build_ctx"] = orch.start_build_ctx(
commander=commander, commander=commander,
tags=sess.get("tags", []), tags=sess.get("tags", []),
bracket=safe_bracket, bracket=safe_bracket,
ideals=ideals_val, ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"), 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" status = "Stage complete" if not res.get("done") else "Build complete"
stage_label = res.get("label") stage_label = res.get("label")
log = res.get("log_delta", "") log = res.get("log_delta", "")
@ -479,6 +562,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"tags": sess.get("tags", []), "tags": sess.get("tags", []),
"bracket": sess.get("bracket"), "bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()), "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, "status": status,
"stage_label": stage_label, "stage_label": stage_label,
"log": log, "log": log,
@ -489,6 +575,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
"txt_path": txt_path, "txt_path": txt_path,
"summary": summary, "summary": summary,
"game_changers": bc.GAME_CHANGERS, "game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
}, },
) )
resp.set_cookie("sid", sid, httponly=True, samesite="lax") 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", []), "tags": sess.get("tags", []),
"bracket": sess.get("bracket"), "bracket": sess.get("bracket"),
"values": sess.get("ideals", orch.ideal_defaults()), "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", "status": "Error",
"stage_label": None, "stage_label": None,
"log": f"Failed to start build: {e}", "log": f"Failed to start build: {e}",

View file

@ -6,6 +6,7 @@ from pathlib import Path
import os import os
import json import json
from ..app import templates from ..app import templates
from ..services import owned_store
from ..services import orchestrator as orch from ..services import orchestrator as orch
from deck_builder import builder_constants as bc 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) @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() base = _config_dir()
p = (base / name).resolve() p = (base / name).resolve()
try: try:
@ -125,8 +126,33 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
except Exception: except Exception:
tag_mode = "AND" 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 # 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"): if not res.get("ok"):
return templates.TemplateResponse( return templates.TemplateResponse(
"configs/run_result.html", "configs/run_result.html",
@ -138,6 +164,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"cfg_name": p.name, "cfg_name": p.name,
"commander": commander, "commander": commander,
"tag_mode": tag_mode, "tag_mode": tag_mode,
"use_owned_only": owned_flag,
"owned_set": {n.lower() for n in owned_store.get_names()},
}, },
) )
return templates.TemplateResponse( return templates.TemplateResponse(
@ -152,6 +180,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"cfg_name": p.name, "cfg_name": p.name,
"commander": commander, "commander": commander,
"tag_mode": tag_mode, "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, "game_changers": bc.GAME_CHANGERS,
}, },
) )

View file

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

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

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

View file

@ -631,7 +631,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
_write_status({"running": False, "phase": "error", "message": "Setup check failed"}) _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. """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] } 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: except Exception:
pass 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 # Load data and run phases
try: try:
b.determine_color_identity() b.determine_color_identity()
@ -784,6 +805,14 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
if callable(fn): if callable(fn):
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"}) 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 # 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'): 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"}) 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'): 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 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] = [] logs: List[str] = []
def out(msg: str) -> None: def out(msg: str) -> None:
@ -865,6 +904,25 @@ def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[
except Exception: except Exception:
pass 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 # Data load
b.determine_color_identity() b.determine_color_identity()
b.setup_dataframes() 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)) 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"] b: DeckBuilder = ctx["builder"]
stages: List[Dict[str, Any]] = ctx["stages"] stages: List[Dict[str, Any]] = ctx["stages"]
logs: List[str] = ctx["logs"] 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), "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 i += 1
# Continue loop to auto-advance # Continue loop to auto-advance

View file

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

View file

@ -61,6 +61,10 @@ body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text);
/* Buttons, inputs */ /* Buttons, inputs */
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
button:hover{ filter:brightness(1.05); } 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; } 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; } 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; } 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{ .card-tile{
width:170px; width:170px;
position: relative;
background:#0f1115; background:#0f1115;
border:1px solid var(--border); border:1px solid var(--border);
border-radius:6px; 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 .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } .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) */ /* Step 1 candidate grid (200px-wide scaled images) */
.candidate-grid{ .candidate-grid{
display:grid; display:grid;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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