feat: revamp multicopy flow with include/exclude conflict dialogs (#60)
Some checks failed
CI / build (push) Has been cancelled

* feat: revamp multicopy flow with include/exclude conflict dialogs

* feat: revamp multicopy flow with include/exclude conflict dialogs
This commit is contained in:
mwisnowski 2026-03-21 19:39:51 -07:00 committed by GitHub
parent 4aa41adb20
commit 1aa8e4d7e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 665 additions and 252 deletions

View file

@ -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 - **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 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` - **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 ### Changed
_No unreleased changes yet_ _No unreleased changes yet_
### Fixed ### 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 ### Removed
_No unreleased changes yet_ _No unreleased changes yet_

View file

@ -5,98 +5,14 @@
- **RandomService**: Service wrapper for seeded RNG with validation (`code/web/services/random_service.py`) - **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 diagnostics**: `GET /api/random/diagnostics` endpoint (requires `WEB_RANDOM_DIAGNOSTICS=1`)
- **Random Mode docs**: `docs/random_mode/` covering seed infrastructure, developer guide, and diagnostics - **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 ### Changed
_No unreleased changes yet_ _No unreleased changes yet_
### Fixed ### Fixed
_No unreleased changes yet_ - **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_
## [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
### Removed ### Removed
_No unreleased changes yet_ _No unreleased changes yet_

View file

@ -1404,8 +1404,37 @@ class DeckBuilder(
logger.info(f"INCLUDE_INJECTION: Starting injection of {len(validated_includes)} include cards") logger.info(f"INCLUDE_INJECTION: Starting injection of {len(validated_includes)} include cards")
# Inject each valid include card # 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: 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 continue # Skip empty names or already added cards
# Attempt to find card in available pool for metadata enrichment # Attempt to find card in available pool for metadata enrichment

View file

@ -78,20 +78,3 @@ def test_commander_launch_preselects_commander_and_requires_theme(client: TestCl
assert init_match is not None assert init_match is not None
assert _html.unescape(init_match.group(1)) == commander_name assert _html.unescape(init_match.group(1)) == commander_name
assert "Back to Commanders" in body 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

View file

@ -203,42 +203,6 @@ def test_rory_partner_options_only_include_amy() -> None:
assert rows == [("Amy Pond", "partner_with", "Partner With")] 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: def test_step5_summary_displays_combined_partner_details() -> None:
commander = "Halana, Kessig Ranger" commander = "Halana, Kessig Ranger"
secondary = "Alena, Kessig Trapper" secondary = "Alena, Kessig Trapper"

View file

@ -8,9 +8,10 @@ for deck building, including the card toggle endpoint and summary rendering.
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from deck_builder import builder_constants as bc
from ..app import ALLOW_MUST_HAVES, templates from ..app import ALLOW_MUST_HAVES, templates
from ..services.build_utils import step5_base_ctx from ..services.build_utils import step5_base_ctx
from ..services.tasks import get_session, new_sid from ..services.tasks import get_session, new_sid
@ -21,6 +22,15 @@ from .build import _merge_hx_trigger
router = APIRouter() 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]]: def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]:
""" """
Extract include/exclude card lists and enforcement settings from session. Extract include/exclude card lists and enforcement settings from session.
@ -129,6 +139,22 @@ async def toggle_must_haves(
sid = new_sid() sid = new_sid()
sess = get_session(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 []) includes = list(sess.get("include_cards") or [])
excludes = list(sess.get("exclude_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()} 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: except Exception:
pass pass
return response 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)

View file

@ -150,6 +150,10 @@ async def multicopy_check(request: Request) -> HTMLResponse:
pass pass
# Detect viable archetypes # Detect viable archetypes
results = bu.detect_viable_multi_copy_archetypes(tmp) or [] 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: if not results:
# Remember this key to avoid re-checking until tags/commander change # Remember this key to avoid re-checking until tags/commander change
try: try:

View file

@ -37,12 +37,27 @@ from .build_partners import (
_partner_ui_context, _partner_ui_context,
_resolve_partner_selection, _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 from ..services import custom_theme_manager as theme_mgr
router = APIRouter() 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 # 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_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD, "enable_batch_build": ENABLE_BATCH_BUILD,
"ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider'
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": { "form": {
"commander": sess.get("commander", ""), # Pre-fill for quick-build "commander": sess.get("commander", ""), # Pre-fill for quick-build
"prefer_combos": bool(sess.get("prefer_combos")), "prefer_combos": bool(sess.get("prefer_combos")),
@ -485,6 +501,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD, "enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(suggested), "form": _form_state(suggested),
"tag_slot_html": None, "tag_slot_html": None,
} }
@ -510,6 +527,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD, "enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(commander), "form": _form_state(commander),
"tag_slot_html": None, "tag_slot_html": None,
} }
@ -615,6 +633,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD, "enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(primary_commander_name), "form": _form_state(primary_commander_name),
"tag_slot_html": tag_slot_html, "tag_slot_html": tag_slot_html,
} }
@ -754,6 +773,7 @@ async def build_new_submit(
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_custom_themes": ENABLE_CUSTOM_THEMES,
"enable_batch_build": ENABLE_BATCH_BUILD, "enable_batch_build": ENABLE_BATCH_BUILD,
"multi_copy_archetypes_js": _ARCHETYPE_JS_MAP,
"form": _form_state(sess.get("commander", "")), "form": _form_state(sess.get("commander", "")),
"tag_slot_html": None, "tag_slot_html": None,
} }

View file

@ -20,11 +20,58 @@ from ..app import (
) )
from ..services.tasks import get_session, new_sid from ..services.tasks import get_session, new_sid
from ..services import custom_theme_manager as theme_mgr from ..services import custom_theme_manager as theme_mgr
from ..services.theme_catalog_loader import load_index, slugify
router = APIRouter() 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 = ( _INVALID_THEME_MESSAGE = (
"Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores." "Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores."
) )

View file

@ -13,7 +13,7 @@ Extracted from build.py as part of Phase 3 modularization (Roadmap 9 M1).
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Request, Form, Query from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from typing import Any from typing import Any
from ..app import templates, ENABLE_PARTNER_MECHANICS, THEME_POOL_SECTIONS 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) @router.get("/step1", response_class=HTMLResponse)
async def build_step1(request: Request) -> HTMLResponse: async def build_step1(request: Request) -> HTMLResponse:
"""Display commander search form.""" return RedirectResponse("/build", status_code=302)
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
@router.post("/step1", response_class=HTMLResponse) @router.post("/step1", response_class=HTMLResponse)
async def build_step1_search( async def build_step1_search(request: Request) -> HTMLResponse:
request: Request, return RedirectResponse("/build", status_code=302)
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
candidates = [] candidates = []
if query: if query:
candidates = orch.commander_candidates(query, limit=10) candidates = orch.commander_candidates(query, limit=10)
@ -275,11 +262,8 @@ async def build_step1_search(
@router.post("/step1/inspect", response_class=HTMLResponse) @router.post("/step1/inspect", response_class=HTMLResponse)
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: async def build_step1_inspect(request: Request) -> HTMLResponse:
"""Preview commander details before confirmation.""" return RedirectResponse("/build", status_code=302)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
info = orch.commander_inspect(name) info = orch.commander_inspect(name)
resp = templates.TemplateResponse( resp = templates.TemplateResponse(
"build/_step1.html", "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) @router.post("/step1/confirm", response_class=HTMLResponse)
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: async def build_step1_confirm(request: Request) -> HTMLResponse:
"""Confirm commander selection and proceed to step 2.""" return RedirectResponse("/build", status_code=302)
res = orch.commander_select(name)
if not res.get("ok"): if not res.get("ok"):
sid = request.cookies.get("sid") or new_sid() sid = request.cookies.get("sid") or new_sid()
sess = get_session(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) @router.post("/reset-all", response_class=HTMLResponse)
async def build_reset_all(request: Request) -> HTMLResponse: async def build_reset_all(request: Request) -> HTMLResponse:
"""Clear all build-related session state and return Step 1.""" return RedirectResponse("/build", status_code=302)
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
# ============================================================================ # ============================================================================
@ -406,11 +373,7 @@ async def build_reset_all(request: Request) -> HTMLResponse:
@router.get("/step2", response_class=HTMLResponse) @router.get("/step2", response_class=HTMLResponse)
async def build_step2_get(request: Request) -> HTMLResponse: async def build_step2_get(request: Request) -> HTMLResponse:
"""Display theme picker and partner selection.""" return RedirectResponse("/build", status_code=302)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
commander = sess.get("commander")
if not commander: if not commander:
# Fallback to step1 if no commander in session # Fallback to step1 if no commander in session
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) 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) @router.post("/step2", response_class=HTMLResponse)
async def build_step2_submit( async def build_step2_submit(request: Request) -> HTMLResponse:
request: Request, return RedirectResponse("/build", status_code=302)
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
partner_feature_enabled = ENABLE_PARTNER_MECHANICS partner_feature_enabled = ENABLE_PARTNER_MECHANICS
partner_flag = False partner_flag = False
@ -776,11 +723,7 @@ async def build_step2_submit(
@router.get("/step3", response_class=HTMLResponse) @router.get("/step3", response_class=HTMLResponse)
async def build_step3_get(request: Request) -> HTMLResponse: async def build_step3_get(request: Request) -> HTMLResponse:
"""Display ideal card count sliders.""" return RedirectResponse("/build", status_code=302)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
defaults = orch.ideal_defaults()
values = sess.get("ideals") or defaults values = sess.get("ideals") or defaults
# Check if any skip flags are enabled to show skeleton automation page # 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) @router.post("/step3", response_class=HTMLResponse)
async def build_step3_submit( async def build_step3_submit(request: Request) -> HTMLResponse:
request: Request, return RedirectResponse("/build", status_code=302)
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()
submitted = { submitted = {
"ramp": ramp, "ramp": ramp,
"lands": lands, "lands": lands,
@ -944,11 +876,7 @@ async def build_step3_submit(
@router.get("/step4", response_class=HTMLResponse) @router.get("/step4", response_class=HTMLResponse)
async def build_step4_get(request: Request) -> HTMLResponse: async def build_step4_get(request: Request) -> HTMLResponse:
"""Display review page with owned card preferences.""" return RedirectResponse("/build", status_code=302)
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults() values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander") commander = sess.get("commander")
return templates.TemplateResponse( return templates.TemplateResponse(
@ -966,17 +894,8 @@ async def build_step4_get(request: Request) -> HTMLResponse:
@router.post("/toggle-owned-review", response_class=HTMLResponse) @router.post("/toggle-owned-review", response_class=HTMLResponse)
async def build_toggle_owned_review( async def build_toggle_owned_review(request: Request) -> HTMLResponse:
request: Request, return RedirectResponse("/build", status_code=302)
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
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).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 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) @router.get("/step5/start", response_class=HTMLResponse)
async def build_step5_start_get(request: Request) -> HTMLResponse: async def build_step5_start_get(request: Request) -> HTMLResponse:
"""Allow GET as a fallback to start the build (delegates to POST handler).""" return RedirectResponse("/build", status_code=302)
return await build_step5_start(request)
@router.post("/step5/start", response_class=HTMLResponse) @router.post("/step5/start", response_class=HTMLResponse)
async def build_step5_start(request: Request) -> HTMLResponse: async def build_step5_start(request: Request) -> HTMLResponse:
"""Initialize build context and run first stage.""" return RedirectResponse("/build", status_code=302)
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: if not commander:
resp = templates.TemplateResponse( resp = templates.TemplateResponse(
"build/_step1.html", "build/_step1.html",

View file

@ -195,3 +195,18 @@ def cleanup_expired() -> int:
Number of sessions cleaned up Number of sessions cleaned up
""" """
return _get_manager().cleanup_state() 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()

View file

@ -241,6 +241,56 @@
<button type="submit" class="btn-continue" id="create-btn">Create</button> <button type="submit" class="btn-continue" id="create-btn">Create</button>
</div> </div>
</div> </div>
{% if allow_must_haves and multi_copy_archetypes_js %}
{# Multi-copy archetype confirmation dialog — shown when user types an archetype in must-include #}
<div id="mc-include-confirm" role="dialog" aria-modal="true" aria-labelledby="mc-confirm-title"
style="display:none; position:fixed; inset:0; z-index:10000; align-items:center; justify-content:center;">
<div style="position:absolute; inset:0; background:rgba(0,0,0,.6);" onclick="window._mcConfirmClose()"></div>
<div style="position:relative; max-width:420px; width:clamp(290px,90vw,420px);
background:var(--surface,#0f1115); border:1px solid var(--border,#333);
border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1.25rem;">
<h3 id="mc-confirm-title" style="margin:0 0 .5rem; font-size:1rem;">Multi-copy package detected</h3>
<p id="mc-confirm-msg" class="muted" style="font-size:13px; margin:0 0 .75rem;"></p>
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.5rem;">
<label style="display:flex; align-items:center; gap:.35rem;">
Copies
<input type="number" id="mc-confirm-count" min="1" value="25"
style="width:5rem; margin-left:.25rem;">
</label>
<small id="mc-confirm-hint" class="muted"></small>
</div>
<div id="mc-confirm-thrumming-row" style="margin-bottom:.75rem;">
<label style="display:flex; align-items:center; gap:.4rem; font-size:13px; cursor:pointer;">
<input type="checkbox" id="mc-confirm-thrumming" checked>
Include <em>Thrumming Stone</em>
</label>
</div>
<div style="display:flex; gap:.5rem; justify-content:flex-end; flex-wrap:wrap;">
<button type="button" class="btn" onclick="window._mcConfirmClose()">Cancel</button>
<button type="button" class="btn-continue" onclick="window._mcConfirmSubmit()">Add to must-include</button>
</div>
</div>
</div>
{# Conflict dialog — shown when a selected multicopy archetype also appears in the exclude list #}
<div id="mc-exclude-conflict" role="dialog" aria-modal="true" aria-labelledby="mc-conflict-title"
style="display:none; position:fixed; inset:0; z-index:10001; align-items:center; justify-content:center;">
<div style="position:absolute; inset:0; background:rgba(0,0,0,.6);" onclick="window._mcConflictClose()"></div>
<div style="position:relative; max-width:440px; width:clamp(290px,90vw,440px);
background:var(--surface,#0f1115); border:1px solid var(--border,#333);
border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1.25rem;">
<h3 id="mc-conflict-title" style="margin:0 0 .5rem; font-size:1rem;">Multi-copy / Exclude conflict</h3>
<p id="mc-conflict-msg" class="muted" style="font-size:13px; margin:0 0 1rem;"></p>
<div style="display:flex; flex-direction:column; gap:.5rem;">
<button type="button" class="btn-continue" onclick="window._mcConflictKeepMulti()">Keep multi-copy (remove from excludes)</button>
<button type="button" class="btn-continue" onclick="window._mcConflictKeepExcludes()">Keep excludes (remove multi-copy)</button>
<button type="button" class="btn" onclick="window._mcConflictClose()">Cancel</button>
</div>
</div>
</div>
<script>
window._MC_ARCHETYPES = {{ multi_copy_archetypes_js | tojson }};
</script>
{% endif %}
</form> </form>
</div> </div>
</div> </div>
@ -1559,4 +1609,155 @@
} }
} }
})(); })();
</script>
<script>
// Multi-copy archetype popups — include-confirm and exclude-conflict
(function(){
if (!window._MC_ARCHETYPES) return;
var _pending = null;
var _pendingConflict = null;
// --- Include-confirm dialog ---
window._mcConfirmClose = function(){
var dlg = document.getElementById('mc-include-confirm');
if (dlg) dlg.style.display = 'none';
_pending = null;
};
window._mcConfirmSubmit = function(){
if (!_pending) return;
var form = _pending.form;
var archetype = _pending.archetype;
form.querySelectorAll('[data-mc-injected]').forEach(function(el){ el.remove(); });
function inj(name, value){
var h = document.createElement('input');
h.type = 'hidden'; h.name = name; h.value = value;
h.setAttribute('data-mc-injected', '1');
form.appendChild(h);
}
inj('enable_multicopy', '1');
inj('multi_choice_id', archetype.id);
inj('multi_count', document.getElementById('mc-confirm-count').value || archetype.default_count);
var thrum = document.getElementById('mc-confirm-thrumming');
if (thrum && thrum.checked) inj('multi_thrumming', '1');
window._mcConfirmClose();
form.requestSubmit();
};
// --- Exclude-conflict dialog ---
window._mcConflictClose = function(){
var dlg = document.getElementById('mc-exclude-conflict');
if (dlg) dlg.style.display = 'none';
_pendingConflict = null;
};
window._mcConflictKeepMulti = function(){
if (!_pendingConflict) return;
var form = _pendingConflict.form;
var archetype = _pendingConflict.archetype;
// Remove archetype name from exclude textarea
var exTa = document.getElementById('exclude_cards_textarea');
if (exTa) {
var exName = archetype.name.toLowerCase();
var filtered = exTa.value.split(/\n/).filter(function(line){
return line.trim().replace(/^\d+[x\s]+/i, '').toLowerCase() !== exName;
});
exTa.value = filtered.join('\n');
}
window._mcConflictClose();
form.requestSubmit();
};
window._mcConflictKeepExcludes = function(){
if (!_pendingConflict) return;
var form = _pendingConflict.form;
// Uncheck the multicopy enable checkbox so the server ignores it
var mcChk = form.querySelector('#pref-mc-chk');
if (mcChk) mcChk.checked = false;
window._mcConflictClose();
form.requestSubmit();
};
// Helper: show include-confirm dialog
function showIncludeConfirm(form, found){
_pending = { form: form, archetype: found };
document.getElementById('mc-confirm-msg').textContent =
'"' + found.name + '" is a multi-copy archetype card. How many copies should be included?';
var countIn = document.getElementById('mc-confirm-count');
countIn.value = found.default_count || 25;
if (found.printed_cap) countIn.max = found.printed_cap;
else countIn.removeAttribute('max');
var hint = document.getElementById('mc-confirm-hint');
if (found.printed_cap) hint.textContent = 'Max ' + found.printed_cap;
else if (found.rec_window) hint.textContent = 'Suggested ' + found.rec_window[0] + '\u2013' + found.rec_window[1];
else hint.textContent = '';
var thrumRow = document.getElementById('mc-confirm-thrumming-row');
var thrumChk = document.getElementById('mc-confirm-thrumming');
if (thrumRow) thrumRow.style.display = found.thrumming_stone_synergy ? '' : 'none';
if (thrumChk) thrumChk.checked = found.thrumming_stone_synergy;
var dlg = document.getElementById('mc-include-confirm');
if (dlg) dlg.style.display = 'flex';
}
// Intercept form submit in capture phase so we run before HTMX
document.addEventListener('submit', function(e){
try {
var form = e.target;
if (!form) return;
if (form.getAttribute('hx-post') !== '/build/new') return;
// Skip if hidden fields already injected (re-submit after a dialog confirm)
if (form.querySelector('input[data-mc-injected]')) return;
var mcChk = form.querySelector('#pref-mc-chk');
if (mcChk && mcChk.checked) {
// User is using the explicit multicopy selector — check for exclude conflict
var choiceRadio = form.querySelector('input[name="multi_choice_id"]:checked');
if (choiceRadio) {
var choiceId = choiceRadio.value;
var choiceArchetype = null;
var allA = window._MC_ARCHETYPES;
for (var ak in allA) { if (allA[ak].id === choiceId) { choiceArchetype = allA[ak]; break; } }
if (choiceArchetype) {
var exTa = document.getElementById('exclude_cards_textarea');
if (exTa && exTa.value.trim()) {
var exName = choiceArchetype.name.toLowerCase();
var exLines = exTa.value.split(/[\n,]+/);
for (var j = 0; j < exLines.length; j++) {
var exLine = exLines[j].trim().replace(/^\d+[x\s]+/i, '').toLowerCase();
if (exLine === exName) {
e.preventDefault();
e.stopImmediatePropagation();
_pendingConflict = { form: form, archetype: choiceArchetype };
document.getElementById('mc-conflict-msg').textContent =
'"' + choiceArchetype.name + '" is selected as your multi-copy archetype but also appears in your Exclude list. How would you like to proceed?';
var cdlg = document.getElementById('mc-exclude-conflict');
if (cdlg) cdlg.style.display = 'flex';
return;
}
}
}
}
}
return; // no conflict — let HTMX handle it
}
// Scan the include_cards textarea for archetype names
var ta = document.getElementById('include_cards_textarea');
if (!ta || !ta.value.trim()) return;
var lines = ta.value.split(/[\n,]+/);
var found = null;
for (var i = 0; i < lines.length; i++){
var key = lines[i].trim().toLowerCase();
if (key && window._MC_ARCHETYPES[key]){ found = window._MC_ARCHETYPES[key]; break; }
}
if (!found) return;
e.preventDefault();
e.stopImmediatePropagation();
showIncludeConfirm(form, found);
} catch(err){ /* never block submit on errors */ }
}, true);
})();
</script> </script>

View file

@ -0,0 +1,70 @@
<!-- #include-exclude-summary stays as empty placeholder so HTMX can target it -->
<div id="include-exclude-summary" data-summary></div>
<!-- Modal is appended to <body> by the script below to avoid CSS containment issues -->
<div id="mc-exclude-dialog" role="dialog" aria-modal="true" aria-labelledby="mc-excl-title"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center;">
<div id="mc-exclude-overlay"
style="position:absolute; inset:0; background:rgba(0,0,0,.6); cursor:default;"></div>
<div class="modal-content"
style="position:relative; max-width:440px; width:clamp(300px, 90vw, 440px);
background:#0f1115; border:1px solid var(--border); border-radius:10px;
box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1.25rem;">
<div class="modal-header" style="display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-bottom:.75rem;">
<h3 id="mc-excl-title" style="margin:0; font-size:1rem;">Remove multi-copy archetype?</h3>
<button type="button" class="btn" aria-label="Close" onclick="_mcExcludeClose()">×</button>
</div>
<p class="muted" style="font-size:13px; margin:.25rem 0 .75rem;">
<strong>{{ card_name }}</strong> is your currently selected multi-copy archetype
{% if archetype.count %}({{ archetype.count }} copies){% endif %}.
Excluding it will remove that selection and add the card to your must-exclude list.
</p>
<form id="mc-exclude-form"
hx-post="/build/must-haves/clear-archetype"
hx-target="#include-exclude-summary"
hx-swap="outerHTML">
<input type="hidden" name="card_name" value="{{ card_name | e }}">
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:.75rem;">
<button type="button" class="btn" onclick="_mcExcludeClose()">Cancel</button>
<button type="submit" class="btn" style="background:#7f1d1d; border-color:#991b1b; color:#fca5a5;">
Exclude &amp; remove archetype
</button>
</div>
</form>
</div>
</div>
<script>
(function(){
function _mcExcludeClose(){
var m=document.getElementById('mc-exclude-dialog');
if(m) m.remove();
try{
window.htmx.ajax('GET','/build/must-haves/summary',{
target:document.getElementById('include-exclude-summary'),
swap:'outerHTML'
});
}catch(_){}
}
window._mcExcludeClose = _mcExcludeClose;
var modal = document.getElementById('mc-exclude-dialog');
if(!modal) return;
document.body.appendChild(modal);
document.getElementById('mc-exclude-overlay').addEventListener('click', _mcExcludeClose);
document.addEventListener('keydown', function _mcEsc(e){
if(e.key !== 'Escape') return;
document.removeEventListener('keydown', _mcEsc);
_mcExcludeClose();
});
var form = document.getElementById('mc-exclude-form');
if(form){
form.addEventListener('htmx:afterRequest', function(){
var m=document.getElementById('mc-exclude-dialog');
if(m) m.remove();
});
}
})();
</script>

View file

@ -0,0 +1,98 @@
<!-- #include-exclude-summary stays as empty placeholder so HTMX can target it -->
<div id="include-exclude-summary" data-summary></div>
<!-- Modal is appended to <body> by the script below to avoid CSS containment issues -->
<div id="mc-include-dialog" role="dialog" aria-modal="true" aria-labelledby="mc-include-title"
style="position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center;">
<div id="mc-include-overlay"
style="position:absolute; inset:0; background:rgba(0,0,0,.6); cursor:default;"></div>
<div class="modal-content"
style="position:relative; max-width:440px; width:clamp(300px, 90vw, 440px);
background:#0f1115; border:1px solid var(--border); border-radius:10px;
box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1.25rem;">
<div class="modal-header" style="display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-bottom:.75rem;">
<h3 id="mc-include-title" style="margin:0; font-size:1rem;">Include multi-copy package?</h3>
<button type="button" class="btn" aria-label="Close" onclick="_mcIncludeClose()">×</button>
</div>
<p class="muted" style="font-size:13px; margin:.25rem 0 .75rem;">
<strong>{{ card_name }}</strong> is a multi-copy archetype card. Adding it to
your must-include list will configure it as your active multi-copy package.
</p>
<form id="mc-include-form"
hx-post="/build/must-haves/save-archetype-include"
hx-target="#include-exclude-summary"
hx-swap="outerHTML">
<input type="hidden" name="card_name" value="{{ card_name | e }}">
<input type="hidden" name="choice_id" value="{{ archetype.id | e }}">
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.5rem;">
<label>
Copies
<input type="number" name="count" min="1"
{% if archetype.printed_cap %}max="{{ archetype.printed_cap }}"{% endif %}
value="{{ archetype.default_count or 25 }}"
style="width:6rem; margin-left:.35rem;">
</label>
{% if archetype.printed_cap %}
<small class="muted">Max {{ archetype.printed_cap }}</small>
{% elif archetype.rec_window %}
<small class="muted">Suggested {{ archetype.rec_window[0] }}{{ archetype.rec_window[1] }}</small>
{% endif %}
</div>
{% if archetype.thrumming_stone_synergy %}
<div style="margin-bottom:.75rem;">
<label title="Adds 1 copy of Thrumming Stone to your deck.">
<input type="checkbox" name="thrumming" value="1" checked> Include Thrumming Stone
</label>
</div>
{% endif %}
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:.75rem;">
<button type="button" class="btn" onclick="_mcIncludeClose()">Cancel</button>
<button type="submit" class="btn-continue">Add to must-include</button>
</div>
</form>
</div>
</div>
<script>
(function(){
function _mcIncludeClose(){
var m=document.getElementById('mc-include-dialog');
if(m) m.remove();
try{
window.htmx.ajax('GET','/build/must-haves/summary',{
target:document.getElementById('include-exclude-summary'),
swap:'outerHTML'
});
}catch(_){}
}
window._mcIncludeClose = _mcIncludeClose;
var modal = document.getElementById('mc-include-dialog');
if(!modal) return;
document.body.appendChild(modal);
document.getElementById('mc-include-overlay').addEventListener('click', _mcIncludeClose);
document.addEventListener('keydown', function _mcEsc(e){
if(e.key !== 'Escape') return;
document.removeEventListener('keydown', _mcEsc);
_mcIncludeClose();
});
var form = document.getElementById('mc-include-form');
if(form){
form.addEventListener('htmx:afterRequest', function(){
var m=document.getElementById('mc-include-dialog');
if(m) m.remove();
});
}
})();
</script>
<script>
(function(){
document.addEventListener('keydown', function handler(e){
if (e.key === 'Escape'){
document.removeEventListener('keydown', handler);
try { htmx.ajax('GET', '/build/must-haves/summary', {target:'#include-exclude-summary', swap:'outerHTML'}); } catch(_){}
}
});
})();
</script>