From 1aa8e4d7e8b4ecb1a483099fdb1385014d9eaaf3 Mon Sep 17 00:00:00 2001 From: mwisnowski <93788087+mwisnowski@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:39:51 -0700 Subject: [PATCH] feat: revamp multicopy flow with include/exclude conflict dialogs (#60) * feat: revamp multicopy flow with include/exclude conflict dialogs * feat: revamp multicopy flow with include/exclude conflict dialogs --- CHANGELOG.md | 4 +- RELEASE_NOTES_TEMPLATE.md | 90 +------- code/deck_builder/builder.py | 31 ++- code/tests/test_commander_build_cta.py | 17 -- code/tests/test_web_new_deck_partner.py | 36 ---- code/web/routes/build_include_exclude.py | 154 +++++++++++++- code/web/routes/build_multicopy.py | 4 + code/web/routes/build_newflow.py | 22 +- code/web/routes/build_themes.py | 47 ++++ code/web/routes/build_wizard.py | 128 ++--------- code/web/services/tasks.py | 15 ++ code/web/templates/build/_new_deck_modal.html | 201 ++++++++++++++++++ .../partials/multicopy_exclude_warning.html | 70 ++++++ .../partials/multicopy_include_dialog.html | 98 +++++++++ 14 files changed, 665 insertions(+), 252 deletions(-) create mode 100644 code/web/templates/partials/multicopy_exclude_warning.html create mode 100644 code/web/templates/partials/multicopy_include_dialog.html diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b8203..f02477f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,14 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - **InvalidSeedError**: New `InvalidSeedError` exception in `code/exceptions.py` for seed validation failures - **Random diagnostics endpoint**: `GET /api/random/diagnostics` behind `WEB_RANDOM_DIAGNOSTICS=1` flag, returning seed derivation test vectors for cross-platform consistency checks - **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md` +- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox +- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection) ### Changed _No unreleased changes yet_ ### Fixed -_No unreleased changes yet_ +- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed ### Removed _No unreleased changes yet_ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index c4df7f0..0625893 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -5,98 +5,14 @@ - **RandomService**: Service wrapper for seeded RNG with validation (`code/web/services/random_service.py`) - **Random diagnostics**: `GET /api/random/diagnostics` endpoint (requires `WEB_RANDOM_DIAGNOSTICS=1`) - **Random Mode docs**: `docs/random_mode/` covering seed infrastructure, developer guide, and diagnostics +- **Multi-copy include dialog**: Typing a multi-copy archetype card (e.g., Hare Apparent) in Must Include now triggers a popup to choose copy count and optional Thrumming Stone inclusion +- **Multi-copy/exclude conflict dialog**: Selecting a multi-copy archetype while the same card is in the Exclude list now shows a resolution popup — keep the archetype (removes from excludes) or keep the exclude (disables archetype) ### Changed _No unreleased changes yet_ ### Fixed -_No unreleased changes yet_ - -### Removed -_No unreleased changes yet_ - -## [4.1.0] - 2026-03-20 -### Added -- **Deck Builder Theme Selection**: Enhanced theme picker with pool size indicators, smart sorting, and optional grouping - - **Pool Size Badges**: Numeric card count displayed on all theme chips (recommended + general) - - **Smart Sorting**: Themes automatically sorted by pool size (descending), then alphabetically - - **Visual Separator**: Clear separation between recommended and general themes with section headers - - **Pool Size Sections**: Optional grouping of themes into Vast/Large/Moderate/Small/Tiny sections, controlled by `THEME_POOL_SECTIONS` environment variable (default: off) - - **Popup Wizard Parity**: New Deck modal has full feature parity with the legacy builder (badges, sorting, sections) - - **Partner-Aware Sections**: When a partner commander is selected, partner themes are bucketed into the correct pool size section rather than appended flat - - **Pool Size Tooltips**: Section headers and the "All Available Themes" label include tooltips explaining what the card count badge means and the tier thresholds - - **Badge Styling**: Muted, compact pool size badges integrated seamlessly into chip design -- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality` - - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) - - **Catalog Statistics**: Total themes, average quality score displayed prominently - - **Top 10 Highest Quality**: Best-curated themes with links to theme pages - - **Bottom 10 Lowest Quality**: Themes needing improvement with actionable suggestions - - **Improvement Tools**: Direct links to linter CLI command and editorial documentation - - **Protected Access**: Dashboard gated behind SHOW_DIAGNOSTICS=1 flag for admin use - - **Main Diagnostics Integration**: Quality stats preview card on main diagnostics page with link to full dashboard -- **Theme Badge Explanations**: Detailed reasoning for quality, pool size, and popularity badges on individual theme pages - - **Quality Explanations**: Multi-factor breakdown showing synergy breakdown (curated/enforced/inferred counts), deck archetype classification, description curation status, and editorial quality status - - **Pool Size Explanations**: Card count with contextual guidance on flexibility and optimization potential - - **Popularity Explanations**: Adoption pattern descriptions explaining why themes have their popularity tier - - **Collapsible Display**: Badge details in collapsible section (open by default), matching catalog page badge legend pattern - - **Feature Flag Respects**: Explanations only show for enabled badge types (respects SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES) - - **Dynamic Reasoning**: Explanations generated based on actual theme data (quality score, synergy counts, editorial status, archetype metadata) -- **Theme Catalog Badge System**: Comprehensive metric visualization with granular display control - - **Quality Badges**: Editorial quality indicators (Excellent/Good/Fair/Poor) with semantic colors - - **Pool Size Badges**: Card availability indicators (Vast/Large/Moderate/Small/Tiny) showing total cards per theme - - **Popularity Badges**: Usage frequency indicators (Very Common/Common/Uncommon/Niche/Rare) based on theme adoption - - **Badge Feature Flags**: Individual toggle flags for each badge type (SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES) - - **Filter Controls**: Dropdown filters and quick-select chips for all three metrics with master toggle (SHOW_THEME_FILTERS) -- **Theme Pool Size Display**: Visual indicators showing total card availability per theme - - **Pool Size Calculation**: Automatic counting of cards with each theme tag from parquet data - - **Pool Tier Badges**: Color-coded badges (Vast/Large/Moderate/Small/Tiny) showing pool size categories - - **Pool Data in API**: Theme pool size (card count) and tier included in all theme API responses - - **Pool Badges CSS**: New badge styles with distinct colors (violet/teal/cyan/orange/gray for pool tiers) - - **Dual Metric System**: Quality badges (editorial completeness) + Pool size badges (card availability) shown together -- **Theme Quality Score Display**: Visual quality indicators in web UI for theme catalog - - **Quality Tier Badges**: Color-coded badges (Excellent/Good/Fair/Poor) shown in theme lists and detail pages - - **Quality Scoring**: Automatic calculation during theme loading based on completeness, uniqueness, and curation quality - - **Quality Data in API**: Theme quality tier and normalized score (0.0-1.0) included in all theme API responses - - **Quality Badges CSS**: New badge styles with semantic colors (green/blue/yellow/red for quality tiers) -- **Theme Catalog Filtering**: Advanced filtering system for quality, pool size, and popularity - - **Filter Dropdowns**: Select-based filters for precise tier selection (Quality: E/G/F/P, Pool: V/L/M/S/T, Popularity: VC/C/U/N/R) - - **Quick Filter Chips**: Single-click filter activation with letter-based shortcuts - - **Combined Filtering**: Multiple filter types work together with AND logic (e.g., Good quality + Vast pool + Common popularity) - - **Active Filter Display**: Visual chips showing applied filters with individual remove buttons - - **Filter Performance**: Backend filtering in both fast path (theme_list.json) and fallback (full index) with sub-200ms response times -- **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation - - **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override) - - **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability - - **Enhanced Quality Scoring**: Four-tier system (Excellent/Good/Fair/Poor) with 0.0-1.0 numerical scores based on uniqueness, duplication, description quality, and metadata completeness - - **CLI Linter**: `validate_theme_catalog.py --lint` flag with configurable thresholds for duplication and quality warnings, provides actionable improvement suggestions - - **Editorial Documentation**: Comprehensive guide at `docs/theme_editorial_guide.md` covering quality scoring, best practices, linter usage, and workflow examples -- **Theme Stripping Configuration**: Configurable minimum card threshold for theme retention - - **THEME_MIN_CARDS Setting**: Environment variable (default: 5) to strip themes with too few cards from catalogs and card metadata - - **Analysis Tooling**: `analyze_theme_distribution.py` script to visualize theme distribution and identify stripping candidates - - **Core Threshold Logic**: `theme_stripper.py` module with functions to identify and filter low-card-count themes - - **Catalog Stripping**: Automated removal of low-card themes from YAML catalog with backup/logging via `strip_catalog_themes.py` script - -### Changed -- **Build Process Modernization**: Theme catalog generation now reads from parquet files instead of obsolete CSV format - - Updated `build_theme_catalog.py` and `extract_themes.py` to use parquet data (matches rest of codebase) - - Removed silent CSV exception handling (build now fails loudly if parquet read fails) - - Added THEME_MIN_CARDS filtering directly in build pipeline (themes below threshold excluded during generation) - - `theme_list.json` now auto-generated from stripped parquet data after theme stripping - - Eliminated manual JSON stripping step (JSON is derived artifact, not source of truth) -- **Parquet Theme Stripping**: Strip low-card themes directly from card data files - - Added `strip_parquet_themes.py` script with dry-run, verbose, and backup modes - - Added parquet manipulation functions to `theme_stripper.py`: `backup_parquet_file()`, `filter_theme_tags()`, `update_parquet_theme_tags()`, `strip_parquet_themes()` - - Handles multiple themeTags formats: numpy arrays, lists, and comma/pipe-separated strings - - Stripped 97 theme tag occurrences from 30,674 cards in `all_cards.parquet` - - Updated `stripped_themes.yml` log with 520 themes stripped from parquet source - - **Automatic integration**: Theme stripping now runs automatically in `run_tagging()` after tagging completes (when `THEME_MIN_CARDS` > 1, default: 5) - - Integrated into web UI setup, CLI tagging, and CI/CD workflows (build-similarity-cache) - -### Fixed -- **Counter Type Tags**: Fixed leading spaces in theme names for Blood and Hone counter types - - Corrected ` Blood` to `Blood` and ` Hone` to `Hone` in `tag_constants.py` COUNTER_TYPES list - - Prevents creation of malformed theme names like ` Blood Counters` (with leading space) - - Requires re-tagging to regenerate parquet files and theme catalog with corrected names +- **Multi-copy include count**: Archetype cards in Must Include now inject the correct count instead of always adding 1 copy ### Removed _No unreleased changes yet_ diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index a7eadd7..79b2ace 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1404,8 +1404,37 @@ class DeckBuilder( logger.info(f"INCLUDE_INJECTION: Starting injection of {len(validated_includes)} include cards") # Inject each valid include card + active_mc = getattr(self, '_web_multi_copy', None) or getattr(self, '_multi_copy', None) + active_mc_name = str(active_mc.get('name', '')).strip().lower() if active_mc else None for card_name in validated_includes: - if not card_name or card_name in self.card_library: + if not card_name: + continue + + # R13: Multi-copy archetype include — set count directly rather than calling add_card once + if active_mc and active_mc_name and card_name.strip().lower() == active_mc_name: + mc_count = max(1, int(active_mc.get('count', 1))) + if card_name in self.card_library: + self.card_library[card_name]['Count'] = mc_count + else: + card_info = self._find_card_in_pool(card_name) + if card_info: + self.add_card( + card_name=card_name, + card_type=card_info.get('type', card_info.get('type_line', '')), + mana_cost=card_info.get('mana_cost', card_info.get('manaCost', '')), + mana_value=card_info.get('mana_value', card_info.get('manaValue', card_info.get('cmc', None))), + creature_types=card_info.get('creatureTypes', []), + tags=card_info.get('themeTags', []), + role='include', + added_by='include_injection', + ) + if card_name in self.card_library: + self.card_library[card_name]['Count'] = mc_count + injected_cards.append(card_name) + logger.info(f"INCLUDE_ADD (multi-copy archetype, count={mc_count}): {card_name}") + continue + + if card_name in self.card_library: continue # Skip empty names or already added cards # Attempt to find card in available pool for metadata enrichment diff --git a/code/tests/test_commander_build_cta.py b/code/tests/test_commander_build_cta.py index 337edf7..5c0b239 100644 --- a/code/tests/test_commander_build_cta.py +++ b/code/tests/test_commander_build_cta.py @@ -78,20 +78,3 @@ def test_commander_launch_preselects_commander_and_requires_theme(client: TestCl assert init_match is not None assert _html.unescape(init_match.group(1)) == commander_name assert "Back to Commanders" in body - - step2 = client.get("/build/step2") - assert step2.status_code == 200 - step2_body = step2.text - assert commander_name in _html.unescape(step2_body) - assert 'name="primary_tag"' in step2_body - - submit = client.post( - "/build/step2", - data={ - "commander": commander_name, - "bracket": "3", - "tag_mode": "AND", - }, - ) - assert submit.status_code == 200 - assert "Please choose a primary theme." in submit.text diff --git a/code/tests/test_web_new_deck_partner.py b/code/tests/test_web_new_deck_partner.py index 655f081..0d7dec1 100644 --- a/code/tests/test_web_new_deck_partner.py +++ b/code/tests/test_web_new_deck_partner.py @@ -203,42 +203,6 @@ def test_rory_partner_options_only_include_amy() -> None: assert rows == [("Amy Pond", "partner_with", "Partner With")] -def test_step2_tags_merge_partner_union() -> None: - commander = "Akiri, Line-Slinger" - secondary = "Silas Renn, Seeker Adept" - builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - combined = apply_partner_inputs( - builder, - primary_name=commander, - secondary_name=secondary, - feature_enabled=True, - ) - expected_tags = set(combined.theme_tags if combined else ()) - assert expected_tags, "expected combined commander to produce theme tags" - - client = _fresh_client() - with client: - client.get("/build/new") - primary_tag = _first_commander_tag(commander) - form_data = { - "name": "Tag Merge", - "commander": commander, - "partner_enabled": "1", - "secondary_commander": secondary, - "partner_auto_opt_out": "0", - "bracket": "3", - } - if primary_tag: - form_data["primary_tag"] = primary_tag - client.post("/build/new", data=form_data) - - resp = client.get("/build/step2") - assert resp.status_code == 200 - body = resp.text - for tag in expected_tags: - assert tag in body - - def test_step5_summary_displays_combined_partner_details() -> None: commander = "Halana, Kessig Ranger" secondary = "Alena, Kessig Trapper" diff --git a/code/web/routes/build_include_exclude.py b/code/web/routes/build_include_exclude.py index caa4ed8..19e4f80 100644 --- a/code/web/routes/build_include_exclude.py +++ b/code/web/routes/build_include_exclude.py @@ -8,9 +8,10 @@ for deck building, including the card toggle endpoint and summary rendering. from __future__ import annotations from typing import Any -from fastapi import APIRouter, Request, Form +from fastapi import APIRouter, Request, Form, Query from fastapi.responses import HTMLResponse, JSONResponse +from deck_builder import builder_constants as bc from ..app import ALLOW_MUST_HAVES, templates from ..services.build_utils import step5_base_ctx from ..services.tasks import get_session, new_sid @@ -21,6 +22,15 @@ from .build import _merge_hx_trigger router = APIRouter() +def _is_multi_copy_archetype(card_name: str) -> dict | None: + """Return archetype dict if card_name exactly matches a known multi-copy archetype, else None.""" + normalized = card_name.strip().lower() + for archetype in bc.MULTI_COPY_ARCHETYPES.values(): + if str(archetype.get("name", "")).strip().lower() == normalized: + return archetype + return None + + def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]: """ Extract include/exclude card lists and enforcement settings from session. @@ -129,6 +139,22 @@ async def toggle_must_haves( sid = new_sid() sess = get_session(sid) + # R13: Multi-copy archetype conflict detection + if enabled_flag and ALLOW_MUST_HAVES: + archetype = _is_multi_copy_archetype(name) + if list_key == "include" and archetype: + ctx = {"request": request, "archetype": archetype, "card_name": name} + resp = templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + if list_key == "exclude": + active_mc = sess.get("multi_copy") or {} + if active_mc and str(active_mc.get("name", "")).strip().lower() == name.lower(): + ctx = {"request": request, "archetype": active_mc, "card_name": name} + resp = templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + includes = list(sess.get("include_cards") or []) excludes = list(sess.get("exclude_cards") or []) include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()} @@ -214,3 +240,129 @@ async def toggle_must_haves( except Exception: pass return response + + +@router.get("/must-haves/summary", response_class=HTMLResponse) +async def must_haves_summary(request: Request) -> HTMLResponse: + """Return the current include/exclude summary fragment (used by dialog cancel actions).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + return _render_include_exclude_summary(request, sess, sid) + + +@router.get("/must-haves/multicopy-dialog", response_class=HTMLResponse) +async def multicopy_include_dialog_view( + request: Request, + card_name: str = Query(...), +) -> HTMLResponse: + """Return the count-picker dialog fragment for a multi-copy archetype card.""" + archetype = _is_multi_copy_archetype(card_name) + if not archetype: + return HTMLResponse("", status_code=200) + ctx = {"request": request, "archetype": archetype, "card_name": card_name} + return templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx) + + +@router.get("/must-haves/exclude-archetype-warning", response_class=HTMLResponse) +async def exclude_archetype_warning_view( + request: Request, + card_name: str = Query(...), +) -> HTMLResponse: + """Return the warning dialog fragment for excluding the currently-active archetype.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + active_mc = sess.get("multi_copy") or {} + ctx = {"request": request, "archetype": active_mc, "card_name": card_name} + return templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx) + + +@router.post("/must-haves/save-archetype-include", response_class=HTMLResponse) +async def save_archetype_include( + request: Request, + card_name: str = Form(...), + choice_id: str = Form(...), + count: int = Form(None), + thrumming: str | None = Form(None), +) -> HTMLResponse: + """Save multi-copy archetype selection and add card to the must-include list. + + Combines the archetype save (multicopy/save) and include toggle into a single + action, called from the multicopy_include_dialog when the user confirms. + """ + if not ALLOW_MUST_HAVES: + return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403) + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Persist archetype selection (mirrors multicopy/save logic) + meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {}) + archetype_name = meta.get("name") or card_name.strip() + if count is None: + count = int(meta.get("default_count", 25)) + try: + count = int(count) + except Exception: + count = int(meta.get("default_count", 25)) + printed_cap = meta.get("printed_cap") + if isinstance(printed_cap, int) and printed_cap > 0: + count = max(1, min(printed_cap, count)) + sess["multi_copy"] = { + "id": str(choice_id), + "name": archetype_name, + "count": count, + "thrumming": str(thrumming or "").strip().lower() in {"1", "true", "on", "yes"}, + } + try: + if "mc_applied_key" in sess: + del sess["mc_applied_key"] + except Exception: + pass + + # Add card to include list + name = card_name.strip() + key = name.lower() + includes = list(sess.get("include_cards") or []) + include_lookup = {str(v).strip().lower(): str(v) for v in includes} + if key not in include_lookup: + includes.append(name) + # Remove from excludes if present + excludes = [c for c in (sess.get("exclude_cards") or []) if str(c).strip().lower() != key] + sess["include_cards"] = includes + sess["exclude_cards"] = excludes + + return _render_include_exclude_summary(request, sess, sid) + + +@router.post("/must-haves/clear-archetype", response_class=HTMLResponse) +async def clear_archetype( + request: Request, + card_name: str | None = Form(None), +) -> HTMLResponse: + """Clear the active multi-copy archetype and optionally add card to the exclude list. + + Called when user confirms the exclude-archetype-warning dialog. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + for k in ("multi_copy", "mc_applied_key", "mc_seen_keys"): + try: + if k in sess: + del sess[k] + except Exception: + pass + + if card_name: + name = str(card_name).strip() + key = name.lower() + excludes = list(sess.get("exclude_cards") or []) + exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes} + if key not in exclude_lookup: + excludes.append(name) + # Remove from includes if present + includes = [c for c in (sess.get("include_cards") or []) if str(c).strip().lower() != key] + sess["include_cards"] = includes + sess["exclude_cards"] = excludes + + return _render_include_exclude_summary(request, sess, sid) diff --git a/code/web/routes/build_multicopy.py b/code/web/routes/build_multicopy.py index ee1eb16..a7d3c48 100644 --- a/code/web/routes/build_multicopy.py +++ b/code/web/routes/build_multicopy.py @@ -150,6 +150,10 @@ async def multicopy_check(request: Request) -> HTMLResponse: pass # Detect viable archetypes results = bu.detect_viable_multi_copy_archetypes(tmp) or [] + # R13: Filter out archetypes whose card is in the must-exclude list + exclude_names = {str(c).strip().lower() for c in (sess.get("exclude_cards") or [])} + if exclude_names: + results = [r for r in results if str(r.get("name", "")).strip().lower() not in exclude_names] if not results: # Remember this key to avoid re-checking until tags/commander change try: diff --git a/code/web/routes/build_newflow.py b/code/web/routes/build_newflow.py index e270a7e..d1d3725 100644 --- a/code/web/routes/build_newflow.py +++ b/code/web/routes/build_newflow.py @@ -37,12 +37,27 @@ from .build_partners import ( _partner_ui_context, _resolve_partner_selection, ) -from .build_wizard import _prepare_step2_theme_data, _section_themes_by_pool_size # R21: Pool size data +from .build_themes import _prepare_step2_theme_data, _section_themes_by_pool_size from ..services import custom_theme_manager as theme_mgr router = APIRouter() +# Pre-built JS-serialisable map of multi-copy archetypes for client-side popup detection. +# Keys are lowercased card names; values contain what the popup needs. +_ARCHETYPE_JS_MAP: dict[str, dict] = { + str(a["name"]).strip().lower(): { + "id": a["id"], + "name": a["name"], + "default_count": a.get("default_count", 25), + "printed_cap": a.get("printed_cap"), + "rec_window": list(a["rec_window"]) if a.get("rec_window") else None, + "thrumming_stone_synergy": bool(a.get("thrumming_stone_synergy", False)), + } + for a in bc.MULTI_COPY_ARCHETYPES.values() +} + + # ============================================================================== # New Deck Modal and Commander Search # ============================================================================== @@ -99,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": { "commander": sess.get("commander", ""), # Pre-fill for quick-build "prefer_combos": bool(sess.get("prefer_combos")), @@ -485,6 +501,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(suggested), "tag_slot_html": None, } @@ -510,6 +527,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(commander), "tag_slot_html": None, } @@ -615,6 +633,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, } @@ -754,6 +773,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, } diff --git a/code/web/routes/build_themes.py b/code/web/routes/build_themes.py index 5255822..cc1604d 100644 --- a/code/web/routes/build_themes.py +++ b/code/web/routes/build_themes.py @@ -20,11 +20,58 @@ from ..app import ( ) from ..services.tasks import get_session, new_sid from ..services import custom_theme_manager as theme_mgr +from ..services.theme_catalog_loader import load_index, slugify router = APIRouter() +def _prepare_step2_theme_data(tags: list[str], recommended: list[str]) -> tuple[list[str], list[str], dict[str, int]]: + """Load pool size data and sort themes for display. + + Returns: + Tuple of (sorted_tags, sorted_recommended, pool_size_dict) + """ + import logging + logger = logging.getLogger(__name__) + + try: + theme_index = load_index() + pool_size_by_slug = theme_index.pool_size_by_slug + except Exception as e: + logger.warning(f"Failed to load theme index for pool sizes: {e}") + pool_size_by_slug = {} + + def sort_by_pool_size(theme_list: list[str]) -> list[str]: + return sorted( + theme_list, + key=lambda t: (-pool_size_by_slug.get(slugify(t), 0), t.lower()) + ) + + return sort_by_pool_size(tags), sort_by_pool_size(recommended), pool_size_by_slug + + +def _section_themes_by_pool_size(themes: list[str], pool_size: dict[str, int]) -> list[dict[str, Any]]: + """Group themes into sections by pool size. + + Thresholds: Vast ≥1000, Large 500-999, Moderate 200-499, Small 50-199, Tiny <50 + """ + sections = [ + {"label": "Vast", "min": 1000, "max": 9999999, "themes": []}, + {"label": "Large", "min": 500, "max": 999, "themes": []}, + {"label": "Moderate", "min": 200, "max": 499, "themes": []}, + {"label": "Small", "min": 50, "max": 199, "themes": []}, + {"label": "Tiny", "min": 0, "max": 49, "themes": []}, + ] + for theme in themes: + theme_pool = pool_size.get(slugify(theme), 0) + for section in sections: + if section["min"] <= theme_pool <= section["max"]: + section["themes"].append(theme) + break + return [s for s in sections if s["themes"]] + + _INVALID_THEME_MESSAGE = ( "Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores." ) diff --git a/code/web/routes/build_wizard.py b/code/web/routes/build_wizard.py index e6283db..8885496 100644 --- a/code/web/routes/build_wizard.py +++ b/code/web/routes/build_wizard.py @@ -13,7 +13,7 @@ Extracted from build.py as part of Phase 3 modularization (Roadmap 9 M1). from __future__ import annotations from fastapi import APIRouter, Request, Form, Query -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from typing import Any from ..app import templates, ENABLE_PARTNER_MECHANICS, THEME_POOL_SECTIONS @@ -182,25 +182,12 @@ def _get_current_deck_names(sess: dict) -> list[str]: @router.get("/step1", response_class=HTMLResponse) async def build_step1(request: Request) -> HTMLResponse: - """Display commander search form.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp + return RedirectResponse("/build", status_code=302) @router.post("/step1", response_class=HTMLResponse) -async def build_step1_search( - request: Request, - query: str = Form(""), - auto: str | None = Form(None), - active: str | None = Form(None), -) -> HTMLResponse: - """Search for commander candidates and optionally auto-select.""" - query = (query or "").strip() - auto_enabled = True if (auto == "1") else False +async def build_step1_search(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) candidates = [] if query: candidates = orch.commander_candidates(query, limit=10) @@ -275,11 +262,8 @@ async def build_step1_search( @router.post("/step1/inspect", response_class=HTMLResponse) -async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: - """Preview commander details before confirmation.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 +async def build_step1_inspect(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) info = orch.commander_inspect(name) resp = templates.TemplateResponse( "build/_step1.html", @@ -290,9 +274,8 @@ async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLRe @router.post("/step1/confirm", response_class=HTMLResponse) -async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: - """Confirm commander selection and proceed to step 2.""" - res = orch.commander_select(name) +async def build_step1_confirm(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) if not res.get("ok"): sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) @@ -381,23 +364,7 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe @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 + return RedirectResponse("/build", status_code=302) # ============================================================================ @@ -406,11 +373,7 @@ async def build_reset_all(request: Request) -> HTMLResponse: @router.get("/step2", response_class=HTMLResponse) async def build_step2_get(request: Request) -> HTMLResponse: - """Display theme picker and partner selection.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 - commander = sess.get("commander") + return RedirectResponse("/build", status_code=302) if not commander: # Fallback to step1 if no commander in session resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) @@ -513,24 +476,8 @@ async def build_step2_get(request: Request) -> HTMLResponse: @router.post("/step2", response_class=HTMLResponse) -async def build_step2_submit( - request: Request, - 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(...), - partner_enabled: str | None = Form(None), - secondary_commander: str | None = Form(None), - background: str | None = Form(None), - partner_selection_source: str | None = Form(None), - partner_auto_opt_out: str | None = Form(None), -) -> HTMLResponse: - """Submit theme and partner selections, proceed to step 3.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 +async def build_step2_submit(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) partner_feature_enabled = ENABLE_PARTNER_MECHANICS partner_flag = False @@ -776,11 +723,7 @@ async def build_step2_submit( @router.get("/step3", response_class=HTMLResponse) async def build_step3_get(request: Request) -> HTMLResponse: - """Display ideal card count sliders.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 3 - defaults = orch.ideal_defaults() + return RedirectResponse("/build", status_code=302) values = sess.get("ideals") or defaults # Check if any skip flags are enabled to show skeleton automation page @@ -850,19 +793,8 @@ async def build_step3_get(request: Request) -> HTMLResponse: @router.post("/step3", response_class=HTMLResponse) -async def build_step3_submit( - request: Request, - ramp: int = Form(...), - lands: int = Form(...), - basic_lands: int = Form(...), - creatures: int = Form(...), - removal: int = Form(...), - wipes: int = Form(...), - card_advantage: int = Form(...), - protection: int = Form(...), -) -> HTMLResponse: - """Submit ideal card counts, proceed to step 4.""" - labels = orch.ideal_labels() +async def build_step3_submit(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) submitted = { "ramp": ramp, "lands": lands, @@ -944,11 +876,7 @@ async def build_step3_submit( @router.get("/step4", response_class=HTMLResponse) async def build_step4_get(request: Request) -> HTMLResponse: - """Display review page with owned card preferences.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 4 - labels = orch.ideal_labels() + return RedirectResponse("/build", status_code=302) values = sess.get("ideals") or orch.ideal_defaults() commander = sess.get("commander") return templates.TemplateResponse( @@ -966,17 +894,8 @@ async def build_step4_get(request: Request) -> HTMLResponse: @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), - swap_mdfc_basics: 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) - sess["last_step"] = 4 - only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False +async def build_toggle_owned_review(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False sess["use_owned_only"] = only_val @@ -1024,19 +943,12 @@ async def build_step5_get(request: Request) -> HTMLResponse: @router.get("/step5/start", response_class=HTMLResponse) async def build_step5_start_get(request: Request) -> HTMLResponse: - """Allow GET as a fallback to start the build (delegates to POST handler).""" - return await build_step5_start(request) + return RedirectResponse("/build", status_code=302) @router.post("/step5/start", response_class=HTMLResponse) async def build_step5_start(request: Request) -> HTMLResponse: - """Initialize build context and run first stage.""" - 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") + return RedirectResponse("/build", status_code=302) if not commander: resp = templates.TemplateResponse( "build/_step1.html", diff --git a/code/web/services/tasks.py b/code/web/services/tasks.py index a548252..9e217e5 100644 --- a/code/web/services/tasks.py +++ b/code/web/services/tasks.py @@ -195,3 +195,18 @@ def cleanup_expired() -> int: Number of sessions cleaned up """ return _get_manager().cleanup_state() + + +class _SessionsProxy: + """Dict-like proxy exposing internal session state — for test isolation only.""" + def clear(self) -> None: + _get_manager()._state.clear() + + def get(self, key: str, default: Any = None) -> Any: + return _get_manager()._state.get(key, default) + + def pop(self, key: str, *args: Any) -> Any: + return _get_manager()._state.pop(key, *args) + + +_SESSIONS = _SessionsProxy() diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index 0000fcd..b3f8111 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -241,6 +241,56 @@ + {% if allow_must_haves and multi_copy_archetypes_js %} + {# Multi-copy archetype confirmation dialog — shown when user types an archetype in must-include #} +
+ {# Conflict dialog — shown when a selected multicopy archetype also appears in the exclude list #} + + + {% endif %} @@ -1559,4 +1609,155 @@ } } })(); + + + \ No newline at end of file diff --git a/code/web/templates/partials/multicopy_exclude_warning.html b/code/web/templates/partials/multicopy_exclude_warning.html new file mode 100644 index 0000000..d4ef323 --- /dev/null +++ b/code/web/templates/partials/multicopy_exclude_warning.html @@ -0,0 +1,70 @@ + + + +