diff --git a/.gitignore b/.gitignore index bb188a0..6133971 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.txt .mypy_cache/ .venv/ +.pytest_cache/ test.py !requirements.txt __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 403d9e3..2da8b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added +- Web UI performance: optional virtualized grids/lists in Step 5 and Owned (enable with `WEB_VIRTUALIZE=1`). +- Virtualization diagnostics overlay (when `SHOW_DIAGNOSTICS=1`); press `v` to toggle per‑grid overlays and a global summary bubble with visible range, totals, render time, and counters. +- Image polish: lazy‑loading with responsive `srcset/sizes` and LQIP blur/fade‑in for Step 5 and Owned thumbnails and the commander preview image. +- Short‑TTL fragment caching for template partials (e.g., finished deck summaries and config run summaries) to reduce re‑render cost. - Web UI: FastAPI + Jinja front-end for the builder; staged build view with per-stage reasons +- New Deck modal consolidating steps 1–3 with optional Name for exports, Enter-to-select commander, and disabled browser autofill +- Locks, Replace flow, Compare builds, and shareable permalinks for finished decks +- Compare page: Copy summary action to copy diffs (Only in A/B and Changed counts) to clipboard + - Finished Decks multi-select → Compare with fallback to "Latest two"; options carry modified-time for ordering + - Permalinks include locks; global "Open Permalink…" entry exposed in header and Finished Decks + - Replace flow supports session-local Undo and lock-aware validation +- New Deck modal: inline summary of selected themes with order (1, 2, 3) - 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 @@ -39,7 +50,11 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - `/status/logs?tail=N` endpoint (read-only) to fetch a recent log tail for quick diagnostics - Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists +Roadmap and usage for Web UI features are tracked in `logs/web-ui-upgrade-outline.md`. + ### Changed +- Accessibility: respect OS “reduced motion” by disabling blur/fade transitions and smooth scrolling. +- Static asset caching and compression tuned for the web service (cache headers + gzip) to improve load performance. - Rename folder from `card_library` to `owned_cards` (env override: `OWNED_CARDS_DIR`; back-compat respected) - Docker assets and docs updated: - New volume mounts: `./owned_cards:/app/owned_cards` and `./config:/app/config` @@ -51,6 +66,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Builder Review (Step 4): "Use only owned cards" toggle moved here; Step 5 is status-only with "Edit in Review" for changes - Minor UI/CSS polish and consolidation across builder/owned pages - Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources + - New Deck modal submits directly to build, removing the intermediate review step + - Finished Decks banner and lists now prefer the custom Name provided in the modal + - Step 5 Replace toggle now includes a tooltip clarifying that reruns will replace picks in that stage when enabled + - Locks are enforced on rerun; the Locked section live-updates on unlock (row removal and chip refresh) + - Compare page shows ▲/▼ indicators on Changed counts and preserves the "Changed only" toggle across interactions + - Bracket selector shows numbered labels (e.g., "Bracket 3: Upgraded") and defaults to bracket 3 on new deck creation - List view highlight polished to wrap only the card name (no overrun of the row) - Total sources calculation updated to include 'C' properly - 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html) @@ -66,7 +87,9 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export - Fetch lands are no longer miscounted as mana sources - Web 404s previously returned JSON to browsers in some cases; now correctly render HTML via a Starlette HTTPException handler + - Windows PowerShell curl parsing issue documented with guidance in README - Deck summary alignment issues in some sections (e.g., Enchantments) fixed by splitting the count and the × into separate columns and pinning the owned flag to a fixed width; prevents drift across responsive breakpoints + - Banned list filtering applied consistently to all color/guild CSV generation paths with exact, case-insensitive matching on name/faceName (e.g., Hullbreacher, Dockside Extortionist, and Lutri are excluded) --- diff --git a/DOCKER.md b/DOCKER.md index 108362d..6d8cead 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -34,6 +34,36 @@ Then open http://localhost:8080 Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder. The app serves a favicon at `/favicon.ico` and exposes a health endpoint at `/healthz`. +Compare view offers a Copy summary button to copy a plain-text diff of two runs. + +Web UI feature highlights: +- Locks: Click a card or the lock control in Step 5; locks persist across reruns. +- Replace: Enable Replace in Step 5, click a card to open Alternatives (filters include Owned-only), then choose a swap. +- Permalinks: Copy a permalink from Step 5 or a Finished deck; paste via “Open Permalink…” to restore. +- Compare: Use the Compare page from Finished Decks; quick actions include Latest two and Swap A/B. + +Virtualized lists and lazy images (opt‑in) +- Set `WEB_VIRTUALIZE=1` to enable virtualization in Step 5 grids/lists and the Owned library for smoother scrolling on large sets. +- Example (Compose): + ```yaml + services: + web: + environment: + - WEB_VIRTUALIZE=1 + ``` +- Example (Docker Hub): + ```powershell + docker run --rm -p 8080:8080 ` + -e WEB_VIRTUALIZE=1 ` + -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" ` + -e SHOW_DIAGNOSTICS=1 ` # optional: enables diagnostics tools and overlay + mwisnowski/mtg-python-deckbuilder:latest ` + bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" + ``` ### Diagnostics and logs (optional) Enable internal diagnostics and a read-only logs viewer with environment flags. @@ -44,6 +74,7 @@ Enable internal diagnostics and a read-only logs viewer with environment flags. When enabled: - `/logs` supports an auto-refresh toggle with interval, a level filter (All/Error/Warning/Info/Debug), and a Copy button to copy the visible tail. - `/status/sys` returns a simple system summary (version, uptime, UTC server time, and feature flags) and is shown on the Diagnostics page when `SHOW_DIAGNOSTICS=1`. + - Virtualization overlay: press `v` on pages with virtualized grids to toggle per-grid overlays and a global summary bubble. Compose example (web service): ```yaml @@ -125,6 +156,8 @@ GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "upti ### Web UI tuning env vars - WEB_TAG_PARALLEL=1|0 (parallel tagging on/off) - WEB_TAG_WORKERS= (process count; set based on CPU/memory) +- WEB_VIRTUALIZE=1 (enable virtualization) +- SHOW_DIAGNOSTICS=1 (enables diagnostics pages and overlay hotkey `v`) ## Manual build/run ```powershell diff --git a/Dockerfile b/Dockerfile index 0fc40aa..1d29417 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,3 +48,7 @@ WORKDIR /app/code # Run the application CMD ["python", "main.py"] + +# Note: For the Web UI, start uvicorn in your orchestrator (compose/run) like: +# uvicorn code.web.app:app --host 0.0.0.0 --port 8080 +# Phase 9: enable web list virtualization with env WEB_VIRTUALIZE=1 diff --git a/README.md b/README.md index e9375cd..b721bb5 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index fa2475f..5514115 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -8,6 +8,10 @@ - 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`. - Web setup speed: initial tagging runs in parallel by default for the Web UI. Configure with `WEB_TAG_PARALLEL=1|0` and `WEB_TAG_WORKERS=` (compose default: 4). Falls back to sequential if parallel init fails. + - Phase 8 UI upgrade: Unified “New Deck” modal (steps 1–3), Locks, Replace flow, Compare builds, and shareable Permalinks. Optional Name field becomes the export filename stem and display name. + - Compare page now includes a Copy summary button to quickly share diffs. + - New Deck modal: shows selected themes and their order (1, 2, 3) inline while picking. + - Commander search UX: press Enter to select the first suggestion; arrow key navigation removed per feedback; browser autofill disabled. - Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources. - Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback). - Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts. @@ -16,6 +20,10 @@ - Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label. - Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews. - Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesn’t shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability. + - Finished Decks: banner and lists prefer the run’s custom Name when provided; runs include a sidecar `.summary.json` with `meta.name` for display. + - Replace toggle includes a tooltip explaining that reruns will replace that stage’s picks when enabled. + - Bracket selector labels now include numbers (e.g., "Bracket 3: Upgraded"). Default bracket is 3 when creating a new deck. + - Exports: CSV/TXT/JSON now share the same filename stem derived from the optional Name in the modal. ### Diagnostics and error handling - Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`. @@ -91,6 +99,7 @@ docker compose up --no-deps web - Finished Decks page uses a dropdown theme filter with shareable state. - Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity. - Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths. + - Data integrity: per-color/guild CSVs now consistently respect the Commander banned list using exact, case-insensitive name/faceName matching. ### Tagging updates - 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. diff --git a/WINDOWS_DOCKER_GUIDE.md b/WINDOWS_DOCKER_GUIDE.md index 345f484..64d5796 100644 --- a/WINDOWS_DOCKER_GUIDE.md +++ b/WINDOWS_DOCKER_GUIDE.md @@ -46,6 +46,7 @@ Run the browser UI by mapping a port and starting uvicorn: ```powershell docker run --rm ` -p 8080:8080 ` + -e WEB_VIRTUALIZE=1 ` # optional virtualization -v "${PWD}/deck_files:/app/deck_files" ` -v "${PWD}/logs:/app/logs" ` -v "${PWD}/csv_files:/app/csv_files" ` diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index cd5bc0b..93e394d 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -343,22 +343,31 @@ class ReportingMixin: return candidate i += 1 if filename is None: - cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' - cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' - # Collect themes in order - themes: List[str] = [] - if getattr(self, 'selected_tags', None): - themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] - else: - for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: - if isinstance(t, str) and t.strip(): - themes.append(t) - theme_parts = [_slug(t) for t in themes if t] - if not theme_parts: - theme_parts = ['notheme'] - theme_slug = '_'.join(theme_parts) + # Build a filename stem from either custom export base or commander/themes + try: + custom_base = getattr(self, 'custom_export_base', None) + except Exception: + custom_base = None date_part = _dt.date.today().strftime('%Y%m%d') - filename = f"{cmdr_slug}_{theme_slug}_{date_part}.csv" + if isinstance(custom_base, str) and custom_base.strip(): + stem = f"{_slug(custom_base.strip())}_{date_part}" + else: + cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' + cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' + # Collect themes in order + themes: List[str] = [] + if getattr(self, 'selected_tags', None): + themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] + else: + for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: + if isinstance(t, str) and t.strip(): + themes.append(t) + theme_parts = [_slug(t) for t in themes if t] + if not theme_parts: + theme_parts = ['notheme'] + theme_slug = '_'.join(theme_parts) + stem = f"{cmdr_slug}_{theme_slug}_{date_part}" + filename = f"{stem}.csv" fname = _unique_path(os.path.join(directory, filename)) full_df = getattr(self, '_full_cards_df', None) @@ -534,21 +543,30 @@ class ReportingMixin: return candidate i += 1 if filename is None: - cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' - cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' - themes: List[str] = [] - if getattr(self, 'selected_tags', None): - themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] - else: - for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: - if isinstance(t, str) and t.strip(): - themes.append(t) - theme_parts = [_slug(t) for t in themes if t] - if not theme_parts: - theme_parts = ['notheme'] - theme_slug = '_'.join(theme_parts) + # Prefer custom export base if provided; else fall back to commander/themes + try: + custom_base = getattr(self, 'custom_export_base', None) + except Exception: + custom_base = None date_part = _dt.date.today().strftime('%Y%m%d') - filename = f"{cmdr_slug}_{theme_slug}_{date_part}.txt" + if isinstance(custom_base, str) and custom_base.strip(): + stem = f"{_slug(custom_base.strip())}_{date_part}" + else: + cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' + cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' + themes: List[str] = [] + if getattr(self, 'selected_tags', None): + themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] + else: + for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: + if isinstance(t, str) and t.strip(): + themes.append(t) + theme_parts = [_slug(t) for t in themes if t] + if not theme_parts: + theme_parts = ['notheme'] + theme_slug = '_'.join(theme_parts) + stem = f"{cmdr_slug}_{theme_slug}_{date_part}" + filename = f"{stem}.txt" if not filename.lower().endswith('.txt'): filename = filename + '.txt' path = _unique_path(os.path.join(directory, filename)) @@ -643,21 +661,30 @@ class ReportingMixin: i += 1 if filename is None: - cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' - cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' - themes: List[str] = [] - if getattr(self, 'selected_tags', None): - themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] - else: - for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: - if isinstance(t, str) and t.strip(): - themes.append(t) - theme_parts = [_slug(t) for t in themes if t] - if not theme_parts: - theme_parts = ['notheme'] - theme_slug = '_'.join(theme_parts) + # Prefer a custom export base when present; else commander/themes + try: + custom_base = getattr(self, 'custom_export_base', None) + except Exception: + custom_base = None date_part = _dt.date.today().strftime('%Y%m%d') - filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json" + if isinstance(custom_base, str) and custom_base.strip(): + stem = f"{_slug(custom_base.strip())}_{date_part}" + else: + cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' + cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' + themes: List[str] = [] + if getattr(self, 'selected_tags', None): + themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] + else: + for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: + if isinstance(t, str) and t.strip(): + themes.append(t) + theme_parts = [_slug(t) for t in themes if t] + if not theme_parts: + theme_parts = ['notheme'] + theme_slug = '_'.join(theme_parts) + stem = f"{cmdr_slug}_{theme_slug}_{date_part}" + filename = f"{stem}.json" path = _unique_path(os.path.join(directory, filename)) diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index da2ccf8..bd2ddad 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -198,24 +198,24 @@ def regenerate_csv_by_color(color: str) -> None: try: if color not in SETUP_COLORS: raise ValueError(f'Invalid color: {color}') - + color_abv = COLOR_ABRV[SETUP_COLORS.index(color)] - + logger.info(f'Downloading latest card data for {color} cards') download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - + logger.info('Loading and processing card data') df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) - + logger.info(f'Regenerating {color} cards CSV') - # Use shared utilities to base-filter once then slice color - base_df = filter_dataframe(df, []) + # Use shared utilities to base-filter once then slice color, honoring bans + base_df = filter_dataframe(df, BANNED_CARDS) base_df[base_df['colorIdentity'] == color_abv].to_csv( f'{CSV_DIRECTORY}/{color}_cards.csv', index=False ) - + logger.info(f'Successfully regenerated {color} cards database') - + except Exception as e: logger.error(f'Failed to regenerate {color} cards: {str(e)}') raise diff --git a/code/file_setup/setup_utils.py b/code/file_setup/setup_utils.py index 8982499..4fc56a9 100644 --- a/code/file_setup/setup_utils.py +++ b/code/file_setup/setup_utils.py @@ -36,7 +36,8 @@ from .setup_constants import ( COLUMN_ORDER, TAGGED_COLUMN_ORDER, SETUP_COLORS, - COLOR_ABRV + COLOR_ABRV, + BANNED_CARDS, ) from exceptions import ( MTGJSONDownloadError, @@ -138,7 +139,8 @@ def save_color_filtered_csvs(df: pd.DataFrame, out_dir: Union[str, Path]) -> Non # Base-filter once for efficiency, then per-color filter without redoing base filters try: - base_df = filter_dataframe(df, []) + # Apply full standard filtering including banned list once, then slice per color + base_df = filter_dataframe(df, BANNED_CARDS) except Exception as e: # Wrap any unexpected issues as DataFrameProcessingError raise DataFrameProcessingError( @@ -207,10 +209,16 @@ def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame: filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)] logger.debug('Removed illegal sets') - # Remove banned cards - for card in banned_cards: - filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)] - logger.debug('Removed banned cards') + # Remove banned cards (exact, case-insensitive match on name or faceName) + if banned_cards: + banned_set = {b.casefold() for b in banned_cards} + name_lc = filtered_df['name'].astype(str).str.casefold() + face_lc = filtered_df['faceName'].astype(str).str.casefold() + mask = ~(name_lc.isin(banned_set) | face_lc.isin(banned_set)) + before = len(filtered_df) + filtered_df = filtered_df[mask] + after = len(filtered_df) + logger.debug(f'Removed banned cards: {before - after} filtered out') # Remove special card types for card_type in CARD_TYPES_TO_EXCLUDE: @@ -268,7 +276,7 @@ def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFr # Apply base filtering with tqdm(total=1, desc='Applying base filtering') as pbar: - filtered_df = filter_dataframe(df, []) + filtered_df = filter_dataframe(df, BANNED_CARDS) pbar.update(1) # Filter by color identity diff --git a/code/tests/test_alternatives_filters.py b/code/tests/test_alternatives_filters.py new file mode 100644 index 0000000..0dda908 --- /dev/null +++ b/code/tests/test_alternatives_filters.py @@ -0,0 +1,68 @@ +import importlib +from starlette.testclient import TestClient + + +class FakeBuilder: + def __init__(self): + # Minimal attributes accessed by /build/alternatives + self._card_name_tags_index = { + 'target card': ['ramp', 'mana'], + 'alt good': ['ramp', 'mana'], + 'alt owned': ['ramp'], + 'alt commander': ['ramp'], + 'alt in deck': ['ramp'], + 'alt locked': ['ramp'], + 'unrelated': ['draw'], + } + # Simulate pandas DataFrame mapping to preserve display casing + # Represented as a simple mock object with .empty and .iterrows() for keys above + class DF: + empty = False + def __init__(self, names): + self._names = names + def __getattr__(self, name): + if name == 'empty': + return False + raise AttributeError + def __iter__(self): + return iter(self._names) + # We'll emulate minimal API used: df[ df["name"].astype(str).str.lower().isin(pool) ] + # To keep it simple, we won't rely on DF in this test; display falls back to lower-case names. + self._combined_cards_df = None + self.card_library = {} + # Simulate deck names containing 'alt in deck' + self.current_names = ['alt in deck'] + + +def _inject_fake_ctx(client: TestClient, commander: str, locks: list[str]): + # Touch session to get sid cookie + r = client.get('/build') + assert r.status_code == 200 + sid = r.cookies.get('sid') + assert sid + # Import session service and mutate directly + tasks = importlib.import_module('code.web.services.tasks') + sess = tasks.get_session(sid) + sess['commander'] = commander + sess['locks'] = locks + sess['build_ctx'] = { + 'builder': FakeBuilder(), + 'locks': {s.lower() for s in locks}, + } + return sid + + +def test_alternatives_filters_out_commander_in_deck_and_locked(): + app_module = importlib.import_module('code.web.app') + client = TestClient(app_module.app) + _inject_fake_ctx(client, commander='Alt Commander', locks=['alt locked']) + # owned_only off + r = client.get('/build/alternatives?name=Target%20Card&owned_only=0') + assert r.status_code == 200 + body = r.text.lower() + # Should include alt good and alt owned, but not commander, in deck, or locked + assert 'alt good' in body or 'alt%20good' in body + assert 'alt owned' in body or 'alt%20owned' in body + assert 'alt commander' not in body + assert 'alt in deck' not in body + assert 'alt locked' not in body diff --git a/code/tests/test_compare_diffs.py b/code/tests/test_compare_diffs.py new file mode 100644 index 0000000..cc3e9bb --- /dev/null +++ b/code/tests/test_compare_diffs.py @@ -0,0 +1,52 @@ +import os +import tempfile +from pathlib import Path +import importlib +from starlette.testclient import TestClient + + +def _write_csv(p: Path, rows): + p.write_text('\n'.join(rows), encoding='utf-8') + + +def test_compare_diffs_with_temp_exports(monkeypatch): + with tempfile.TemporaryDirectory() as tmpd: + tmp = Path(tmpd) + # Create two CSV exports with small differences + a = tmp / 'A.csv' + b = tmp / 'B.csv' + header = 'Name,Count,Type,ManaValue\n' + _write_csv(a, [ + header.rstrip('\n'), + 'Card One,1,Creature,2', + 'Card Two,2,Instant,1', + 'Card Three,1,Sorcery,3', + ]) + _write_csv(b, [ + header.rstrip('\n'), + 'Card Two,1,Instant,1', # decreased in B + 'Card Four,1,Creature,2', # only in B + 'Card Three,1,Sorcery,3', + ]) + # Touch mtime so B is newer + os.utime(a, None) + os.utime(b, None) + + # Point DECK_EXPORTS at this temp dir + monkeypatch.setenv('DECK_EXPORTS', str(tmp)) + app_module = importlib.import_module('code.web.app') + client = TestClient(app_module.app) + + # Compare A vs B + r = client.get(f'/decks/compare?A={a.name}&B={b.name}') + assert r.status_code == 200 + body = r.text + # Only in A: Card One + assert 'Only in A' in body + assert 'Card One' in body + # Only in B: Card Four + assert 'Only in B' in body + assert 'Card Four' in body + # Changed list includes Card Two with delta -1 + assert 'Card Two' in body + assert 'Decreased' in body or '( -1' in body or '(-1)' in body diff --git a/code/tests/test_compare_metadata.py b/code/tests/test_compare_metadata.py new file mode 100644 index 0000000..b3c1c8a --- /dev/null +++ b/code/tests/test_compare_metadata.py @@ -0,0 +1,12 @@ +import importlib +from starlette.testclient import TestClient + + +def test_compare_options_include_mtime_attribute(): + app_module = importlib.import_module('code.web.app') + client = TestClient(app_module.app) + r = client.get('/decks/compare') + assert r.status_code == 200 + body = r.text + # Ensure at least one option contains data-mtime attribute (present even with empty list structure) + assert 'data-mtime' in body diff --git a/code/tests/test_permalinks_and_locks.py b/code/tests/test_permalinks_and_locks.py new file mode 100644 index 0000000..f43488f --- /dev/null +++ b/code/tests/test_permalinks_and_locks.py @@ -0,0 +1,44 @@ +import base64 +import json +from starlette.testclient import TestClient + + +def test_permalink_includes_locks_and_restores_notice(monkeypatch): + # Lazy import to ensure fresh app state + import importlib + app_module = importlib.import_module('code.web.app') + client = TestClient(app_module.app) + + # Seed a session with a commander and locks by calling /build and directly touching session via cookie path + # Start a session + r = client.get('/build') + assert r.status_code == 200 + + # Now set some session state by invoking endpoints that mutate session + # Simulate selecting commander and a lock + # Use /build/from to load a permalink-like payload directly + payload = { + "commander": "Atraxa, Praetors' Voice", + "tags": ["proliferate"], + "bracket": 3, + "ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28, "removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4}, + "tag_mode": "AND", + "flags": {"owned_only": False, "prefer_owned": False}, + "locks": ["Swords to Plowshares", "Sol Ring"], + } + raw = json.dumps(payload, separators=(",", ":")).encode('utf-8') + token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=') + r2 = client.get(f'/build/from?state={token}') + assert r2.status_code == 200 + # Step 4 should contain the locks restored chip + body = r2.text + assert 'locks restored' in body.lower() + + # Ask the server for a permalink now and ensure locks are present + r3 = client.get('/build/permalink') + assert r3.status_code == 200 + data = r3.json() + # Prefer decoded state when token not provided + state = data.get('state') or {} + assert 'locks' in state + assert set([s.lower() for s in state.get('locks', [])]) == {"swords to plowshares", "sol ring"} diff --git a/code/tests/test_replace_and_locks_flow.py b/code/tests/test_replace_and_locks_flow.py new file mode 100644 index 0000000..6023db5 --- /dev/null +++ b/code/tests/test_replace_and_locks_flow.py @@ -0,0 +1,68 @@ +import base64 +import json +import importlib +from starlette.testclient import TestClient + + +def _decode_permalink_state(client: TestClient) -> dict: + r = client.get('/build/permalink') + assert r.status_code == 200 + data = r.json() + if data.get('state'): + return data['state'] + # If only permalink token provided, decode it for inspection + url = data.get('permalink') or '' + assert '/build/from?state=' in url + token = url.split('state=', 1)[1] + pad = '=' * (-len(token) % 4) + raw = base64.urlsafe_b64decode((token + pad).encode('ascii')).decode('utf-8') + return json.loads(raw) + + +def test_replace_updates_locks_and_undo_restores(monkeypatch): + app_module = importlib.import_module('code.web.app') + client = TestClient(app_module.app) + + # Start session + r = client.get('/build') + assert r.status_code == 200 + + # Replace Old -> New (locks: add new, remove old) + r2 = client.post('/build/replace', data={'old': 'Old Card', 'new': 'New Card'}) + assert r2.status_code == 200 + body = r2.text + assert 'Locked New Card and unlocked Old Card' in body + + state = _decode_permalink_state(client) + locks = {s.lower() for s in state.get('locks', [])} + assert 'new card' in locks + assert 'old card' not in locks + + # Undo should remove new and re-add old + r3 = client.post('/build/replace/undo', data={'old': 'Old Card', 'new': 'New Card'}) + assert r3.status_code == 200 + state2 = _decode_permalink_state(client) + locks2 = {s.lower() for s in state2.get('locks', [])} + assert 'old card' in locks2 + assert 'new card' not in locks2 + + +def test_lock_from_list_unlock_emits_oob_updates(): + app_module = importlib.import_module('code.web.app') + client = TestClient(app_module.app) + + # Initialize session + r = client.get('/build') + assert r.status_code == 200 + + # Lock a name + r1 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '1'}) + assert r1.status_code == 200 + + # Now unlock from the locked list path (from_list=1) + r2 = client.post('/build/lock', data={'name': 'Test Card', 'locked': '0', 'from_list': '1'}) + assert r2.status_code == 200 + body = r2.text + # Should include out-of-band updates so UI can refresh the locks chip/section + assert 'hx-swap-oob' in body + assert 'id="locks-chip"' in body or "id='locks-chip'" in body diff --git a/code/web/app.py b/code/web/app.py index 8c5e0fd..61f73c3 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -11,6 +11,8 @@ import time import uuid import logging from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.gzip import GZipMiddleware +from typing import Any, Tuple # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent @@ -18,10 +20,20 @@ _TEMPLATES_DIR = _THIS_DIR / "templates" _STATIC_DIR = _THIS_DIR / "static" app = FastAPI(title="MTG Deckbuilder Web UI") +app.add_middleware(GZipMiddleware, minimum_size=500) # Mount static if present if _STATIC_DIR.exists(): - app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + class CacheStatic(StaticFiles): + async def get_response(self, path, scope): # type: ignore[override] + resp = await super().get_response(path, scope) + try: + # Add basic cache headers for static assets + resp.headers.setdefault("Cache-Control", "public, max-age=604800, immutable") + except Exception: + pass + return resp + app.mount("/static", CacheStatic(directory=str(_STATIC_DIR)), name="static") # Jinja templates templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) @@ -35,14 +47,42 @@ def _as_bool(val: str | None, default: bool = False) -> bool: SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False) SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True) SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False) +SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False) # Expose as Jinja globals so all templates can reference without passing per-view templates.env.globals.update({ "show_logs": SHOW_LOGS, "show_setup": SHOW_SETUP, "show_diagnostics": SHOW_DIAGNOSTICS, + "virtualize": SHOW_VIRTUALIZE, }) +# --- Simple fragment cache for template partials (low-risk, TTL-based) --- +_FRAGMENT_CACHE: dict[Tuple[str, str], tuple[float, str]] = {} +_FRAGMENT_TTL_SECONDS = 60.0 + +def render_cached(template_name: str, cache_key: str | None, /, **ctx: Any) -> str: + """Render a template fragment with an optional cache key and short TTL. + + Intended for finished/immutable views (e.g., saved deck summaries). On error, + falls back to direct rendering without cache interaction. + """ + try: + if cache_key: + now = time.time() + k = (template_name, str(cache_key)) + hit = _FRAGMENT_CACHE.get(k) + if hit and (now - hit[0]) < _FRAGMENT_TTL_SECONDS: + return hit[1] + html = templates.get_template(template_name).render(**ctx) + _FRAGMENT_CACHE[k] = (now, html) + return html + return templates.get_template(template_name).render(**ctx) + except Exception: + return templates.get_template(template_name).render(**ctx) + +templates.env.globals["render_cached"] = render_cached + # --- Diagnostics: request-id and uptime --- _APP_START_TIME = time.time() @@ -331,3 +371,11 @@ async def diagnostics_home(request: Request) -> HTMLResponse: if not SHOW_DIAGNOSTICS: raise HTTPException(status_code=404, detail="Not Found") return templates.TemplateResponse("diagnostics/index.html", {"request": request}) + + +@app.get("/diagnostics/perf", response_class=HTMLResponse) +async def diagnostics_perf(request: Request) -> HTMLResponse: + """Synthetic scroll performance page (diagnostics only).""" + if not SHOW_DIAGNOSTICS: + raise HTTPException(status_code=404, detail="Not Found") + return templates.TemplateResponse("diagnostics/perf.html", {"request": request}) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 3030d70..5419cfe 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1,15 +1,35 @@ from __future__ import annotations -from fastapi import APIRouter, Request, Form -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Request, Form, Query +from fastapi.responses import HTMLResponse, JSONResponse 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 +from html import escape as _esc router = APIRouter(prefix="/build") +# --- lightweight in-memory TTL cache for alternatives (Phase 9 planned item) --- +_ALTS_CACHE: dict[tuple[str, str, bool], tuple[float, str]] = {} +_ALTS_TTL_SECONDS = 60.0 # short TTL; avoids stale UI while helping burst traffic +def _alts_get_cached(key: tuple[str, str, bool]) -> str | None: + try: + ts, html = _ALTS_CACHE.get(key, (0.0, "")) + import time as _t + if ts and (_t.time() - ts) < _ALTS_TTL_SECONDS: + return html + except Exception: + return None + return None +def _alts_set_cached(key: tuple[str, str, bool], html: str) -> None: + try: + import time as _t + _ALTS_CACHE[key] = (_t.time(), html) + except Exception: + pass + @router.get("/", response_class=HTMLResponse) async def build_index(request: Request) -> HTMLResponse: @@ -35,6 +55,7 @@ async def build_index(request: Request) -> HTMLResponse: "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", []), + "name": sess.get("custom_export_base"), "last_step": last_step, }, ) @@ -42,6 +63,214 @@ async def build_index(request: Request) -> HTMLResponse: return resp +# Unified "New Deck" modal (steps 1–3 condensed) +@router.get("/new", response_class=HTMLResponse) +async def build_new_modal(request: Request) -> HTMLResponse: + """Return the New Deck modal content (for an overlay).""" + sid = request.cookies.get("sid") or new_sid() + ctx = { + "request": request, + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + } + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.get("/new/candidates", response_class=HTMLResponse) +async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse: + """Return a small list of commander candidates for the modal live search.""" + q = (commander or "").strip() + items = orch.commander_candidates(q, limit=8) if q else [] + ctx = {"request": request, "query": q, "candidates": items} + return templates.TemplateResponse("build/_new_deck_candidates.html", ctx) + + +@router.get("/new/inspect", response_class=HTMLResponse) +async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse: + """When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates).""" + info = orch.commander_select(name) + if not info.get("ok"): + return HTMLResponse(f'
Commander not found: {name}
') + tags = orch.tags_for_commander(info["name"]) or [] + recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] + recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} + # Render tags slot content and OOB commander preview simultaneously + ctx = { + "request": request, + "commander": {"name": info["name"]}, + "tags": tags, + "recommended": recommended, + "recommended_reasons": recommended_reasons, + } + return templates.TemplateResponse("build/_new_deck_tags.html", ctx) + + +@router.post("/new", response_class=HTMLResponse) +async def build_new_submit( + request: Request, + name: str = Form("") , + commander: str = Form(...), + primary_tag: str | None = Form(None), + secondary_tag: str | None = Form(None), + tertiary_tag: str | None = Form(None), + tag_mode: str | None = Form("AND"), + bracket: int = Form(...), + ramp: int = Form(None), + lands: int = Form(None), + basic_lands: int = Form(None), + creatures: int = Form(None), + removal: int = Form(None), + wipes: int = Form(None), + card_advantage: int = Form(None), + protection: int = Form(None), +) -> HTMLResponse: + """Handle New Deck modal submit and immediately start the build (skip separate review page).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + # Normalize and validate commander selection (best-effort via orchestrator) + sel = orch.commander_select(commander) + if not sel.get("ok"): + # Re-render modal with error + ctx = { + "request": request, + "error": sel.get("error", "Commander not found"), + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + "form": { + "name": name, + "commander": commander, + "primary_tag": primary_tag or "", + "secondary_tag": secondary_tag or "", + "tertiary_tag": tertiary_tag or "", + "tag_mode": tag_mode or "AND", + "bracket": bracket, + } + } + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Save to session + sess["commander"] = sel.get("name") or commander + tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] + # If commander has a tag list and primary missing, set first recommended as default + if not tags: + try: + rec = orch.recommended_tags_for_commander(sess["commander"]) or [] + if rec: + tags = [rec[0]] + except Exception: + pass + sess["tags"] = tags + sess["tag_mode"] = (tag_mode or "AND").upper() + try: + # Default to bracket 3 (Upgraded) when not provided + sess["bracket"] = int(bracket) if (bracket is not None) else 3 + except Exception: + try: + sess["bracket"] = int(bracket) + except Exception: + sess["bracket"] = 3 + # Ideals: use provided values if any, else defaults + ideals = orch.ideal_defaults() + overrides = {k: v for k, v in { + "ramp": ramp, + "lands": lands, + "basic_lands": basic_lands, + "creatures": creatures, + "removal": removal, + "wipes": wipes, + "card_advantage": card_advantage, + "protection": protection, + }.items() if v is not None} + for k, v in overrides.items(): + try: + ideals[k] = int(v) + except Exception: + pass + sess["ideals"] = ideals + # Clear any old staged build context + for k in ["build_ctx", "locks", "replace_mode"]: + if k in sess: + try: + del sess[k] + except Exception: + pass + # Persist optional custom export base name + if isinstance(name, str) and name.strip(): + sess["custom_export_base"] = name.strip() + else: + if "custom_export_base" in sess: + try: + del sess["custom_export_base"] + except Exception: + pass + # Immediately initialize a build context and run the first stage, like hitting Build Deck on review + if "replace_mode" not in sess: + sess["replace_mode"] = True + opts = orch.bracket_options() + default_bracket = (opts[0]["level"] if opts else 1) + bracket_val = sess.get("bracket") + try: + safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket) + 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, + locks=list(sess.get("locks", [])), + custom_export_base=sess.get("custom_export_base"), + ) + res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) + status = "Build complete" if res.get("done") else "Stage complete" + sess["last_step"] = 5 + resp = templates.TemplateResponse( + "build/_step5.html", + { + "request": request, + "commander": sess.get("commander"), + "name": sess.get("custom_export_base"), + "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": res.get("label"), + "log": res.get("log_delta", ""), + "added_cards": res.get("added_cards", []), + "i": res.get("idx"), + "n": res.get("total"), + "csv_path": res.get("csv_path") if res.get("done") else None, + "txt_path": res.get("txt_path") if res.get("done") else None, + "summary": res.get("summary") if res.get("done") else None, + "game_changers": bc.GAME_CHANGERS, + "show_skipped": False, + "total_cards": res.get("total_cards"), + "added_total": res.get("added_total"), + "skipped": bool(res.get("skipped")), + "locks": list(sess.get("locks", [])), + "replace_mode": bool(sess.get("replace_mode", True)), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + @router.get("/step1", response_class=HTMLResponse) async def build_step1(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() @@ -81,6 +310,7 @@ async def build_step1_search( "recommended": orch.recommended_tags_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), + "clear_persisted": True, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -127,9 +357,16 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp - # Proceed to step2 placeholder + # Proceed to step2 placeholder and reset any prior build/session selections sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + # Reset sticky selections from previous runs + for k in ["tags", "ideals", "bracket", "build_ctx", "last_step", "tag_mode"]: + try: + if k in sess: + del sess[k] + except Exception: + pass sess["last_step"] = 2 resp = templates.TemplateResponse( "build/_step2.html", @@ -140,6 +377,138 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe "recommended": orch.recommended_tags_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), + # Signal that this navigation came from a fresh commander confirmation, + # so the Step 2 UI should clear any localStorage theme persistence. + "clear_persisted": True, + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + +@router.post("/reset-all", response_class=HTMLResponse) +async def build_reset_all(request: Request) -> HTMLResponse: + """Clear all build-related session state and return Step 1.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + keys = [ + "commander","tags","tag_mode","bracket","ideals","build_ctx","last_step", + "locks","replace_mode" + ] + for k in keys: + try: + if k in sess: + del sess[k] + except Exception: + pass + sess["last_step"] = 1 + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + +@router.post("/step5/rewind", response_class=HTMLResponse) +async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse: + """Rewind the staged build to a previous visible stage by index or key and show that stage. + + Param `to` can be an integer index (1-based stage index) or a stage key string. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") + if not ctx: + return await build_step5_get(request) + target_i: int | None = None + # Resolve by numeric index first + try: + idx_val = int(str(to).strip()) + target_i = idx_val + except Exception: + target_i = None + if target_i is None: + # attempt by key + key = str(to).strip() + try: + for h in ctx.get("history", []) or []: + if str(h.get("key")) == key or str(h.get("label")) == key: + target_i = int(h.get("i")) + break + except Exception: + target_i = None + if not target_i: + return await build_step5_get(request) + # Try to restore snapshot stored for that history entry + try: + hist = ctx.get("history", []) or [] + snap = None + for h in hist: + if int(h.get("i")) == int(target_i): + snap = h.get("snapshot") + break + if snap is not None: + orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined] + ctx["idx"] = int(target_i) - 1 + ctx["last_visible_idx"] = int(target_i) - 1 + except Exception: + # As a fallback, restart ctx and run forward until target + opts = orch.bracket_options() + default_bracket = (opts[0]["level"] if opts else 1) + bracket_val = sess.get("bracket") + try: + safe_bracket = int(bracket_val) if bracket_val is not None else int(default_bracket) + 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, + locks=list(sess.get("locks", [])), + custom_export_base=sess.get("custom_export_base"), + ) + ctx = sess["build_ctx"] + # Run forward until reaching target + while True: + res = orch.run_stage(ctx, rerun=False, show_skipped=False) + if int(res.get("idx", 0)) >= int(target_i): + break + if res.get("done"): + break + # Finally show the target stage by running it with show_skipped True to get a view + res = orch.run_stage(ctx, rerun=False, show_skipped=True) + status = "Stage (rewound)" if not res.get("done") else "Build complete" + resp = templates.TemplateResponse( + "build/_step5.html", + { + "request": request, + "commander": sess.get("commander"), + "name": sess.get("custom_export_base"), + "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": res.get("label"), + "log": res.get("log_delta", ""), + "added_cards": res.get("added_cards", []), + "i": res.get("idx"), + "n": res.get("total"), + "game_changers": bc.GAME_CHANGERS, + "show_skipped": True, + "total_cards": res.get("total_cards"), + "added_total": res.get("added_total"), + "skipped": bool(res.get("skipped")), + "locks": list(sess.get("locks", [])), + "replace_mode": bool(sess.get("replace_mode", True)), + "history": ctx.get("history", []), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -173,6 +542,9 @@ async def build_step2_get(request: Request) -> HTMLResponse: "tertiary_tag": selected[2] if len(selected) > 2 else "", "selected_bracket": sess.get("bracket"), "tag_mode": sess.get("tag_mode", "AND"), + # If there are no server-side tags for this commander, let the client clear any persisted ones + # to avoid themes sticking between fresh runs. + "clear_persisted": False if selected else True, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -401,17 +773,22 @@ async def build_step5_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) sess["last_step"] = 5 + # Default replace-mode to ON unless explicitly toggled off + if "replace_mode" not in sess: + sess["replace_mode"] = True resp = templates.TemplateResponse( "build/_step5.html", { "request": request, "commander": sess.get("commander"), + "name": sess.get("custom_export_base"), "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()}, + "locks": list(sess.get("locks", [])), "status": None, "stage_label": None, "log": None, @@ -423,6 +800,7 @@ async def build_step5_get(request: Request) -> HTMLResponse: "show_skipped": False, "skipped": False, "game_changers": bc.GAME_CHANGERS, + "replace_mode": bool(sess.get("replace_mode", True)), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -432,6 +810,8 @@ async def build_step5_get(request: Request) -> HTMLResponse: async def build_step5_continue(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + if "replace_mode" not in sess: + sess["replace_mode"] = True # Validate commander; redirect to step1 if missing if not sess.get("commander"): resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) @@ -460,6 +840,8 @@ async def build_step5_continue(request: Request) -> HTMLResponse: use_owned_only=use_owned, prefer_owned=prefer, owned_names=owned_names, + locks=list(sess.get("locks", [])), + custom_export_base=sess.get("custom_export_base"), ) # Read show_skipped from either query or form safely show_skipped = True if (request.query_params.get('show_skipped') == '1') else False @@ -508,6 +890,8 @@ async def build_step5_continue(request: Request) -> HTMLResponse: "total_cards": total_cards, "added_total": added_total, "skipped": bool(res.get("skipped")), + "locks": list(sess.get("locks", [])), + "replace_mode": bool(sess.get("replace_mode", True)), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -517,6 +901,8 @@ async def build_step5_continue(request: Request) -> HTMLResponse: async def build_step5_rerun(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + if "replace_mode" not in sess: + sess["replace_mode"] = True if not sess.get("commander"): resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -543,14 +929,24 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: use_owned_only=use_owned, prefer_owned=prefer, owned_names=owned_names, + locks=list(sess.get("locks", [])), ) + else: + # Ensure latest locks are reflected in the existing context + try: + sess["build_ctx"]["locks"] = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + except Exception: + pass 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) + # If replace-mode is OFF, keep the stage visible even if no new cards were added + if not bool(sess.get("replace_mode", True)): + show_skipped = True + res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) status = "Stage rerun complete" if not res.get("done") else "Build complete" stage_label = res.get("label") log = res.get("log_delta", "") @@ -563,6 +959,72 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: total_cards = res.get("total_cards") added_total = res.get("added_total") sess["last_step"] = 5 + # Build locked cards list with ownership and in-deck presence + locked_cards = [] + try: + ctx = sess.get("build_ctx") or {} + b = ctx.get("builder") if isinstance(ctx, dict) else None + present: set[str] = set() + def _add_names(x): + try: + if not x: + return + if isinstance(x, dict): + for k, v in x.items(): + if isinstance(k, str) and k.strip(): + present.add(k.strip().lower()) + elif isinstance(v, dict) and v.get('name'): + present.add(str(v.get('name')).strip().lower()) + elif isinstance(x, (list, tuple, set)): + for item in x: + if isinstance(item, str): + present.add(item.strip().lower()) + elif isinstance(item, dict) and item.get('name'): + present.add(str(item.get('name')).strip().lower()) + else: + try: + nm = getattr(item, 'name', None) + if isinstance(nm, str) and nm.strip(): + present.add(nm.strip().lower()) + except Exception: + pass + except Exception: + pass + if b is not None: + for attr in ( + 'current_deck', 'deck', 'final_deck', 'final_cards', + 'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck', + ): + _add_names(getattr(b, attr, None)) + for attr in ('current_names', 'deck_names', 'final_names'): + val = getattr(b, attr, None) + if isinstance(val, (list, tuple, set)): + for n in val: + if isinstance(n, str) and n.strip(): + present.add(n.strip().lower()) + # Display-map via combined df when available + display_map: dict[str, str] = {} + try: + if b is not None: + df = getattr(b, "_combined_cards_df", None) + if df is not None and not df.empty: + lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + sub = df[df["name"].astype(str).str.lower().isin(lock_lower)] + for _idx, row in sub.iterrows(): + display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip() + except Exception: + display_map = {} + owned_lower = {str(n).strip().lower() for n in owned_store.get_names()} + for nm in (sess.get("locks", []) or []): + key = str(nm).strip().lower() + disp = display_map.get(key, nm) + locked_cards.append({ + "name": disp, + "owned": key in owned_lower, + "in_deck": key in present, + }) + except Exception: + locked_cards = [] resp = templates.TemplateResponse( "build/_step5.html", { @@ -588,6 +1050,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: "total_cards": total_cards, "added_total": added_total, "skipped": bool(res.get("skipped")), + "locks": list(sess.get("locks", [])), + "replace_mode": bool(sess.get("replace_mode", True)), + "locked_cards": locked_cards, }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -598,6 +1063,8 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: async def build_step5_start(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + if "replace_mode" not in sess: + sess["replace_mode"] = True # Validate commander exists before starting commander = sess.get("commander") if not commander: @@ -629,6 +1096,8 @@ async def build_step5_start(request: Request) -> HTMLResponse: use_owned_only=use_owned, prefer_owned=prefer, owned_names=owned_names, + locks=list(sess.get("locks", [])), + custom_export_base=sess.get("custom_export_base"), ) show_skipped = False try: @@ -652,6 +1121,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: { "request": request, "commander": commander, + "name": sess.get("custom_export_base"), "tags": sess.get("tags", []), "bracket": sess.get("bracket"), "values": sess.get("ideals", orch.ideal_defaults()), @@ -669,6 +1139,8 @@ async def build_step5_start(request: Request) -> HTMLResponse: "summary": summary, "game_changers": bc.GAME_CHANGERS, "show_skipped": show_skipped, + "locks": list(sess.get("locks", [])), + "replace_mode": bool(sess.get("replace_mode", True)), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -715,5 +1187,495 @@ async def build_banner(request: Request, step: str = "", i: int | None = None, n # Render only the inner text for the subtitle return templates.TemplateResponse( "build/_banner_subtitle.html", - {"request": request, "commander": commander, "tags": tags, "step": step, "i": i, "n": n}, + {"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")}, ) + + +@router.post("/step5/toggle-replace") +async def build_step5_toggle_replace(request: Request, replace: str = Form("0")): + """Toggle replace-mode for reruns and return an updated button HTML.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + enabled = True if str(replace).strip() in ("1","true","on","yes") else False + sess["replace_mode"] = enabled + # Return the checkbox control snippet (same as template) + checked = 'checked' if enabled else '' + html = ( + '
' + '
' + f'' + '' + '
' + '
' + ) + return HTMLResponse(html) + + +@router.post("/step5/reset-stage", response_class=HTMLResponse) +async def build_step5_reset_stage(request: Request) -> HTMLResponse: + """Reset current visible stage to the pre-stage snapshot (if available) without running it.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") + if not ctx or not ctx.get("snapshot"): + return await build_step5_get(request) + try: + orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] + except Exception: + return await build_step5_get(request) + # Re-render step 5 with cleared added list + resp = templates.TemplateResponse( + "build/_step5.html", + { + "request": request, + "commander": sess.get("commander"), + "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": "Stage reset", + "stage_label": None, + "log": None, + "added_cards": [], + "i": ctx.get("idx"), + "n": len(ctx.get("stages", [])), + "game_changers": bc.GAME_CHANGERS, + "show_skipped": False, + "total_cards": None, + "added_total": 0, + "skipped": False, + "locks": list(sess.get("locks", [])), + "replace_mode": bool(sess.get("replace_mode")), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +# --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- + +@router.post("/lock") +async def build_lock_toggle(request: Request, name: str = Form(...), locked: str = Form("1"), from_list: str | None = Form(None)): + """Toggle lock for a card name in the current session; return an HTML button to swap in-place.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + locks = set(sess.get("locks", [])) + key = str(name).strip().lower() + want_lock = True if str(locked).strip() in ("1","true","on","yes") else False + if want_lock: + locks.add(key) + else: + locks.discard(key) + sess["locks"] = list(locks) + # If a build context exists, update it too + if sess.get("build_ctx"): + try: + sess["build_ctx"]["locks"] = {str(n) for n in locks} + except Exception: + pass + # Return a compact button HTML that flips state on next click, and an OOB last-action chip + next_state = "0" if want_lock else "1" + label = "Unlock" if want_lock else "Lock" + title = ("Click to unlock" if want_lock else "Click to lock") + icon = ("🔒" if want_lock else "🔓") + # Include data-locked to reflect the current state for client-side handler + btn = f'''''' + # Compute locks count for chip + locks_count = len(locks) + if locks_count > 0: + chip_html = f'🔒 {locks_count} locked' + else: + chip_html = '' + # Last action chip for feedback (use hx-swap-oob) + try: + disp = (name or '').strip() + except Exception: + disp = str(name) + action = "Locked" if want_lock else "Unlocked" + chip = ( + f'
' + f'{action} {disp}' + f'
' + ) + # If this request came from the locked-cards list and it's an unlock, remove the row inline + try: + if (from_list is not None) and (not want_lock): + # Also update the locks-count chip, and if no locks remain, remove the whole section + extra = chip_html + if locks_count == 0: + extra += '
' + # Return empty body to delete the
  • via hx-swap=outerHTML, plus OOB updates + return HTMLResponse('' + extra) + except Exception: + pass + return HTMLResponse(btn + chip + chip_html) + + +@router.get("/alternatives", response_class=HTMLResponse) +async def build_alternatives(request: Request, name: str, stage: str | None = None, owned_only: int = Query(0)) -> HTMLResponse: + """Suggest alternative cards for a given card name using tag overlap and availability. + + Returns a small HTML snippet listing up to ~10 alternatives with Replace buttons. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") or {} + b = ctx.get("builder") if isinstance(ctx, dict) else None + # Owned library + owned_set = {str(n).strip().lower() for n in owned_store.get_names()} + require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only")) + # If builder context missing, show a guidance message + if not b: + html = ( + '
    Start the build to see alternatives.
    ' + ) + return HTMLResponse(html) + try: + name_l = str(name).strip().lower() + commander_l = str((sess.get("commander") or "")).strip().lower() + locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + # Check cache: key = (seed, commander, require_owned) + cache_key = (name_l, commander_l, require_owned) + cached = _alts_get_cached(cache_key) + if cached is not None: + return HTMLResponse(cached) + # Tags index provides quick similarity candidates + tags_idx = getattr(b, "_card_name_tags_index", {}) or {} + seed_tags = set(tags_idx.get(name_l) or []) + # Fallback: use the card's role/sub-role from current library if available + lib = getattr(b, "card_library", {}) or {} + lib_entry = lib.get(name) or lib.get(name_l) + # Best-effort set of names currently in the deck to avoid duplicates + in_deck: set[str] = set() + try: + def _add_names(x): + try: + if not x: + return + if isinstance(x, dict): + for k, v in x.items(): + # dict of name->count or name->obj + if isinstance(k, str) and k.strip(): + in_deck.add(k.strip().lower()) + elif isinstance(v, dict) and v.get('name'): + in_deck.add(str(v.get('name')).strip().lower()) + elif isinstance(x, (list, tuple, set)): + for item in x: + if isinstance(item, str): + in_deck.add(item.strip().lower()) + elif isinstance(item, dict) and item.get('name'): + in_deck.add(str(item.get('name')).strip().lower()) + else: + try: + nm = getattr(item, 'name', None) + if isinstance(nm, str) and nm.strip(): + in_deck.add(nm.strip().lower()) + except Exception: + pass + except Exception: + pass + # Probe a few likely attributes; ignore if missing + for attr in ( + 'current_deck', 'deck', 'final_deck', 'final_cards', + 'chosen_cards', 'selected_cards', 'picked_cards', 'cards_in_deck', + ): + _add_names(getattr(b, attr, None)) + # Some builders may expose a flat set of names + for attr in ('current_names', 'deck_names', 'final_names'): + val = getattr(b, attr, None) + if isinstance(val, (list, tuple, set)): + for n in val: + if isinstance(n, str) and n.strip(): + in_deck.add(n.strip().lower()) + except Exception: + in_deck = set() + # Build candidate pool from tags overlap + all_names = set(tags_idx.keys()) + candidates: list[tuple[str,int]] = [] # (name, score) + for nm in all_names: + if nm == name_l: + continue + # Exclude commander and any names we believe are already in the current deck + if commander_l and nm == commander_l: + continue + if in_deck and nm in in_deck: + continue + # Also exclude any card currently locked (these are intended to be kept) + if locked_set and nm in locked_set: + continue + tgs = set(tags_idx.get(nm) or []) + score = len(seed_tags & tgs) + if score <= 0: + continue + candidates.append((nm, score)) + # If no tag-based candidates, try using same trigger tag if present + if not candidates and isinstance(lib_entry, dict): + try: + trig = str(lib_entry.get("TriggerTag") or "").strip().lower() + except Exception: + trig = "" + if trig: + for nm, tglist in tags_idx.items(): + if nm == name_l: + continue + if nm in {str(k).strip().lower() for k in lib.keys()}: + continue + if trig in {str(t).strip().lower() for t in (tglist or [])}: + candidates.append((nm, 1)) + # Sort by score desc, then owned-first, then name asc + def _owned(nm: str) -> bool: + return nm in owned_set + candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) + # Map back to display names using combined DF when possible for proper casing + display_map: dict[str, str] = {} + try: + df = getattr(b, "_combined_cards_df", None) + if df is not None and not df.empty: + # Build lower->original map limited to candidate pool for speed + pool_lower = {nm for (nm, _s) in candidates} + sub = df[df["name"].astype(str).str.lower().isin(pool_lower)] + for _idx, row in sub.iterrows(): + display_map[str(row["name"]).strip().lower()] = str(row["name"]).strip() + except Exception: + display_map = {} + # Apply owned filter and cap list + items_html: list[str] = [] + seen = set() + count = 0 + for nm, score in candidates: + if nm in seen: + continue + seen.add(nm) + disp = display_map.get(nm, nm) + is_owned = (nm in owned_set) + if require_owned and not is_owned: + continue + badge = "✔" if is_owned else "✖" + title = "Owned" if is_owned else "Not owned" + # Replace button posts to /build/replace; we'll update locks and prompt rerun + # Provide hover-preview metadata so moving the mouse over the alternative shows that card + cand_tags = tags_idx.get(nm) or [] + data_tags = ", ".join([str(t) for t in cand_tags]) + items_html.append( + f'
  • {badge} ' + f'
  • ' + ) + count += 1 + if count >= 10: + break + # Build HTML + if not items_html: + owned_msg = " (owned only)" if require_owned else "" + html = f'
    No alternatives found{owned_msg}.
    ' + else: + toggle_q = "0" if require_owned else "1" + toggle_label = ("Owned only: On" if require_owned else "Owned only: Off") + html = ( + '
    ' + f'
    Alternatives' + f'
    ' + '
      ' + + "".join(items_html) + + '
    ' + '
    ' + ) + # Save to cache and return + _alts_set_cached(cache_key, html) + return HTMLResponse(html) + except Exception as e: + return HTMLResponse(f'
    No alternatives: {e}
    ') + + +@router.post("/replace", response_class=HTMLResponse) +async def build_replace(request: Request, old: str = Form(...), new: str = Form(...)) -> HTMLResponse: + """Update locks to prefer `new` over `old` and prompt the user to rerun the stage with Replace enabled. + + This does not immediately mutate the builder; users should click Rerun Stage (Replace: On) to apply. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + locks = set(sess.get("locks", [])) + o = str(old).strip().lower() + n = str(new).strip().lower() + # Always ensure new is locked and old is unlocked + locks.discard(o) + locks.add(n) + sess["locks"] = list(locks) + # Track last replace for optional undo + try: + sess["last_replace"] = {"old": o, "new": n} + except Exception: + pass + if sess.get("build_ctx"): + try: + sess["build_ctx"]["locks"] = {str(x) for x in locks} + except Exception: + pass + # Return a small confirmation with a shortcut to rerun + hint = ( + '
    ' + f'
    Locked {new} and unlocked {old}.
    ' + '
    Now click Rerun Stage with Replace: On to apply this change.
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + f'' + f'' + '' + '
    ' + '' + '
    ' + '
    ' + ) + # Also emit an OOB last-action chip + chip = ( + f'
    ' + f'Replaced {old}{new}' + f'
    ' + ) + return HTMLResponse(hint + chip) + + +@router.post("/replace/undo", response_class=HTMLResponse) +async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse: + """Undo the last replace by restoring the previous lock state (best-effort).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + last = sess.get("last_replace") or {} + try: + # Prefer provided args, else fallback to last recorded + o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip() + n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip() + except Exception: + o, n = "", "" + locks = set(sess.get("locks", [])) + changed = False + if n and n in locks: + locks.discard(n) + changed = True + if o: + locks.add(o) + changed = True + sess["locks"] = list(locks) + if sess.get("build_ctx"): + try: + sess["build_ctx"]["locks"] = {str(x) for x in locks} + except Exception: + pass + # Clear last_replace after undo + try: + if sess.get("last_replace"): + del sess["last_replace"] + except Exception: + pass + # Return confirmation panel and OOB chip + msg = 'Undid replace' if changed else 'No changes to undo' + html = ( + '
    ' + f'
    {msg}.
    ' + '
    Rerun the stage to recompute picks if needed.
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '' + '
    ' + '
    ' + ) + chip = ( + f'
    ' + f'{msg}' + f'
    ' + ) + return HTMLResponse(html + chip) + + +@router.get("/compare") +async def build_compare(runA: str, runB: str): + """Stub: return empty diffs; later we can diff summary files under deck_files.""" + return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) + + +@router.get("/permalink") +async def build_permalink(request: Request): + """Return a URL-safe JSON payload representing current run config (basic).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + payload = { + "commander": sess.get("commander"), + "tags": sess.get("tags", []), + "bracket": sess.get("bracket"), + "ideals": sess.get("ideals"), + "tag_mode": sess.get("tag_mode", "AND"), + "flags": { + "owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + }, + "locks": list(sess.get("locks", [])), + } + try: + import base64 + import json as _json + raw = _json.dumps(payload, separators=(",", ":")) + token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=") + # Also include decoded state for convenience/testing + return JSONResponse({"ok": True, "permalink": f"/build/from?state={token}", "state": payload}) + except Exception: + return JSONResponse({"ok": True, "state": payload}) + + +@router.get("/from", response_class=HTMLResponse) +async def build_from(request: Request, state: str | None = None) -> HTMLResponse: + """Load a run from a permalink token.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + if state: + try: + import base64 + import json as _json + pad = '=' * (-len(state) % 4) + raw = base64.urlsafe_b64decode((state + pad).encode("ascii")).decode("utf-8") + data = _json.loads(raw) + sess["commander"] = data.get("commander") + sess["tags"] = data.get("tags", []) + sess["bracket"] = data.get("bracket") + if data.get("ideals"): + sess["ideals"] = data.get("ideals") + sess["tag_mode"] = data.get("tag_mode", "AND") + flags = data.get("flags") or {} + sess["use_owned_only"] = bool(flags.get("owned_only")) + sess["prefer_owned"] = bool(flags.get("prefer_owned")) + sess["locks"] = list(data.get("locks", [])) + sess["last_step"] = 4 + except Exception: + pass + locks_restored = 0 + try: + locks_restored = len(sess.get("locks", []) or []) + except Exception: + locks_restored = 0 + resp = templates.TemplateResponse("build/_step4.html", { + "request": request, + "labels": orch.ideal_labels(), + "values": sess.get("ideals") or orch.ideal_defaults(), + "commander": sess.get("commander"), + "owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + "locks_restored": locks_restored, + }) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 194cc5c..deb35bc 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse from pathlib import Path import csv import os -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional from ..app import templates from ..services import owned_store @@ -47,6 +47,8 @@ def _list_decks() -> list[dict]: _m = payload.get('meta', {}) if isinstance(payload, dict) else {} meta["commander"] = _m.get('commander') or meta.get("commander") meta["tags"] = _m.get('tags') or meta.get("tags") or [] + if _m.get('name'): + meta["display"] = _m.get('name') except Exception: pass # Fallback to parsing commander/themes from filename convention Commander_Themes_YYYYMMDD @@ -213,6 +215,38 @@ def _read_csv_summary(csv_path: Path) -> Tuple[dict, Dict[str, int], Dict[str, i return summary, type_counts, curve_counts, type_cards +def _read_deck_counts(csv_path: Path) -> Dict[str, int]: + """Read a CSV deck export and return a mapping of card name -> total count. + + Falls back to zero on parse issues; ignores header case and missing columns. + """ + counts: Dict[str, int] = {} + try: + with csv_path.open('r', encoding='utf-8') as f: + reader = csv.reader(f) + headers = next(reader, []) + name_idx = headers.index('Name') if 'Name' in headers else 0 + count_idx = headers.index('Count') if 'Count' in headers else 1 + for row in reader: + if not row: + continue + try: + name = row[name_idx] + except Exception: + continue + try: + cnt = int(float(row[count_idx])) if row[count_idx] else 1 + except Exception: + cnt = 1 + name = str(name).strip() + if not name: + continue + counts[name] = counts.get(name, 0) + cnt + except Exception: + pass + return counts + + @router.get("/", response_class=HTMLResponse) async def decks_index(request: Request) -> HTMLResponse: items = _list_decks() @@ -243,11 +277,14 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: _tags = meta.get('tags') or [] if isinstance(_tags, list): tags = [str(t) for t in _tags] + display_name = meta.get('name') or '' except Exception: summary = None + display_name = '' if not summary: # Reconstruct minimal summary from CSV summary, _tc, _cc, _tcs = _read_csv_summary(p) + display_name = '' stem = p.stem txt_path = p.with_suffix('.txt') # If missing still, infer from filename stem @@ -263,7 +300,91 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: "summary": summary, "commander": commander_name, "tags": tags, + "display_name": display_name, "game_changers": bc.GAME_CHANGERS, "owned_set": {n.lower() for n in owned_store.get_names()}, } return templates.TemplateResponse("decks/view.html", ctx) + + +@router.get("/compare", response_class=HTMLResponse) +async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[str] = None) -> HTMLResponse: + """Compare two finished deck CSVs and show diffs. + + Query params: + - A: filename of first deck (e.g., Alena_..._20250827.csv) + - B: filename of second deck + """ + base = _deck_dir() + items = _list_decks() + # Build select options with friendly display labels + options: List[Dict[str, str]] = [] + for it in items: + label = it.get("display") or it.get("commander") or it.get("name") + # Include mtime for "Latest two" selection refinement + mt = it.get("mtime", 0) + try: + mt_val = str(int(mt)) + except Exception: + mt_val = "0" + options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type] + + diffs = None + metaA: Dict[str, str] = {} + metaB: Dict[str, str] = {} + if A and B: + pA = (base / A) + pB = (base / B) + if _safe_within(base, pA) and _safe_within(base, pB) and pA.exists() and pB.exists(): + ca = _read_deck_counts(pA) + cb = _read_deck_counts(pB) + setA = set(ca.keys()) + setB = set(cb.keys()) + onlyA = sorted(list(setA - setB)) + onlyB = sorted(list(setB - setA)) + changed: List[Tuple[str, int, int]] = [] + for n in sorted(setA & setB): + if ca.get(n, 0) != cb.get(n, 0): + changed.append((n, ca.get(n, 0), cb.get(n, 0))) + # Side meta (commander/name/tags) if available + def _meta_for(path: Path) -> Dict[str, str]: + out: Dict[str, str] = {"filename": path.name} + sc = path.with_suffix('.summary.json') + try: + if sc.exists(): + import json as _json + payload = _json.loads(sc.read_text(encoding='utf-8')) + if isinstance(payload, dict): + m = payload.get('meta', {}) or {} + out["display"] = (m.get('name') or '') + out["commander"] = (m.get('commander') or '') + out["tags"] = ', '.join(m.get('tags') or []) + except Exception: + pass + if not out.get("commander"): + parts = path.stem.split('_') + if parts: + out["commander"] = parts[0] + return out + metaA = _meta_for(pA) + metaB = _meta_for(pB) + diffs = { + "onlyA": onlyA, + "onlyB": onlyB, + "changed": changed, + "A": A, + "B": B, + } + + return templates.TemplateResponse( + "decks/compare.html", + { + "request": request, + "options": options, + "A": A or "", + "B": B or "", + "diffs": diffs, + "metaA": metaA, + "metaB": metaB, + }, + ) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index e7def18..c175d9a 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -781,6 +781,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception as e: out(f"Reporting phase failed: {e}") try: + # If a custom export base is threaded via environment/session in web, we can respect env var + try: + custom_base = os.getenv('WEB_CUSTOM_EXPORT_BASE') + if custom_base: + setattr(b, 'custom_export_base', custom_base) + except Exception: + pass if hasattr(b, 'export_decklist_csv'): csv_path = b.export_decklist_csv() # type: ignore[attr-defined] except Exception as e: @@ -819,6 +826,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i "csv": csv_path, "txt": txt_path, } + # Attach custom deck name if provided + try: + custom_base = getattr(b, 'custom_export_base', None) + except Exception: + custom_base = None + if isinstance(custom_base, str) and custom_base.strip(): + meta["name"] = custom_base.strip() payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) @@ -898,6 +912,8 @@ def start_build_ctx( use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None, + locks: List[str] | None = None, + custom_export_base: str | None = None, ) -> Dict[str, Any]: logs: List[str] = [] @@ -974,6 +990,9 @@ def start_build_ctx( "csv_path": None, "txt_path": None, "snapshot": None, + "history": [], # list of {i, key, label, snapshot} + "locks": {str(n).strip().lower() for n in (locks or []) if str(n).strip()}, + "custom_export_base": str(custom_export_base).strip() if isinstance(custom_export_base, str) and custom_export_base.strip() else None, } return ctx @@ -1021,13 +1040,21 @@ 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, show_skipped: bool = False) -> Dict[str, Any]: +def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = False, *, replace: bool = False) -> Dict[str, Any]: b: DeckBuilder = ctx["builder"] stages: List[Dict[str, Any]] = ctx["stages"] logs: List[str] = ctx["logs"] + locks_set: set[str] = set(ctx.get("locks") or []) # If all stages done, finalize exports (interactive/manual build) if ctx["idx"] >= len(stages): + # Apply custom export base if present in context + try: + custom_base = ctx.get("custom_export_base") + if custom_base: + setattr(b, 'custom_export_base', str(custom_base)) + except Exception: + pass if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'): try: ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] @@ -1045,6 +1072,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass except Exception as e: logs.append(f"Text export failed: {e}") + # Final lock enforcement before finishing + try: + for lname in locks_set: + try: + # If locked card missing, attempt to add a placeholder entry + if lname not in {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()}: + # Try to find exact name in dataframes + target_name = None + try: + df = getattr(b, '_combined_cards_df', None) + if df is not None and not df.empty: + row = df[df['name'].astype(str).str.lower() == lname] + if not row.empty: + target_name = str(row.iloc[0]['name']) + except Exception: + target_name = None + if target_name is None: + # As fallback, use the locked name as-is (display only) + target_name = lname + b.card_library[target_name] = { + 'Count': 1, + 'Role': 'Locked', + 'SubRole': '', + 'AddedBy': 'Lock', + 'TriggerTag': '', + } + except Exception: + continue + except Exception: + pass # Build structured summary for UI summary = None try: @@ -1067,6 +1124,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "csv": ctx.get("csv_path"), "txt": ctx.get("txt_path"), } + try: + custom_base = getattr(b, 'custom_export_base', None) + except Exception: + custom_base = None + if isinstance(custom_base, str) and custom_base.strip(): + meta["name"] = custom_base.strip() payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) @@ -1095,8 +1158,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal label = stage["label"] runner_name = stage["runner_name"] - # Take snapshot before executing; for rerun, restore first if we have one - if rerun and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1): + # Take snapshot before executing; for rerun with replace, restore first if we have one + if rerun and replace and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1): _restore_builder(b, ctx["snapshot"]) # restore to pre-stage state snap_before = _snapshot_builder(b) @@ -1112,6 +1175,36 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal logs.append(f"Runner not available: {runner_name}") delta_log = "\n".join(logs[start_log:]) + # Enforce locks immediately after the stage runs so they appear in added list + try: + for lname in locks_set: + try: + lib_keys_lower = {str(n).strip().lower(): str(n) for n in getattr(b, 'card_library', {}).keys()} + if lname not in lib_keys_lower: + # Try to resolve canonical name from DF + target_name = None + try: + df = getattr(b, '_combined_cards_df', None) + if df is not None and not df.empty: + row = df[df['name'].astype(str).str.lower() == lname] + if not row.empty: + target_name = str(row.iloc[0]['name']) + except Exception: + target_name = None + if target_name is None: + target_name = lname + b.card_library[target_name] = { + 'Count': 1, + 'Role': 'Locked', + 'SubRole': '', + 'AddedBy': 'Lock', + 'TriggerTag': '', + } + except Exception: + continue + except Exception: + pass + # Compute added cards based on snapshot try: prev_lib = snap_before.get("card_library", {}) if isinstance(snap_before, dict) else {} @@ -1170,6 +1263,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: added_total = 0 ctx["snapshot"] = snap_before # snapshot for rerun + try: + (ctx.setdefault("history", [])).append({ + "i": i + 1, + "key": stage.get("key"), + "label": label, + "snapshot": snap_before, + }) + except Exception: + pass ctx["idx"] = i + 1 ctx["last_visible_idx"] = i + 1 return { @@ -1196,6 +1298,15 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: total_cards = None ctx["snapshot"] = snap_before + try: + (ctx.setdefault("history", [])).append({ + "i": i + 1, + "key": stage.get("key"), + "label": label, + "snapshot": snap_before, + }) + except Exception: + pass ctx["idx"] = i + 1 ctx["last_visible_idx"] = i + 1 return { @@ -1210,12 +1321,19 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "added_total": 0, } - # No cards added and not showing skipped: advance to next + # No cards added and not showing skipped: advance to next stage and continue loop i += 1 # Continue loop to auto-advance # If we reached here, all remaining stages were no-ops; finalize exports ctx["idx"] = len(stages) + # Apply custom export base if present + try: + custom_base = ctx.get("custom_export_base") + if custom_base: + setattr(b, 'custom_export_base', str(custom_base)) + except Exception: + pass if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'): try: ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] @@ -1255,6 +1373,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "csv": ctx.get("csv_path"), "txt": ctx.get("txt_path"), } + try: + custom_base = getattr(b, 'custom_export_base', None) + except Exception: + custom_base = None + if isinstance(custom_base, str) and custom_base.strip(): + meta["name"] = custom_base.strip() payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) diff --git a/code/web/static/app.js b/code/web/static/app.js index 8b3223e..d002af9 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -110,6 +110,13 @@ document.addEventListener('keydown', function(e){ if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs var k = e.key.toLowerCase(); + // If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock) + try { + var active = document.activeElement; + if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) { + return; + } + } catch(_) { /* noop */ } if (keymap[k]){ e.preventDefault(); keymap[k](); } }); @@ -165,6 +172,7 @@ hydrateProgress(document); syncShowSkipped(document); initCardFilters(document); + initVirtualization(document); }); // Hydrate progress bars with width based on data-pct @@ -192,8 +200,31 @@ hydrateProgress(e.target); syncShowSkipped(e.target); initCardFilters(e.target); + initVirtualization(e.target); }); + // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) + function scrollCardIntoView(name){ + if (!name) return; + try{ + var section = document.querySelector('section'); + var grid = section && section.querySelector('.card-grid'); + if (!grid) return; + // If virtualized, force a render around the approximate match by searching stored children + var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); + if (!target) { + // Trigger a render update and try again + grid.dispatchEvent(new Event('scroll')); // noop but can refresh + target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); + } + if (target) { + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + target.focus && target.focus(); + } + }catch(_){} + } + window.scrollCardIntoView = scrollCardIntoView; + // --- Card grid filters, reasons, and collapsible groups --- function initCardFilters(root){ var section = (root || document).querySelector('section'); @@ -250,7 +281,7 @@ } }); // Filter tiles - var tiles = section.querySelectorAll('.card-grid .card-tile'); + var tiles = section.querySelectorAll('.card-grid .card-tile'); var visible = 0; tiles.forEach(function(tile){ var name = (tile.getAttribute('data-card-name')||'').toLowerCase(); @@ -272,7 +303,7 @@ return { name: name.toLowerCase(), owned: owned, gc: gc }; } section.querySelectorAll('.card-grid').forEach(function(grid){ - var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile')); + var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile')); arr.sort(function(a,b){ var ka = keyFor(a), kb = keyFor(b); if (sortMode === 'owned'){ @@ -368,4 +399,268 @@ } document.addEventListener('keydown', onKey); } + + // --- Lightweight virtualization (feature-flagged via data-virtualize) --- + function initVirtualization(root){ + try{ + var body = document.body || document.documentElement; + var DIAG = !!(body && body.getAttribute('data-diag') === '1'); + // Global diagnostics aggregator + var GLOBAL = (function(){ + if (!DIAG) return null; + if (window.__virtGlobal) return window.__virtGlobal; + var store = { grids: [], summaryEl: null }; + function ensure(){ + if (!store.summaryEl){ + var el = document.createElement('div'); + el.id = 'virt-global-diag'; + el.style.position = 'fixed'; + el.style.right = '8px'; + el.style.bottom = '8px'; + el.style.background = 'rgba(17,24,39,.85)'; + el.style.border = '1px solid var(--border)'; + el.style.padding = '.25rem .5rem'; + el.style.borderRadius = '6px'; + el.style.fontSize = '12px'; + el.style.color = '#cbd5e1'; + el.style.zIndex = '50'; + el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)'; + el.style.cursor = 'default'; + // Hidden by default; toggle with 'v' + el.style.display = 'none'; + document.body.appendChild(el); + store.summaryEl = el; + } + return store.summaryEl; + } + function update(){ + var el = ensure(); if (!el) return; + var g = store.grids; + var total = 0, visible = 0, lastMs = 0; + for (var i=0;i