mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
feat: revamp multicopy flow with include/exclude conflict dialogs (#60)
Some checks failed
CI / build (push) Has been cancelled
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:
parent
4aa41adb20
commit
1aa8e4d7e8
14 changed files with 665 additions and 252 deletions
|
|
@ -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_
|
||||||
|
|
|
||||||
|
|
@ -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_
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1560,3 +1610,154 @@
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
||||||
70
code/web/templates/partials/multicopy_exclude_warning.html
Normal file
70
code/web/templates/partials/multicopy_exclude_warning.html
Normal 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 & 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>
|
||||||
|
|
||||||
98
code/web/templates/partials/multicopy_include_dialog.html
Normal file
98
code/web/templates/partials/multicopy_include_dialog.html
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue