mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update
This commit is contained in:
parent
fd7fc01071
commit
625f6abb13
26 changed files with 1618 additions and 229 deletions
|
@ -13,6 +13,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Web UI: FastAPI + Jinja front-end for the builder; staged build view with per-stage reasons
|
||||
- Theme combine mode (AND/OR) with tooltips and selection-order display in the Web UI
|
||||
- AND-mode creatures pre-pass: select "all selected themes" creatures first, then fill by weighted overlap; staged reasons show matched themes
|
||||
- Scryfall attribution footer in the Web UI
|
||||
- Owned-cards workflow:
|
||||
- Prompt (only if lists exist) to "Use only owned cards?"
|
||||
- Support multiple file selection; parse `.txt` (1 per line) and `.csv` (any `name` column)
|
||||
|
@ -27,6 +31,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config`
|
||||
- Compose and helper scripts updated accordingly
|
||||
- Release notes source is `RELEASE_NOTES_TEMPLATE.md`; `RELEASE_NOTES.md` ignored
|
||||
- README/DOCKER/WINDOWS_DOCKER_GUIDE updated for Web UI, headless examples, and PowerShell-friendly commands
|
||||
- Headless: tag_mode (AND/OR) accepted from JSON and environment and exported in interactive run-config JSON
|
||||
|
||||
### Fixed
|
||||
- Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest`
|
||||
|
|
24
DOCKER.md
24
DOCKER.md
|
@ -1,6 +1,6 @@
|
|||
# Docker Guide (concise)
|
||||
# Docker Guide
|
||||
|
||||
Run the MTG Deckbuilder in Docker with persistent volumes and optional headless mode.
|
||||
Run the MTG Deckbuilder (CLI and Web UI) in Docker with persistent volumes and optional headless mode.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
@ -17,6 +17,10 @@ docker run -it --rm `
|
|||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
```
|
||||
|
||||
## Web UI (new)
|
||||
|
||||
The web UI runs the same deckbuilding logic behind a browser-based interface.
|
||||
|
@ -45,11 +49,6 @@ docker run --rm `
|
|||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
```
|
||||
|
||||
---
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
```
|
||||
|
||||
## Volumes
|
||||
- `/app/deck_files` ↔ `./deck_files`
|
||||
- `/app/logs` ↔ `./logs`
|
||||
|
@ -76,6 +75,7 @@ docker run --rm `
|
|||
- DECK_CONFIG=/app/config/deck.json
|
||||
- DECK_COMMANDER, DECK_PRIMARY_CHOICE
|
||||
- DECK_ADD_LANDS, DECK_FETCH_COUNT
|
||||
- DECK_TAG_MODE=AND|OR (combine mode used by the builder)
|
||||
|
||||
## Manual build/run
|
||||
```powershell
|
||||
|
@ -89,11 +89,11 @@ docker run -it --rm `
|
|||
mtg-deckbuilder
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
|
||||
- Files not saving? Verify volume mounts and that folders exist
|
||||
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
|
||||
- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
|
||||
## Troubleshooting
|
||||
- No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run`
|
||||
- Files not saving? Verify volume mounts and that folders exist
|
||||
- Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file
|
||||
- Owned-cards prompt not seeing files? Ensure `./owned_cards` is mounted to `/app/owned_cards`
|
||||
|
||||
## Tips
|
||||
- Use `docker compose run`, not `up`, for interactive mode
|
||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -1,28 +1,31 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
## Highlights
|
||||
- Owned cards: prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`.
|
||||
- Owned-only builds filter the pool by your lists; if the deck can't reach 100, it remains incomplete and notes it.
|
||||
- Recommendations: on incomplete owned-only builds, exports `deck_files/[stem]_recommendations.csv` and `.txt` with ~1.5× missing cards, and prints a short notice.
|
||||
- Owned column: when not using owned-only, owned cards are marked with an `Owned` column in the final CSV.
|
||||
- Headless support: run non-interactively or via the menu's headless submenu.
|
||||
- Config precedence: CLI > env > JSON > defaults; `ideal_counts` in JSON are honored.
|
||||
- Exports: CSV/TXT always; JSON run-config is exported for interactive runs. In headless, JSON export is opt-in via `HEADLESS_EXPORT_JSON`.
|
||||
- Power bracket: set interactively or via `bracket_level` (env: `DECK_BRACKET_LEVEL`).
|
||||
- Data freshness: auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
|
||||
- Docker: mount `./owned_cards` to `/app/owned_cards` to enable owned-cards features; `./config` to `/app/config` for JSON configs.
|
||||
- New Web UI: FastAPI + Jinja front-end with a staged build view and clear reasons per stage. Step 2 now includes AND/OR combine mode with tooltips and selection-order display. Footer includes Scryfall attribution per their guidelines.
|
||||
- AND/OR combine mode: OR (default) recommends across any selected themes with overlap preference; AND prioritizes multi-theme intersections. In creatures, an AND pre-pass selects "all selected themes" creatures first, then fills by weighted overlap. Staged reasons show which selected themes each all-theme creature hits.
|
||||
- Headless improvements: `tag_mode` (AND/OR) accepted via JSON and environment; interactive exports include `tag_mode` in the run-config.
|
||||
- Owned cards workflow: Prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. Owned-only builds filter the pool; if the deck can't reach 100, it remains incomplete and notes it. When not owned-only, owned cards are marked with an `Owned` column in the final CSV.
|
||||
- Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`).
|
||||
- Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`.
|
||||
|
||||
## What’s new
|
||||
- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel.
|
||||
- Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode.
|
||||
- Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`).
|
||||
|
||||
## Docker
|
||||
- Single service; persistent volumes:
|
||||
- CLI and Web UI in the same image.
|
||||
- docker-compose includes a `web` service exposing port 8080 by default.
|
||||
- Persistent volumes:
|
||||
- /app/deck_files
|
||||
- /app/logs
|
||||
- /app/csv_files
|
||||
- /app/owned_cards
|
||||
- /app/config (mount `./config` for JSON configs)
|
||||
- /app/config
|
||||
|
||||
### Quick Start
|
||||
```powershell
|
||||
# From Docker Hub
|
||||
# CLI from Docker Hub
|
||||
docker run -it --rm `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
|
@ -31,30 +34,42 @@ docker run -it --rm `
|
|||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
|
||||
# From source with Compose
|
||||
# Web UI from Docker Hub
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
|
||||
# From source with Compose (CLI)
|
||||
docker compose build
|
||||
docker compose run --rm mtg-deckbuilder
|
||||
|
||||
# Headless (optional)
|
||||
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
|
||||
# With JSON config
|
||||
docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder
|
||||
# From source with Compose (Web)
|
||||
docker compose build web
|
||||
docker compose up --no-deps web
|
||||
```
|
||||
|
||||
## Changes
|
||||
- Added owned-cards workflow, CSV Owned column, and recommendations export when owned-only builds are incomplete.
|
||||
- Docker assets updated to include `/app/owned_cards` volume and mount examples.
|
||||
- Windows release workflow now attaches a PyInstaller-built EXE to GitHub Releases.
|
||||
- Web UI: staged view, Step 2 AND/OR radios with tips, selection order display, improved Why panel readability, and Scryfall attribution footer.
|
||||
- Builder: AND-mode creatures pre-pass with matched-themes reasons; spells prefer overlap in AND mode.
|
||||
- Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON.
|
||||
- Docs: README, DOCKER, and Windows Docker guide updated; PowerShell-friendly examples.
|
||||
- Docker: compose `web` service added; volumes clarified.
|
||||
|
||||
### Tagging updates
|
||||
- Explore/Map: fixed a pattern issue by treating "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.
|
||||
- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.
|
||||
- Discard Matters theme and enrichments for Loot/Connive/Cycling/Blood.
|
||||
- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters mapping; Energy enriched.
|
||||
- Newer mechanics support: Freerunning, Craft, Spree, Rad counters; Time Travel/Vanishing folded into Exile/Time Counters; Energy enriched.
|
||||
- Spawn/Scion creators now map to Aristocrats and Ramp.
|
||||
|
||||
## Known Issues
|
||||
- First run downloads card data (takes a few minutes)
|
||||
- Use `docker compose run --rm` (not `up`) for interactive sessions
|
||||
- Use `docker compose run --rm` (not `up`) for interactive CLI sessions
|
||||
- Ensure volumes are mounted to persist files outside the container
|
||||
|
||||
## Links
|
||||
|
|
|
@ -41,6 +41,21 @@ docker run -it --rm `
|
|||
mwisnowski/mtg-python-deckbuilder:latest
|
||||
```
|
||||
|
||||
### Optional: Web UI from Docker Hub
|
||||
Run the browser UI by mapping a port and starting uvicorn:
|
||||
```powershell
|
||||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
-v "${PWD}/csv_files:/app/csv_files" `
|
||||
-v "${PWD}/owned_cards:/app/owned_cards" `
|
||||
-v "${PWD}/config:/app/config" `
|
||||
mwisnowski/mtg-python-deckbuilder:latest `
|
||||
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
|
||||
```
|
||||
Then open http://localhost:8080
|
||||
|
||||
## Method 2: Command Prompt
|
||||
```cmd
|
||||
REM Create and navigate to workspace
|
||||
|
@ -151,3 +166,7 @@ C:\mtg-decks\
|
|||
├── deck_files\ # Your completed decks (.csv and .txt files)
|
||||
│ ├── Atraxa_Superfriends_20250821.csv
|
||||
│ ├── Atraxa_Superfriends_20250821.txt
|
||||
├── logs\\
|
||||
├── csv_files\\
|
||||
├── owned_cards\\
|
||||
└── config\\
|
||||
|
|
|
@ -309,6 +309,8 @@ class DeckBuilder(
|
|||
use_owned_only: bool = False
|
||||
owned_card_names: set[str] = field(default_factory=set)
|
||||
owned_files_selected: List[str] = field(default_factory=list)
|
||||
# Soft preference: bias selection toward owned names without excluding others
|
||||
prefer_owned: bool = False
|
||||
|
||||
# Deck library (cards added so far) mapping name->record
|
||||
card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
@ -986,6 +988,7 @@ class DeckBuilder(
|
|||
self.output_func("Owned-only mode: no recognizable name column to filter on; skipping filter.")
|
||||
except Exception as _e:
|
||||
self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}")
|
||||
# Soft prefer-owned does not filter the pool; biasing is applied later at selection time
|
||||
self._combined_cards_df = combined
|
||||
# Preserve original snapshot for enrichment across subsequent removals
|
||||
if self._full_cards_df is None:
|
||||
|
|
|
@ -158,6 +158,7 @@ __all__ = [
|
|||
'compute_spell_pip_weights',
|
||||
'parse_theme_tags',
|
||||
'normalize_theme_list',
|
||||
'prefer_owned_first',
|
||||
'compute_adjusted_target',
|
||||
'normalize_tag_cell',
|
||||
'sort_by_priority',
|
||||
|
@ -418,6 +419,32 @@ def sort_by_priority(df, columns: list[str]):
|
|||
return df.sort_values(by=present, ascending=[True]*len(present), na_position='last')
|
||||
|
||||
|
||||
def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
|
||||
"""Stable-reorder DataFrame to put owned names first while preserving prior sort.
|
||||
|
||||
- Adds a temporary column to flag ownership, sorts by it desc with mergesort, then drops it.
|
||||
- If the name column is missing or owned_names_lower empty, returns df unchanged.
|
||||
"""
|
||||
try:
|
||||
if df is None or getattr(df, 'empty', True):
|
||||
return df
|
||||
if not owned_names_lower:
|
||||
return df
|
||||
if name_col not in df.columns:
|
||||
return df
|
||||
tmp_col = '_ownedPref'
|
||||
# Avoid clobbering if already present
|
||||
while tmp_col in df.columns:
|
||||
tmp_col = tmp_col + '_x'
|
||||
ser = df[name_col].astype(str).str.lower().isin(owned_names_lower).astype(int)
|
||||
df = df.assign(**{tmp_col: ser})
|
||||
df = df.sort_values(by=[tmp_col], ascending=[False], kind='mergesort')
|
||||
df = df.drop(columns=[tmp_col])
|
||||
return df
|
||||
except Exception:
|
||||
return df
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag-driven land suggestion helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
@ -85,13 +85,74 @@ class CreatureAdditionMixin:
|
|||
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
|
||||
creature_df['_normTags'] = creature_df['_parsedThemeTags']
|
||||
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
|
||||
# In AND mode, prefer intersections: create a hard filter order 3 -> 2 -> 1 matches
|
||||
combine_mode = getattr(self, 'tag_mode', 'AND')
|
||||
base_top = 30
|
||||
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
|
||||
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
|
||||
total_added = 0
|
||||
added_names: List[str] = []
|
||||
# AND pre-pass: pick creatures that hit all selected themes first (if 2+ themes)
|
||||
all_theme_added: List[tuple[str, List[str]]] = []
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) >= 2:
|
||||
all_cnt = len(selected_tags_lower)
|
||||
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
|
||||
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
|
||||
remaining_capacity = max(0, desired_total - total_added)
|
||||
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
|
||||
if target_cap > 0:
|
||||
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
|
||||
subset_all = subset_all[~subset_all['name'].isin(added_names)]
|
||||
if not subset_all.empty:
|
||||
if 'edhrecRank' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||||
elif 'manaValue' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||||
# Bias owned names ahead before weighting
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
|
||||
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
weighted_pool = []
|
||||
for nm in subset_all['name'].tolist():
|
||||
w = weight_strong
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
w *= owned_mult
|
||||
weighted_pool.append((nm, w))
|
||||
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
|
||||
for nm in chosen_all:
|
||||
if commander_name and nm == commander_name:
|
||||
continue
|
||||
row = subset_all[subset_all['name'] == nm].iloc[0]
|
||||
# Which selected themes does this card hit?
|
||||
selected_display_tags = [t for _r, t in themes_ordered]
|
||||
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
|
||||
try:
|
||||
hits = [t for t in selected_display_tags if str(t).lower() in norm_tags]
|
||||
except Exception:
|
||||
hits = selected_display_tags
|
||||
self.add_card(
|
||||
nm,
|
||||
card_type=row.get('type','Creature'),
|
||||
mana_cost=row.get('manaCost',''),
|
||||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
role='creature',
|
||||
sub_role='all_theme',
|
||||
added_by='creature_all_theme',
|
||||
trigger_tag=", ".join(hits) if hits else None,
|
||||
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
|
||||
)
|
||||
added_names.append(nm)
|
||||
all_theme_added.append((nm, hits))
|
||||
total_added += 1
|
||||
if total_added >= desired_total:
|
||||
break
|
||||
self.output_func(f"All-Theme AND Pre-Pass: added {len(all_theme_added)} / {target_cap} (matching all {all_cnt} themes)")
|
||||
# Per-theme distribution
|
||||
per_theme_added: Dict[str, List[str]] = {r: [] for r,_t in themes_ordered}
|
||||
for role, tag in themes_ordered:
|
||||
w = weights.get(role, 0.0)
|
||||
|
@ -107,7 +168,6 @@ class CreatureAdditionMixin:
|
|||
tnorm = tag.lower()
|
||||
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||
# Constrain to multi-tag overlap first if available
|
||||
if (creature_df['_multiMatch'] >= 2).any():
|
||||
subset = subset[subset['_multiMatch'] >= 2]
|
||||
if subset.empty:
|
||||
|
@ -117,15 +177,30 @@ class CreatureAdditionMixin:
|
|||
subset = subset.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||
elif 'manaValue' in subset.columns:
|
||||
subset = subset.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
|
||||
pool = subset.head(top_n).copy()
|
||||
pool = pool[~pool['name'].isin(added_names)]
|
||||
if pool.empty:
|
||||
continue
|
||||
# In AND mode, boost weights more aggressively for 2+ tag matches
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
if combine_mode == 'AND':
|
||||
weighted_pool = [(nm, (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
|
||||
weighted_pool = []
|
||||
for nm, mm in zip(pool['name'], pool['_multiMatch']):
|
||||
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
else:
|
||||
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
|
||||
weighted_pool = []
|
||||
for nm, mm in zip(pool['name'], pool['_multiMatch']):
|
||||
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||||
for nm in chosen:
|
||||
if commander_name and nm == commander_name:
|
||||
|
@ -152,11 +227,11 @@ class CreatureAdditionMixin:
|
|||
self.output_func(f"Added {len(per_theme_added[role])} creatures for {role} theme '{tag}' (target {target}).")
|
||||
if total_added >= desired_total:
|
||||
break
|
||||
# Fill remaining if still short
|
||||
if total_added < desired_total:
|
||||
need = desired_total - total_added
|
||||
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
|
||||
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
|
||||
# First prefer 3+ then 2, finally 1
|
||||
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
|
||||
if prioritized.empty:
|
||||
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
|
||||
|
@ -168,6 +243,10 @@ class CreatureAdditionMixin:
|
|||
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')
|
||||
elif 'manaValue' in multi_pool.columns:
|
||||
multi_pool = multi_pool.sort_values(by=['_multiMatch','manaValue'], ascending=[False, True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
|
||||
fill = multi_pool['name'].tolist()[:need]
|
||||
for nm in fill:
|
||||
if commander_name and nm == commander_name:
|
||||
|
@ -190,7 +269,15 @@ class CreatureAdditionMixin:
|
|||
if total_added >= desired_total:
|
||||
break
|
||||
self.output_func(f"Fill pass added {min(need, len(fill))} extra creatures (shortfall compensation).")
|
||||
# Summary output
|
||||
self.output_func("\nCreatures Added:")
|
||||
if all_theme_added:
|
||||
self.output_func(f" All-Theme overlap: {len(all_theme_added)}")
|
||||
for nm, hits in all_theme_added:
|
||||
if hits:
|
||||
self.output_func(f" - {nm} (tags: {', '.join(hits)})")
|
||||
else:
|
||||
self.output_func(f" - {nm}")
|
||||
for role, tag in themes_ordered:
|
||||
lst = per_theme_added.get(role, [])
|
||||
if lst:
|
||||
|
@ -401,3 +488,73 @@ class CreatureAdditionMixin:
|
|||
|
||||
def add_creatures_fill_phase(self):
|
||||
return self._add_creatures_fill()
|
||||
|
||||
def add_creatures_all_theme_phase(self):
|
||||
"""Staged pre-pass: when AND mode and 2+ tags, add creatures matching all selected themes first."""
|
||||
combine_mode = getattr(self, 'tag_mode', 'AND')
|
||||
tags = [t for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)] if t]
|
||||
if combine_mode != 'AND' or len(tags) < 2:
|
||||
return
|
||||
desired_total = (self.ideal_counts.get('creatures') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_CREATURE_COUNT', 25)
|
||||
current_added = self._creature_count_in_library()
|
||||
remaining_capacity = max(0, desired_total - current_added)
|
||||
if remaining_capacity <= 0:
|
||||
return
|
||||
creature_df = self._prepare_creature_pool()
|
||||
if creature_df is None or creature_df.empty:
|
||||
return
|
||||
all_cnt = len(tags)
|
||||
pre_cap_ratio = getattr(bc, 'AND_ALL_THEME_CAP_RATIO', 0.6)
|
||||
hard_cap = max(0, int(math.floor(desired_total * float(pre_cap_ratio))))
|
||||
target_cap = min(hard_cap if hard_cap > 0 else remaining_capacity, remaining_capacity)
|
||||
subset_all = creature_df[creature_df['_multiMatch'] >= all_cnt].copy()
|
||||
existing_names = set(getattr(self, 'card_library', {}).keys())
|
||||
subset_all = subset_all[~subset_all['name'].isin(existing_names)]
|
||||
if subset_all.empty or target_cap <= 0:
|
||||
return
|
||||
if 'edhrecRank' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||||
elif 'manaValue' in subset_all.columns:
|
||||
subset_all = subset_all.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset_all = bu.prefer_owned_first(subset_all, {str(n).lower() for n in owned_set})
|
||||
weight_strong = getattr(bc, 'AND_ALL_THEME_WEIGHT', 1.7)
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
weighted_pool = []
|
||||
for nm in subset_all['name'].tolist():
|
||||
w = weight_strong
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
w *= owned_mult
|
||||
weighted_pool.append((nm, w))
|
||||
chosen_all = bu.weighted_sample_without_replacement(weighted_pool, target_cap)
|
||||
added = 0
|
||||
for nm in chosen_all:
|
||||
row = subset_all[subset_all['name'] == nm].iloc[0]
|
||||
# Determine which selected themes this card hits for display
|
||||
norm_tags = row.get('_normTags', []) if isinstance(row.get('_normTags', []), list) else []
|
||||
hits: List[str] = []
|
||||
try:
|
||||
hits = [t for t in tags if str(t).lower() in norm_tags]
|
||||
except Exception:
|
||||
hits = list(tags)
|
||||
self.add_card(
|
||||
nm,
|
||||
card_type=row.get('type','Creature'),
|
||||
mana_cost=row.get('manaCost',''),
|
||||
mana_value=row.get('manaValue', row.get('cmc','')),
|
||||
creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [],
|
||||
tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [],
|
||||
role='creature',
|
||||
sub_role='all_theme',
|
||||
added_by='creature_all_theme',
|
||||
trigger_tag=", ".join(hits) if hits else None,
|
||||
synergy=int(row.get('_multiMatch', all_cnt)) if '_multiMatch' in row else all_cnt
|
||||
)
|
||||
added += 1
|
||||
if added >= target_cap:
|
||||
break
|
||||
if added:
|
||||
self.output_func(f"All-Theme AND Pre-Pass: added {added}/{target_cap} creatures (matching all {all_cnt} themes)")
|
||||
|
|
|
@ -57,6 +57,12 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
work = work[work['name'] != commander_name]
|
||||
work = bu.sort_by_priority(work, ['edhrecRank','manaValue'])
|
||||
# Prefer-owned bias: stable reorder to put owned first while preserving prior sort
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
owned_lower = {str(n).lower() for n in owned_set}
|
||||
work = bu.prefer_owned_first(work, owned_lower)
|
||||
|
||||
rocks_target = min(target_total, math.ceil(target_total/3))
|
||||
dorks_target = min(target_total - rocks_target, math.ceil(target_total/4))
|
||||
|
@ -143,6 +149,10 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
pool = pool[pool['name'] != commander_name]
|
||||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
||||
existing = 0
|
||||
for name, entry in self.card_library.items():
|
||||
lt = [str(t).lower() for t in entry.get('Tags', [])]
|
||||
|
@ -201,6 +211,10 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
pool = pool[pool['name'] != commander_name]
|
||||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
||||
existing = 0
|
||||
for name, entry in self.card_library.items():
|
||||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||||
|
@ -277,6 +291,12 @@ class SpellAdditionMixin:
|
|||
return bu.sort_by_priority(d, ['edhrecRank','manaValue'])
|
||||
conditional_df = sortit(conditional_df)
|
||||
unconditional_df = sortit(unconditional_df)
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
owned_lower = {str(n).lower() for n in owned_set}
|
||||
conditional_df = bu.prefer_owned_first(conditional_df, owned_lower)
|
||||
unconditional_df = bu.prefer_owned_first(unconditional_df, owned_lower)
|
||||
added_cond = 0
|
||||
added_cond_names: List[str] = []
|
||||
for _, r in conditional_df.iterrows():
|
||||
|
@ -349,6 +369,10 @@ class SpellAdditionMixin:
|
|||
if commander_name:
|
||||
pool = pool[pool['name'] != commander_name]
|
||||
pool = bu.sort_by_priority(pool, ['edhrecRank','manaValue'])
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in owned_set})
|
||||
existing = 0
|
||||
for name, entry in self.card_library.items():
|
||||
tags = [str(t).lower() for t in entry.get('Tags', [])]
|
||||
|
@ -491,14 +515,32 @@ class SpellAdditionMixin:
|
|||
ascending=[False, True],
|
||||
na_position='last',
|
||||
)
|
||||
# Prefer-owned: stable reorder before trimming to top_n
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
|
||||
pool = subset.head(top_n).copy()
|
||||
pool = pool[~pool['name'].isin(self.card_library.keys())]
|
||||
if pool.empty:
|
||||
continue
|
||||
# Build weighted pool with optional owned multiplier
|
||||
owned_lower = {str(n).lower() for n in getattr(self, 'owned_card_names', set())} if getattr(self, 'prefer_owned', False) else set()
|
||||
owned_mult = getattr(bc, 'PREFER_OWNED_WEIGHT_MULTIPLIER', 1.25)
|
||||
base_pairs = list(zip(pool['name'], pool['_multiMatch']))
|
||||
weighted_pool: list[tuple[str, float]] = []
|
||||
if combine_mode == 'AND':
|
||||
weighted_pool = [ (nm, (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
|
||||
for nm, mm in base_pairs:
|
||||
base_w = (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
else:
|
||||
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
|
||||
for nm, mm in base_pairs:
|
||||
base_w = (synergy_bonus if mm >= 2 else 1.0)
|
||||
if owned_lower and str(nm).lower() in owned_lower:
|
||||
base_w *= owned_mult
|
||||
weighted_pool.append((nm, base_w))
|
||||
chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
|
||||
for nm in chosen:
|
||||
row = pool[pool['name'] == nm].iloc[0]
|
||||
|
@ -541,6 +583,10 @@ class SpellAdditionMixin:
|
|||
ascending=[False, True],
|
||||
na_position='last',
|
||||
)
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
multi_pool = bu.prefer_owned_first(multi_pool, {str(n).lower() for n in owned_set})
|
||||
fill = multi_pool['name'].tolist()[:need]
|
||||
for nm in fill:
|
||||
row = multi_pool[multi_pool['name'] == nm].iloc[0]
|
||||
|
@ -598,6 +644,10 @@ class SpellAdditionMixin:
|
|||
subset = subset.sort_values(by=['edhrecRank','manaValue'], ascending=[True, True], na_position='last')
|
||||
elif 'manaValue' in subset.columns:
|
||||
subset = subset.sort_values(by=['manaValue'], ascending=[True], na_position='last')
|
||||
if getattr(self, 'prefer_owned', False):
|
||||
owned_set = getattr(self, 'owned_card_names', None)
|
||||
if owned_set:
|
||||
subset = bu.prefer_owned_first(subset, {str(n).lower() for n in owned_set})
|
||||
row = subset.head(1)
|
||||
if row.empty:
|
||||
break
|
||||
|
|
|
@ -90,10 +90,12 @@ from .routes import build as build_routes # noqa: E402
|
|||
from .routes import configs as config_routes # noqa: E402
|
||||
from .routes import decks as decks_routes # noqa: E402
|
||||
from .routes import setup as setup_routes # noqa: E402
|
||||
from .routes import owned as owned_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
app.include_router(setup_routes.router)
|
||||
app.include_router(owned_routes.router)
|
||||
|
||||
# Lightweight file download endpoint for exports
|
||||
@app.get("/files")
|
||||
|
|
|
@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse
|
|||
from ..app import templates
|
||||
from deck_builder import builder_constants as bc
|
||||
from ..services import orchestrator as orch
|
||||
from ..services import owned_store
|
||||
from ..services.tasks import get_session, new_sid
|
||||
|
||||
router = APIRouter(prefix="/build")
|
||||
|
@ -286,10 +287,44 @@ async def build_step4_get(request: Request) -> HTMLResponse:
|
|||
"labels": labels,
|
||||
"values": values,
|
||||
"commander": commander,
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/toggle-owned-review", response_class=HTMLResponse)
|
||||
async def build_toggle_owned_review(
|
||||
request: Request,
|
||||
use_owned_only: str | None = Form(None),
|
||||
prefer_owned: str | None = Form(None),
|
||||
) -> HTMLResponse:
|
||||
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
|
||||
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
|
||||
sess["use_owned_only"] = only_val
|
||||
sess["prefer_owned"] = pref_val
|
||||
# Do not touch build_ctx here; user hasn't started the build yet from review
|
||||
labels = orch.ideal_labels()
|
||||
values = sess.get("ideals") or orch.ideal_defaults()
|
||||
commander = sess.get("commander")
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step4.html",
|
||||
{
|
||||
"request": request,
|
||||
"labels": labels,
|
||||
"values": values,
|
||||
"commander": commander,
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/step5", response_class=HTMLResponse)
|
||||
async def build_step5_get(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
@ -302,6 +337,9 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
|||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"status": None,
|
||||
"stage_label": None,
|
||||
"log": None,
|
||||
|
@ -331,14 +369,27 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
except Exception:
|
||||
safe_bracket = int(default_bracket)
|
||||
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||
# Owned-only integration for staged builds
|
||||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names = owned_store.get_names() if (use_owned or prefer) else None
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names,
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False)
|
||||
show_skipped = True if (request.query_params.get('show_skipped') == '1' or (await request.form().get('show_skipped', None) == '1') if hasattr(request, 'form') else False) else False
|
||||
try:
|
||||
form = await request.form()
|
||||
show_skipped = True if (form.get('show_skipped') == '1') else show_skipped
|
||||
except Exception:
|
||||
pass
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
||||
status = "Build complete" if res.get("done") else "Stage complete"
|
||||
stage_label = res.get("label")
|
||||
log = res.get("log_delta", "")
|
||||
|
@ -357,6 +408,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"status": status,
|
||||
"stage_label": stage_label,
|
||||
"log": log,
|
||||
|
@ -367,6 +421,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
"txt_path": txt_path,
|
||||
"summary": summary,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"show_skipped": show_skipped,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -390,14 +445,26 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
except Exception:
|
||||
safe_bracket = int(default_bracket)
|
||||
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names = owned_store.get_names() if (use_owned or prefer) else None
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=sess.get("commander"),
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names,
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=True)
|
||||
show_skipped = False
|
||||
try:
|
||||
form = await request.form()
|
||||
show_skipped = True if (form.get('show_skipped') == '1') else False
|
||||
except Exception:
|
||||
pass
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped)
|
||||
status = "Stage rerun complete" if not res.get("done") else "Build complete"
|
||||
stage_label = res.get("label")
|
||||
log = res.get("log_delta", "")
|
||||
|
@ -415,6 +482,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"status": status,
|
||||
"stage_label": stage_label,
|
||||
"log": log,
|
||||
|
@ -425,6 +495,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
"txt_path": txt_path,
|
||||
"summary": summary,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"show_skipped": show_skipped,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -454,14 +525,26 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
except Exception:
|
||||
safe_bracket = int(default_bracket)
|
||||
ideals_val = sess.get("ideals") or orch.ideal_defaults()
|
||||
use_owned = bool(sess.get("use_owned_only"))
|
||||
prefer = bool(sess.get("prefer_owned"))
|
||||
owned_names = owned_store.get_names() if (use_owned or prefer) else None
|
||||
sess["build_ctx"] = orch.start_build_ctx(
|
||||
commander=commander,
|
||||
tags=sess.get("tags", []),
|
||||
bracket=safe_bracket,
|
||||
ideals=ideals_val,
|
||||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names,
|
||||
)
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False)
|
||||
show_skipped = False
|
||||
try:
|
||||
form = await request.form()
|
||||
show_skipped = True if (form.get('show_skipped') == '1') else False
|
||||
except Exception:
|
||||
pass
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
||||
status = "Stage complete" if not res.get("done") else "Build complete"
|
||||
stage_label = res.get("label")
|
||||
log = res.get("log_delta", "")
|
||||
|
@ -479,6 +562,9 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"prefer_owned": bool(sess.get("prefer_owned")),
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"status": status,
|
||||
"stage_label": stage_label,
|
||||
"log": log,
|
||||
|
@ -489,6 +575,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
"txt_path": txt_path,
|
||||
"summary": summary,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"show_skipped": show_skipped,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
@ -503,6 +590,8 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
"tags": sess.get("tags", []),
|
||||
"bracket": sess.get("bracket"),
|
||||
"values": sess.get("ideals", orch.ideal_defaults()),
|
||||
"owned_only": bool(sess.get("use_owned_only")),
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"status": "Error",
|
||||
"stage_label": None,
|
||||
"log": f"Failed to start build: {e}",
|
||||
|
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
|||
import os
|
||||
import json
|
||||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
from ..services import orchestrator as orch
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
@ -92,7 +93,7 @@ async def configs_view(request: Request, name: str) -> HTMLResponse:
|
|||
|
||||
|
||||
@router.post("/run", response_class=HTMLResponse)
|
||||
async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
async def configs_run(request: Request, name: str = Form(...), use_owned_only: str | None = Form(None)) -> HTMLResponse:
|
||||
base = _config_dir()
|
||||
p = (base / name).resolve()
|
||||
try:
|
||||
|
@ -125,8 +126,33 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
|||
except Exception:
|
||||
tag_mode = "AND"
|
||||
|
||||
# Optional owned-only for headless runs via JSON flag or form override
|
||||
owned_flag = False
|
||||
try:
|
||||
uo = cfg.get("use_owned_only")
|
||||
if isinstance(uo, bool):
|
||||
owned_flag = uo
|
||||
elif isinstance(uo, str):
|
||||
owned_flag = uo.strip().lower() in ("1","true","yes","on")
|
||||
except Exception:
|
||||
owned_flag = False
|
||||
|
||||
# Form override takes precedence if provided
|
||||
if use_owned_only is not None:
|
||||
owned_flag = str(use_owned_only).strip().lower() in ("1","true","yes","on")
|
||||
|
||||
owned_names = owned_store.get_names() if owned_flag else None
|
||||
|
||||
# Run build headlessly with orchestrator
|
||||
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals, tag_mode=tag_mode)
|
||||
res = orch.run_build(
|
||||
commander=commander,
|
||||
tags=tags,
|
||||
bracket=bracket,
|
||||
ideals=ideals,
|
||||
tag_mode=tag_mode,
|
||||
use_owned_only=owned_flag,
|
||||
owned_names=owned_names,
|
||||
)
|
||||
if not res.get("ok"):
|
||||
return templates.TemplateResponse(
|
||||
"configs/run_result.html",
|
||||
|
@ -138,6 +164,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
|||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
"tag_mode": tag_mode,
|
||||
"use_owned_only": owned_flag,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
|
@ -152,6 +180,8 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
|
|||
"cfg_name": p.name,
|
||||
"commander": commander,
|
||||
"tag_mode": tag_mode,
|
||||
"use_owned_only": owned_flag,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
},
|
||||
)
|
||||
|
|
|
@ -8,6 +8,7 @@ import os
|
|||
from typing import Dict, List, Tuple
|
||||
|
||||
from ..app import templates
|
||||
from ..services import owned_store
|
||||
from deck_builder import builder_constants as bc
|
||||
|
||||
|
||||
|
@ -263,5 +264,6 @@ async def decks_view(request: Request, name: str) -> HTMLResponse:
|
|||
"commander": commander_name,
|
||||
"tags": tags,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"owned_set": {n.lower() for n in owned_store.get_names()},
|
||||
}
|
||||
return templates.TemplateResponse("decks/view.html", ctx)
|
||||
|
|
179
code/web/routes/owned.py
Normal file
179
code/web/routes/owned.py
Normal 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"},
|
||||
)
|
|
@ -631,7 +631,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
|
|||
_write_status({"running": False, "phase": "error", "message": "Setup check failed"})
|
||||
|
||||
|
||||
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None) -> Dict[str, Any]:
|
||||
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None) -> Dict[str, Any]:
|
||||
"""Run the deck build end-to-end with provided selections and capture logs.
|
||||
|
||||
Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] }
|
||||
|
@ -686,6 +686,27 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Owned/Prefer-owned integration (optional for headless runs)
|
||||
try:
|
||||
if use_owned_only:
|
||||
b.use_owned_only = True # type: ignore[attr-defined]
|
||||
# Prefer explicit owned_names list if provided; else let builder discover from files
|
||||
if owned_names:
|
||||
try:
|
||||
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
b.owned_card_names = set() # type: ignore[attr-defined]
|
||||
# Soft preference flag does not filter; only biases selection order
|
||||
if prefer_owned:
|
||||
try:
|
||||
b.prefer_owned = True # type: ignore[attr-defined]
|
||||
if owned_names and not getattr(b, 'owned_card_names', None):
|
||||
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load data and run phases
|
||||
try:
|
||||
b.determine_color_identity()
|
||||
|
@ -784,6 +805,14 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
if callable(fn):
|
||||
stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"})
|
||||
# Creatures split into theme sub-stages for web confirm
|
||||
# AND-mode pre-pass: add cards that match ALL selected themes first
|
||||
try:
|
||||
combine_mode = getattr(b, 'tag_mode', 'AND')
|
||||
except Exception:
|
||||
combine_mode = 'AND'
|
||||
has_two_tags = bool(getattr(b, 'primary_tag', None) and getattr(b, 'secondary_tag', None))
|
||||
if combine_mode == 'AND' and has_two_tags and hasattr(b, 'add_creatures_all_theme_phase'):
|
||||
stages.append({"key": "creatures_all_theme", "label": "Creatures: All-Theme", "runner_name": "add_creatures_all_theme_phase"})
|
||||
if getattr(b, 'primary_tag', None) and hasattr(b, 'add_creatures_primary_phase'):
|
||||
stages.append({"key": "creatures_primary", "label": "Creatures: Primary", "runner_name": "add_creatures_primary_phase"})
|
||||
if getattr(b, 'secondary_tag', None) and hasattr(b, 'add_creatures_secondary_phase'):
|
||||
|
@ -822,7 +851,17 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
|
|||
return stages
|
||||
|
||||
|
||||
def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None) -> Dict[str, Any]:
|
||||
def start_build_ctx(
|
||||
commander: str,
|
||||
tags: List[str],
|
||||
bracket: int,
|
||||
ideals: Dict[str, int],
|
||||
tag_mode: str | None = None,
|
||||
*,
|
||||
use_owned_only: bool | None = None,
|
||||
prefer_owned: bool | None = None,
|
||||
owned_names: List[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
def out(msg: str) -> None:
|
||||
|
@ -865,6 +904,25 @@ def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Owned-only / prefer-owned (if requested)
|
||||
try:
|
||||
if use_owned_only:
|
||||
b.use_owned_only = True # type: ignore[attr-defined]
|
||||
if owned_names:
|
||||
try:
|
||||
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
b.owned_card_names = set() # type: ignore[attr-defined]
|
||||
if prefer_owned:
|
||||
try:
|
||||
b.prefer_owned = True # type: ignore[attr-defined]
|
||||
if owned_names and not getattr(b, 'owned_card_names', None):
|
||||
b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Data load
|
||||
b.determine_color_identity()
|
||||
b.setup_dataframes()
|
||||
|
@ -926,7 +984,7 @@ def _restore_builder(b: DeckBuilder, snap: Dict[str, Any]) -> None:
|
|||
b._spell_pip_cache_dirty = bool(snap.get("_spell_pip_cache_dirty", True))
|
||||
|
||||
|
||||
def run_stage(ctx: Dict[str, Any], rerun: bool = False) -> Dict[str, Any]:
|
||||
def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False) -> Dict[str, Any]:
|
||||
b: DeckBuilder = ctx["builder"]
|
||||
stages: List[Dict[str, Any]] = ctx["stages"]
|
||||
logs: List[str] = ctx["logs"]
|
||||
|
@ -1071,7 +1129,22 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False) -> Dict[str, Any]:
|
|||
"total": len(stages),
|
||||
}
|
||||
|
||||
# No cards added: skip showing this stage and advance to next
|
||||
# No cards added: either skip or surface as a 'skipped' stage
|
||||
if show_skipped:
|
||||
ctx["snapshot"] = snap_before
|
||||
ctx["idx"] = i + 1
|
||||
ctx["last_visible_idx"] = i + 1
|
||||
return {
|
||||
"done": False,
|
||||
"label": label,
|
||||
"log_delta": delta_log,
|
||||
"added_cards": [],
|
||||
"skipped": True,
|
||||
"idx": i + 1,
|
||||
"total": len(stages),
|
||||
}
|
||||
|
||||
# No cards added and not showing skipped: advance to next
|
||||
i += 1
|
||||
# Continue loop to auto-advance
|
||||
|
||||
|
|
424
code/web/services/owned_store.py
Normal file
424
code/web/services/owned_store.py
Normal 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
|
|
@ -61,6 +61,10 @@ body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text);
|
|||
/* Buttons, inputs */
|
||||
button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; }
|
||||
button:hover{ filter:brightness(1.05); }
|
||||
/* Anchor-style buttons */
|
||||
.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; }
|
||||
.btn:hover{ filter:brightness(1.05); text-decoration:none; }
|
||||
.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
|
||||
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
|
||||
select,input[type="text"],input[type="number"]{ background:#0f1115; color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
|
||||
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
|
||||
|
@ -87,6 +91,7 @@ small, .muted{ color: var(--muted); }
|
|||
}
|
||||
.card-tile{
|
||||
width:170px;
|
||||
position: relative;
|
||||
background:#0f1115;
|
||||
border:1px solid var(--border);
|
||||
border-radius:6px;
|
||||
|
@ -98,6 +103,25 @@ small, .muted{ color: var(--muted); }
|
|||
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
|
||||
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
|
||||
|
||||
/* Shared ownership badge for card tiles and stacked images */
|
||||
.owned-badge{
|
||||
position:absolute;
|
||||
top:6px;
|
||||
left:6px;
|
||||
background:rgba(17,24,39,.9);
|
||||
color:#e5e7eb;
|
||||
border:1px solid var(--border);
|
||||
border-radius:12px;
|
||||
font-size:12px;
|
||||
line-height:18px;
|
||||
height:18px;
|
||||
min-width:18px;
|
||||
padding:0 6px;
|
||||
text-align:center;
|
||||
pointer-events:none;
|
||||
z-index:2;
|
||||
}
|
||||
|
||||
/* Step 1 candidate grid (200px-wide scaled images) */
|
||||
.candidate-grid{
|
||||
display:grid;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MTG Deckbuilder</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-1" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-3" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-banner">
|
||||
|
@ -30,6 +30,7 @@
|
|||
<a href="/build">Build</a>
|
||||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
<a href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
|
|
|
@ -14,6 +14,17 @@
|
|||
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form hx-post="/build/toggle-owned-review" hx-target="#wizard" hx-swap="innerHTML" style="margin:.5rem 0; display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="use_owned_only" value="1" {% if owned_only %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Use only owned cards
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Prefer owned cards (allow unowned fallback)
|
||||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a>
|
||||
</form>
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Build Deck</button>
|
||||
|
|
|
@ -27,6 +27,14 @@
|
|||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||||
</div>
|
||||
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a></span>
|
||||
</div>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
{% if i and n %}
|
||||
|
@ -41,20 +49,36 @@
|
|||
|
||||
<!-- Controls moved back above the cards as requested -->
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem;">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit">Start Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
|
||||
<input type="checkbox" name="__toggle_show_skipped" {% if show_skipped %}checked{% endif %}
|
||||
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
|
||||
Show skipped stages
|
||||
</label>
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
|
||||
{% if added_cards %}
|
||||
{% if added_cards is not none %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if skipped and (not added_cards or added_cards|length == 0) %}
|
||||
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✔</span> Owned</span>
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✖</span> Not owned</span>
|
||||
</div>
|
||||
|
||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
|
@ -67,10 +91,12 @@
|
|||
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
|
||||
<div class="card-grid">
|
||||
{% for c in g.list %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
|
||||
</a>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
|
||||
</div>
|
||||
|
@ -80,10 +106,12 @@
|
|||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% for c in added_cards %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
|
||||
</a>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<h2>Build from JSON: {{ cfg_name }}</h2>
|
||||
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
|
||||
{% if commander %}
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}</div>
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}{% if use_owned_only %} · Owned-only{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col two-col-left-rail">
|
||||
|
|
|
@ -15,8 +15,12 @@
|
|||
<summary>Ideal Counts</summary>
|
||||
<pre style="background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px;">{{ data.ideal_counts | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
<form method="post" action="/configs/run" style="margin-top:1rem;">
|
||||
<form method="post" action="/configs/run" style="margin-top:1rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="use_owned_only" value="1" />
|
||||
Use only owned cards
|
||||
</label>
|
||||
<button type="submit">Run Headless</button>
|
||||
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
|
||||
</form>
|
||||
|
|
|
@ -16,18 +16,20 @@
|
|||
<option value="name-asc">Commander A–Z</option>
|
||||
<option value="name-desc">Commander Z–A</option>
|
||||
</select>
|
||||
<select id="deck-theme" aria-label="Theme">
|
||||
<option value="">All Themes</option>
|
||||
</select>
|
||||
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
|
||||
<input type="checkbox" id="deck-txt-only" /> TXT only
|
||||
</label>
|
||||
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
||||
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
|
||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and tags">Reset all</button>
|
||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">Reset all</button>
|
||||
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
|
||||
<span id="deck-count" class="muted" aria-live="polite"></span>
|
||||
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
|
||||
</div>
|
||||
<div id="tag-label" class="muted" style="font-size:12px; margin:.15rem 0 .25rem 0;">Theme filters</div>
|
||||
<div id="tag-chips" aria-labelledby="tag-label" style="display:flex; gap:.25rem; flex-wrap:wrap; margin:.25rem 0 .75rem 0;"></div>
|
||||
|
||||
|
||||
{% if items %}
|
||||
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
|
@ -82,7 +84,7 @@
|
|||
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
|
||||
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
|
||||
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
|
||||
<li><kbd>R</kbd> resets all filters, sort, and tags</li>
|
||||
<li><kbd>R</kbd> resets all filters, sort, and theme</li>
|
||||
<li>Use “TXT only” to show only decks that have a TXT export</li>
|
||||
<li>Share copies a link with your current filters</li>
|
||||
</ul>
|
||||
|
@ -97,9 +99,9 @@
|
|||
(function(){
|
||||
var input = document.getElementById('deck-filter');
|
||||
var sortSel = document.getElementById('deck-sort');
|
||||
var themeSel = document.getElementById('deck-theme');
|
||||
var clearBtn = document.getElementById('deck-clear');
|
||||
var list = document.getElementById('deck-list');
|
||||
var chips = document.getElementById('tag-chips');
|
||||
var countEl = document.getElementById('deck-count');
|
||||
var shareBtn = document.getElementById('deck-share');
|
||||
var resetAllBtn = document.getElementById('deck-reset-all');
|
||||
|
@ -112,15 +114,30 @@
|
|||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
||||
if (!list) return;
|
||||
|
||||
// Build tag chips from data-tags-pipe
|
||||
var tagSet = new Set();
|
||||
// Panels and themes discovery from data-tags-pipe
|
||||
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
raw.split('|').forEach(function(t){ if (t && t.trim()) tagSet.add(t.trim()); });
|
||||
var themeSet = new Set();
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
raw.split('|').forEach(function(t){ t = (t||'').trim(); if (t) themeSet.add(t); });
|
||||
});
|
||||
var activeTags = new Set();
|
||||
// Populate theme dropdown
|
||||
if (themeSel) {
|
||||
// Preserve current selection if any
|
||||
var prev = themeSel.value || '';
|
||||
// Reset to default option
|
||||
themeSel.innerHTML = '<option value="">All Themes</option>';
|
||||
Array.from(themeSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
|
||||
var opt = document.createElement('option');
|
||||
opt.value = t; opt.textContent = t; themeSel.appendChild(opt);
|
||||
});
|
||||
if (prev) {
|
||||
// Re-apply previous selection if it exists
|
||||
var has = Array.prototype.some.call(themeSel.options, function(o){ return o.value === prev; });
|
||||
if (has) themeSel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// URL hash <-> state sync helpers
|
||||
function parseHash(){
|
||||
|
@ -130,22 +147,24 @@
|
|||
var qp = new URLSearchParams(h);
|
||||
var q = qp.get('q') || '';
|
||||
var sort = qp.get('sort') || '';
|
||||
var tag = qp.get('tag') || '';
|
||||
var tagsStr = qp.get('tags') || '';
|
||||
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
|
||||
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
|
||||
if (!tag && tags.length) { tag = tags[0]; }
|
||||
var txt = qp.get('txt');
|
||||
var txtOnly = (txt === '1' || txt === 'true');
|
||||
return { q: q, sort: sort, tags: tags, txt: txtOnly };
|
||||
return { q: q, sort: sort, tag: tag, txt: txtOnly };
|
||||
} catch(_) { return null; }
|
||||
}
|
||||
function updateHashFromState(){
|
||||
try {
|
||||
var q = (input && input.value) ? input.value.trim() : '';
|
||||
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
|
||||
var tags = Array.from(activeTags);
|
||||
var tag = (themeSel && themeSel.value) ? themeSel.value : '';
|
||||
var qp = new URLSearchParams();
|
||||
if (q) qp.set('q', q);
|
||||
if (sort && sort !== 'newest') qp.set('sort', sort);
|
||||
if (tags.length) qp.set('tags', tags.map(function(s){ return encodeURIComponent(s); }).join(','));
|
||||
if (tag) qp.set('tag', tag);
|
||||
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
|
||||
var newHash = qp.toString();
|
||||
var base = location.pathname + location.search;
|
||||
|
@ -161,45 +180,16 @@
|
|||
var changed = false;
|
||||
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
|
||||
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
|
||||
if (Array.isArray(s.tags)) { activeTags = new Set(s.tags); changed = true; }
|
||||
if (typeof s.tag === 'string' && themeSel) {
|
||||
// If the tag isn't present in options, add it for back-compat
|
||||
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === s.tag; });
|
||||
if (s.tag && !exists) { var opt = document.createElement('option'); opt.value = s.tag; opt.textContent = s.tag; themeSel.appendChild(opt); }
|
||||
themeSel.value = s.tag; changed = true;
|
||||
}
|
||||
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
|
||||
renderChips();
|
||||
applyAll();
|
||||
return changed;
|
||||
}
|
||||
function renderChips(){
|
||||
if (!chips) return;
|
||||
chips.innerHTML = '';
|
||||
Array.from(tagSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip chip-filter' + (activeTags.has(t) ? ' active' : '');
|
||||
btn.textContent = t;
|
||||
btn.setAttribute('aria-pressed', activeTags.has(t) ? 'true' : 'false');
|
||||
btn.addEventListener('click', function(){
|
||||
if (activeTags.has(t)) activeTags.delete(t); else activeTags.add(t);
|
||||
renderChips();
|
||||
applyAll();
|
||||
});
|
||||
chips.appendChild(btn);
|
||||
});
|
||||
// Reset tags control appears only when any tags are active
|
||||
if (activeTags.size > 0) {
|
||||
var reset = document.createElement('button');
|
||||
reset.type = 'button';
|
||||
reset.id = 'reset-tags';
|
||||
reset.className = 'chip';
|
||||
reset.textContent = 'Reset tags';
|
||||
reset.title = 'Clear selected theme tags';
|
||||
reset.addEventListener('click', function(){
|
||||
activeTags.clear();
|
||||
renderChips();
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Theme tags cleared';
|
||||
});
|
||||
chips.appendChild(reset);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCount(){
|
||||
if (!countEl) return;
|
||||
|
@ -218,13 +208,13 @@
|
|||
|
||||
function applyFilter(){
|
||||
var q = (input && input.value || '').toLowerCase();
|
||||
var selTag = (themeSel && themeSel.value) ? themeSel.value : '';
|
||||
panels.forEach(function(row){
|
||||
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||
var textMatch = hay.indexOf(q) >= 0;
|
||||
var tagsPipe = row.dataset.tagsPipe || '';
|
||||
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
|
||||
var tagMatch = true;
|
||||
activeTags.forEach(function(t){ if (tags.indexOf(t) === -1) tagMatch = false; });
|
||||
var tagMatch = selTag ? (tags.indexOf(selTag) !== -1) : true;
|
||||
var txtOk = true;
|
||||
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
|
||||
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
|
||||
|
@ -312,7 +302,7 @@
|
|||
try {
|
||||
if (input) localStorage.setItem('decks-filter', input.value || '');
|
||||
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
|
||||
localStorage.setItem('decks-tags', JSON.stringify(Array.from(activeTags)));
|
||||
if (themeSel) localStorage.setItem('decks-theme', themeSel.value || '');
|
||||
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
|
||||
} catch(_){ }
|
||||
// Update URL hash for shareable state
|
||||
|
@ -332,13 +322,13 @@
|
|||
var debouncedApply = debounce(applyAll, 150);
|
||||
if (input) input.addEventListener('input', debouncedApply);
|
||||
if (sortSel) sortSel.addEventListener('change', applyAll);
|
||||
if (themeSel) themeSel.addEventListener('change', applyAll);
|
||||
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
|
||||
if (clearBtn) clearBtn.addEventListener('click', function(){
|
||||
if (input) input.value = '';
|
||||
activeTags.clear();
|
||||
if (themeSel) themeSel.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
renderChips();
|
||||
applyAll();
|
||||
});
|
||||
|
||||
|
@ -348,19 +338,18 @@
|
|||
if (input) input.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
activeTags.clear();
|
||||
renderChips();
|
||||
if (themeSel) themeSel.value = '';
|
||||
// Clear persistence
|
||||
localStorage.removeItem('decks-filter');
|
||||
localStorage.removeItem('decks-sort');
|
||||
localStorage.removeItem('decks-tags');
|
||||
localStorage.removeItem('decks-theme');
|
||||
localStorage.removeItem('decks-txt');
|
||||
// Clear URL hash
|
||||
var base = location.pathname + location.search;
|
||||
history.replaceState(null, '', base);
|
||||
} catch(_){ }
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Filters, sort, and tags reset';
|
||||
if (liveEl) liveEl.textContent = 'Filters, sort, and theme reset';
|
||||
});
|
||||
|
||||
if (shareBtn) shareBtn.addEventListener('click', function(){
|
||||
|
@ -385,7 +374,6 @@
|
|||
var hadHash = false;
|
||||
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
|
||||
if (hadHash) {
|
||||
renderChips();
|
||||
if (!applyStateFromHash()) { applyAll(); }
|
||||
} else {
|
||||
// Load persisted state
|
||||
|
@ -394,11 +382,26 @@
|
|||
if (input) input.value = savedFilter;
|
||||
var savedSort = localStorage.getItem('decks-sort') || 'newest';
|
||||
if (sortSel) sortSel.value = savedSort;
|
||||
var savedTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
|
||||
if (Array.isArray(savedTags)) savedTags.forEach(function(t){ activeTags.add(t); });
|
||||
var savedTheme = localStorage.getItem('decks-theme') || '';
|
||||
if (themeSel && savedTheme) {
|
||||
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === savedTheme; });
|
||||
if (!exists) { var opt = document.createElement('option'); opt.value = savedTheme; opt.textContent = savedTheme; themeSel.appendChild(opt); }
|
||||
themeSel.value = savedTheme;
|
||||
}
|
||||
// Back-compat: if no savedTheme, try first of old saved tags
|
||||
if (themeSel && !savedTheme) {
|
||||
try {
|
||||
var oldTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
|
||||
if (Array.isArray(oldTags) && oldTags.length > 0) {
|
||||
var ot = oldTags[0];
|
||||
var ex2 = Array.prototype.some.call(themeSel.options, function(o){ return o.value === ot; });
|
||||
if (!ex2) { var o2 = document.createElement('option'); o2.value = ot; o2.textContent = ot; themeSel.appendChild(o2); }
|
||||
themeSel.value = ot;
|
||||
}
|
||||
} catch(_e){}
|
||||
}
|
||||
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
|
||||
} catch(_){ }
|
||||
renderChips();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
|
@ -544,8 +547,6 @@
|
|||
})();
|
||||
</script>
|
||||
<style>
|
||||
.chip-filter { cursor:pointer; user-select:none; }
|
||||
.chip-filter.active { background:#2563eb; color:#fff; border-color:#1d4ed8; }
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
|
||||
|
|
|
@ -31,6 +31,29 @@
|
|||
</div>
|
||||
<div>
|
||||
{% if summary %}
|
||||
{% if owned_set %}
|
||||
{% set ns = namespace(owned=0, total=0) %}
|
||||
{% set tb = summary.type_breakdown %}
|
||||
{% if tb and tb.cards %}
|
||||
{% for t, clist in tb.cards.items() %}
|
||||
{% for c in clist %}
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set ns.total = ns.total + cnt %}
|
||||
{% if c.name and (c.name|lower in owned_set) %}
|
||||
{% set ns.owned = ns.owned + cnt %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set not_owned = (ns.total - ns.owned) %}
|
||||
{% set pct = ( (ns.owned * 100.0 / (ns.total or 1)) | round(1) ) %}
|
||||
<div class="panel" style="margin-bottom:.75rem;">
|
||||
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<div><strong>Ownership</strong></div>
|
||||
<div class="muted">Owned: {{ ns.owned }} • Not owned: {{ not_owned }} • Total: {{ ns.total }} ({{ pct }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% else %}
|
||||
<div class="muted">No summary available.</div>
|
||||
|
|
211
code/web/templates/owned/index.html
Normal file
211
code/web/templates/owned/index.html
Normal file
|
@ -0,0 +1,211 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h3>Owned Cards Library</h3>
|
||||
<p class="muted">Upload .txt or .csv lists. We’ll 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 %}
|
|
@ -1,9 +1,9 @@
|
|||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0;">
|
||||
Legend: <span class="game-changer" style="font-weight:600;">Game Changer</span>
|
||||
<span class="muted" style="opacity:.8;">(green highlight)</span>
|
||||
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Legend:</span>
|
||||
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
|
||||
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✔</span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✖</span>Not owned</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Type Breakdown with names-only list and hover preview -->
|
||||
|
@ -29,7 +29,9 @@
|
|||
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
|
||||
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
|
||||
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
|
||||
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
{% for t in tb.order %}
|
||||
|
@ -42,9 +44,11 @@
|
|||
{% for c in clist %}
|
||||
<div class="{% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<span data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||
{{ cnt }}x {{ c.name }}
|
||||
</span>
|
||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -64,9 +68,11 @@
|
|||
<div class="stack-grid">
|
||||
{% for c in clist %}
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" />
|
||||
<div class="count-badge">{{ cnt }}x</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -133,110 +139,115 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<!-- Mana Pip Distribution (vertical bars; only deck colors) -->
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Pip Distribution (non-lands)</h5>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
<h5>Mana Overview</h5>
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
{% if pd %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
|
||||
<!-- Pips Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
{% if pd %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="muted">No pip data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No pip data.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Mana Generation (color sources from lands, vertical bars; only deck colors) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Generation (Color Sources)</h5>
|
||||
{% set mg = summary.mana_generation %}
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
{% if mg %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
{% set ns = namespace(max_src=0) %}
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||
{% endfor %}
|
||||
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
<!-- Sources Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Sources</div>
|
||||
{% set mg = summary.mana_generation %}
|
||||
{% if mg %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
{% set ns = namespace(max_src=0) %}
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||
{% endfor %}
|
||||
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No mana source data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No mana source data.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Mana Curve (vertical bars) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Curve (non-lands)</h5>
|
||||
{% set mc = summary.mana_curve %}
|
||||
{% if mc %}
|
||||
{% set ts = mc.total_spells or 0 %}
|
||||
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
<!-- Curve Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
|
||||
{% set mc = summary.mana_curve %}
|
||||
{% if mc %}
|
||||
{% set ts = mc.total_spells or 0 %}
|
||||
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No curve data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No curve data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
|
@ -318,16 +329,15 @@
|
|||
var grid = document.getElementById('test-hand-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
var unique = compress(hand);
|
||||
unique.forEach(function(it){
|
||||
hand.forEach(function(name){
|
||||
if (!name) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'stack-card';
|
||||
if (GC_SET && GC_SET.has(it.name)) {
|
||||
if (GC_SET && GC_SET.has(name)) {
|
||||
div.className += ' game-changer';
|
||||
}
|
||||
div.innerHTML = (
|
||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(it.name) + '&format=image&version=normal" alt="' + it.name + '" data-card-name="' + it.name + '" />' +
|
||||
'<div class="count-badge">' + it.count + 'x</div>'
|
||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
|
||||
);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue