diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b8203..f02477f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,14 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - **InvalidSeedError**: New `InvalidSeedError` exception in `code/exceptions.py` for seed validation failures - **Random diagnostics endpoint**: `GET /api/random/diagnostics` behind `WEB_RANDOM_DIAGNOSTICS=1` flag, returning seed derivation test vectors for cross-platform consistency checks - **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md` +- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox +- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection) ### Changed _No unreleased changes yet_ ### Fixed -_No unreleased changes yet_ +- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed ### Removed _No unreleased changes yet_ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index c4df7f0..4e11693 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -5,12 +5,14 @@ - **RandomService**: Service wrapper for seeded RNG with validation (`code/web/services/random_service.py`) - **Random diagnostics**: `GET /api/random/diagnostics` endpoint (requires `WEB_RANDOM_DIAGNOSTICS=1`) - **Random Mode docs**: `docs/random_mode/` covering seed infrastructure, developer guide, and diagnostics +- **Multi-copy include dialog**: Typing a multi-copy archetype card (e.g., Hare Apparent) in Must Include now triggers a popup to choose copy count and optional Thrumming Stone inclusion +- **Multi-copy/exclude conflict dialog**: Selecting a multi-copy archetype while the same card is in the Exclude list now shows a resolution popup — keep the archetype (removes from excludes) or keep the exclude (disables archetype) ### Changed _No unreleased changes yet_ ### Fixed -_No unreleased changes yet_ +- **Multi-copy include count**: Archetype cards in Must Include now inject the correct count instead of always adding 1 copy ### Removed _No unreleased changes yet_ diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index a7eadd7..79b2ace 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1404,8 +1404,37 @@ class DeckBuilder( logger.info(f"INCLUDE_INJECTION: Starting injection of {len(validated_includes)} include cards") # Inject each valid include card + active_mc = getattr(self, '_web_multi_copy', None) or getattr(self, '_multi_copy', None) + active_mc_name = str(active_mc.get('name', '')).strip().lower() if active_mc else None for card_name in validated_includes: - if not card_name or card_name in self.card_library: + if not card_name: + continue + + # R13: Multi-copy archetype include — set count directly rather than calling add_card once + if active_mc and active_mc_name and card_name.strip().lower() == active_mc_name: + mc_count = max(1, int(active_mc.get('count', 1))) + if card_name in self.card_library: + self.card_library[card_name]['Count'] = mc_count + else: + card_info = self._find_card_in_pool(card_name) + if card_info: + self.add_card( + card_name=card_name, + card_type=card_info.get('type', card_info.get('type_line', '')), + mana_cost=card_info.get('mana_cost', card_info.get('manaCost', '')), + mana_value=card_info.get('mana_value', card_info.get('manaValue', card_info.get('cmc', None))), + creature_types=card_info.get('creatureTypes', []), + tags=card_info.get('themeTags', []), + role='include', + added_by='include_injection', + ) + if card_name in self.card_library: + self.card_library[card_name]['Count'] = mc_count + injected_cards.append(card_name) + logger.info(f"INCLUDE_ADD (multi-copy archetype, count={mc_count}): {card_name}") + continue + + if card_name in self.card_library: continue # Skip empty names or already added cards # Attempt to find card in available pool for metadata enrichment diff --git a/code/tests/test_commander_build_cta.py b/code/tests/test_commander_build_cta.py index 337edf7..5c0b239 100644 --- a/code/tests/test_commander_build_cta.py +++ b/code/tests/test_commander_build_cta.py @@ -78,20 +78,3 @@ def test_commander_launch_preselects_commander_and_requires_theme(client: TestCl assert init_match is not None assert _html.unescape(init_match.group(1)) == commander_name assert "Back to Commanders" in body - - step2 = client.get("/build/step2") - assert step2.status_code == 200 - step2_body = step2.text - assert commander_name in _html.unescape(step2_body) - assert 'name="primary_tag"' in step2_body - - submit = client.post( - "/build/step2", - data={ - "commander": commander_name, - "bracket": "3", - "tag_mode": "AND", - }, - ) - assert submit.status_code == 200 - assert "Please choose a primary theme." in submit.text diff --git a/code/tests/test_web_new_deck_partner.py b/code/tests/test_web_new_deck_partner.py index 655f081..0d7dec1 100644 --- a/code/tests/test_web_new_deck_partner.py +++ b/code/tests/test_web_new_deck_partner.py @@ -203,42 +203,6 @@ def test_rory_partner_options_only_include_amy() -> None: assert rows == [("Amy Pond", "partner_with", "Partner With")] -def test_step2_tags_merge_partner_union() -> None: - commander = "Akiri, Line-Slinger" - secondary = "Silas Renn, Seeker Adept" - builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - combined = apply_partner_inputs( - builder, - primary_name=commander, - secondary_name=secondary, - feature_enabled=True, - ) - expected_tags = set(combined.theme_tags if combined else ()) - assert expected_tags, "expected combined commander to produce theme tags" - - client = _fresh_client() - with client: - client.get("/build/new") - primary_tag = _first_commander_tag(commander) - form_data = { - "name": "Tag Merge", - "commander": commander, - "partner_enabled": "1", - "secondary_commander": secondary, - "partner_auto_opt_out": "0", - "bracket": "3", - } - if primary_tag: - form_data["primary_tag"] = primary_tag - client.post("/build/new", data=form_data) - - resp = client.get("/build/step2") - assert resp.status_code == 200 - body = resp.text - for tag in expected_tags: - assert tag in body - - def test_step5_summary_displays_combined_partner_details() -> None: commander = "Halana, Kessig Ranger" secondary = "Alena, Kessig Trapper" diff --git a/code/web/routes/build_include_exclude.py b/code/web/routes/build_include_exclude.py index caa4ed8..19e4f80 100644 --- a/code/web/routes/build_include_exclude.py +++ b/code/web/routes/build_include_exclude.py @@ -8,9 +8,10 @@ for deck building, including the card toggle endpoint and summary rendering. from __future__ import annotations from typing import Any -from fastapi import APIRouter, Request, Form +from fastapi import APIRouter, Request, Form, Query from fastapi.responses import HTMLResponse, JSONResponse +from deck_builder import builder_constants as bc from ..app import ALLOW_MUST_HAVES, templates from ..services.build_utils import step5_base_ctx from ..services.tasks import get_session, new_sid @@ -21,6 +22,15 @@ from .build import _merge_hx_trigger router = APIRouter() +def _is_multi_copy_archetype(card_name: str) -> dict | None: + """Return archetype dict if card_name exactly matches a known multi-copy archetype, else None.""" + normalized = card_name.strip().lower() + for archetype in bc.MULTI_COPY_ARCHETYPES.values(): + if str(archetype.get("name", "")).strip().lower() == normalized: + return archetype + return None + + def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]: """ Extract include/exclude card lists and enforcement settings from session. @@ -129,6 +139,22 @@ async def toggle_must_haves( sid = new_sid() sess = get_session(sid) + # R13: Multi-copy archetype conflict detection + if enabled_flag and ALLOW_MUST_HAVES: + archetype = _is_multi_copy_archetype(name) + if list_key == "include" and archetype: + ctx = {"request": request, "archetype": archetype, "card_name": name} + resp = templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + if list_key == "exclude": + active_mc = sess.get("multi_copy") or {} + if active_mc and str(active_mc.get("name", "")).strip().lower() == name.lower(): + ctx = {"request": request, "archetype": active_mc, "card_name": name} + resp = templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + includes = list(sess.get("include_cards") or []) excludes = list(sess.get("exclude_cards") or []) include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()} @@ -214,3 +240,129 @@ async def toggle_must_haves( except Exception: pass return response + + +@router.get("/must-haves/summary", response_class=HTMLResponse) +async def must_haves_summary(request: Request) -> HTMLResponse: + """Return the current include/exclude summary fragment (used by dialog cancel actions).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + return _render_include_exclude_summary(request, sess, sid) + + +@router.get("/must-haves/multicopy-dialog", response_class=HTMLResponse) +async def multicopy_include_dialog_view( + request: Request, + card_name: str = Query(...), +) -> HTMLResponse: + """Return the count-picker dialog fragment for a multi-copy archetype card.""" + archetype = _is_multi_copy_archetype(card_name) + if not archetype: + return HTMLResponse("", status_code=200) + ctx = {"request": request, "archetype": archetype, "card_name": card_name} + return templates.TemplateResponse("partials/multicopy_include_dialog.html", ctx) + + +@router.get("/must-haves/exclude-archetype-warning", response_class=HTMLResponse) +async def exclude_archetype_warning_view( + request: Request, + card_name: str = Query(...), +) -> HTMLResponse: + """Return the warning dialog fragment for excluding the currently-active archetype.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + active_mc = sess.get("multi_copy") or {} + ctx = {"request": request, "archetype": active_mc, "card_name": card_name} + return templates.TemplateResponse("partials/multicopy_exclude_warning.html", ctx) + + +@router.post("/must-haves/save-archetype-include", response_class=HTMLResponse) +async def save_archetype_include( + request: Request, + card_name: str = Form(...), + choice_id: str = Form(...), + count: int = Form(None), + thrumming: str | None = Form(None), +) -> HTMLResponse: + """Save multi-copy archetype selection and add card to the must-include list. + + Combines the archetype save (multicopy/save) and include toggle into a single + action, called from the multicopy_include_dialog when the user confirms. + """ + if not ALLOW_MUST_HAVES: + return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403) + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Persist archetype selection (mirrors multicopy/save logic) + meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {}) + archetype_name = meta.get("name") or card_name.strip() + if count is None: + count = int(meta.get("default_count", 25)) + try: + count = int(count) + except Exception: + count = int(meta.get("default_count", 25)) + printed_cap = meta.get("printed_cap") + if isinstance(printed_cap, int) and printed_cap > 0: + count = max(1, min(printed_cap, count)) + sess["multi_copy"] = { + "id": str(choice_id), + "name": archetype_name, + "count": count, + "thrumming": str(thrumming or "").strip().lower() in {"1", "true", "on", "yes"}, + } + try: + if "mc_applied_key" in sess: + del sess["mc_applied_key"] + except Exception: + pass + + # Add card to include list + name = card_name.strip() + key = name.lower() + includes = list(sess.get("include_cards") or []) + include_lookup = {str(v).strip().lower(): str(v) for v in includes} + if key not in include_lookup: + includes.append(name) + # Remove from excludes if present + excludes = [c for c in (sess.get("exclude_cards") or []) if str(c).strip().lower() != key] + sess["include_cards"] = includes + sess["exclude_cards"] = excludes + + return _render_include_exclude_summary(request, sess, sid) + + +@router.post("/must-haves/clear-archetype", response_class=HTMLResponse) +async def clear_archetype( + request: Request, + card_name: str | None = Form(None), +) -> HTMLResponse: + """Clear the active multi-copy archetype and optionally add card to the exclude list. + + Called when user confirms the exclude-archetype-warning dialog. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + for k in ("multi_copy", "mc_applied_key", "mc_seen_keys"): + try: + if k in sess: + del sess[k] + except Exception: + pass + + if card_name: + name = str(card_name).strip() + key = name.lower() + excludes = list(sess.get("exclude_cards") or []) + exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes} + if key not in exclude_lookup: + excludes.append(name) + # Remove from includes if present + includes = [c for c in (sess.get("include_cards") or []) if str(c).strip().lower() != key] + sess["include_cards"] = includes + sess["exclude_cards"] = excludes + + return _render_include_exclude_summary(request, sess, sid) diff --git a/code/web/routes/build_multicopy.py b/code/web/routes/build_multicopy.py index ee1eb16..a7d3c48 100644 --- a/code/web/routes/build_multicopy.py +++ b/code/web/routes/build_multicopy.py @@ -150,6 +150,10 @@ async def multicopy_check(request: Request) -> HTMLResponse: pass # Detect viable archetypes results = bu.detect_viable_multi_copy_archetypes(tmp) or [] + # R13: Filter out archetypes whose card is in the must-exclude list + exclude_names = {str(c).strip().lower() for c in (sess.get("exclude_cards") or [])} + if exclude_names: + results = [r for r in results if str(r.get("name", "")).strip().lower() not in exclude_names] if not results: # Remember this key to avoid re-checking until tags/commander change try: diff --git a/code/web/routes/build_newflow.py b/code/web/routes/build_newflow.py index e270a7e..d1d3725 100644 --- a/code/web/routes/build_newflow.py +++ b/code/web/routes/build_newflow.py @@ -37,12 +37,27 @@ from .build_partners import ( _partner_ui_context, _resolve_partner_selection, ) -from .build_wizard import _prepare_step2_theme_data, _section_themes_by_pool_size # R21: Pool size data +from .build_themes import _prepare_step2_theme_data, _section_themes_by_pool_size from ..services import custom_theme_manager as theme_mgr router = APIRouter() +# Pre-built JS-serialisable map of multi-copy archetypes for client-side popup detection. +# Keys are lowercased card names; values contain what the popup needs. +_ARCHETYPE_JS_MAP: dict[str, dict] = { + str(a["name"]).strip().lower(): { + "id": a["id"], + "name": a["name"], + "default_count": a.get("default_count", 25), + "printed_cap": a.get("printed_cap"), + "rec_window": list(a["rec_window"]) if a.get("rec_window") else None, + "thrumming_stone_synergy": bool(a.get("thrumming_stone_synergy", False)), + } + for a in bc.MULTI_COPY_ARCHETYPES.values() +} + + # ============================================================================== # New Deck Modal and Commander Search # ============================================================================== @@ -99,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": { "commander": sess.get("commander", ""), # Pre-fill for quick-build "prefer_combos": bool(sess.get("prefer_combos")), @@ -485,6 +501,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(suggested), "tag_slot_html": None, } @@ -510,6 +527,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(commander), "tag_slot_html": None, } @@ -615,6 +633,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, } @@ -754,6 +773,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, } diff --git a/code/web/routes/build_themes.py b/code/web/routes/build_themes.py index 5255822..cc1604d 100644 --- a/code/web/routes/build_themes.py +++ b/code/web/routes/build_themes.py @@ -20,11 +20,58 @@ from ..app import ( ) from ..services.tasks import get_session, new_sid from ..services import custom_theme_manager as theme_mgr +from ..services.theme_catalog_loader import load_index, slugify router = APIRouter() +def _prepare_step2_theme_data(tags: list[str], recommended: list[str]) -> tuple[list[str], list[str], dict[str, int]]: + """Load pool size data and sort themes for display. + + Returns: + Tuple of (sorted_tags, sorted_recommended, pool_size_dict) + """ + import logging + logger = logging.getLogger(__name__) + + try: + theme_index = load_index() + pool_size_by_slug = theme_index.pool_size_by_slug + except Exception as e: + logger.warning(f"Failed to load theme index for pool sizes: {e}") + pool_size_by_slug = {} + + def sort_by_pool_size(theme_list: list[str]) -> list[str]: + return sorted( + theme_list, + key=lambda t: (-pool_size_by_slug.get(slugify(t), 0), t.lower()) + ) + + return sort_by_pool_size(tags), sort_by_pool_size(recommended), pool_size_by_slug + + +def _section_themes_by_pool_size(themes: list[str], pool_size: dict[str, int]) -> list[dict[str, Any]]: + """Group themes into sections by pool size. + + Thresholds: Vast ≥1000, Large 500-999, Moderate 200-499, Small 50-199, Tiny <50 + """ + sections = [ + {"label": "Vast", "min": 1000, "max": 9999999, "themes": []}, + {"label": "Large", "min": 500, "max": 999, "themes": []}, + {"label": "Moderate", "min": 200, "max": 499, "themes": []}, + {"label": "Small", "min": 50, "max": 199, "themes": []}, + {"label": "Tiny", "min": 0, "max": 49, "themes": []}, + ] + for theme in themes: + theme_pool = pool_size.get(slugify(theme), 0) + for section in sections: + if section["min"] <= theme_pool <= section["max"]: + section["themes"].append(theme) + break + return [s for s in sections if s["themes"]] + + _INVALID_THEME_MESSAGE = ( "Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores." ) diff --git a/code/web/routes/build_wizard.py b/code/web/routes/build_wizard.py index e6283db..8885496 100644 --- a/code/web/routes/build_wizard.py +++ b/code/web/routes/build_wizard.py @@ -13,7 +13,7 @@ Extracted from build.py as part of Phase 3 modularization (Roadmap 9 M1). from __future__ import annotations from fastapi import APIRouter, Request, Form, Query -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from typing import Any from ..app import templates, ENABLE_PARTNER_MECHANICS, THEME_POOL_SECTIONS @@ -182,25 +182,12 @@ def _get_current_deck_names(sess: dict) -> list[str]: @router.get("/step1", response_class=HTMLResponse) async def build_step1(request: Request) -> HTMLResponse: - """Display commander search form.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp + return RedirectResponse("/build", status_code=302) @router.post("/step1", response_class=HTMLResponse) -async def build_step1_search( - request: Request, - query: str = Form(""), - auto: str | None = Form(None), - active: str | None = Form(None), -) -> HTMLResponse: - """Search for commander candidates and optionally auto-select.""" - query = (query or "").strip() - auto_enabled = True if (auto == "1") else False +async def build_step1_search(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) candidates = [] if query: candidates = orch.commander_candidates(query, limit=10) @@ -275,11 +262,8 @@ async def build_step1_search( @router.post("/step1/inspect", response_class=HTMLResponse) -async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: - """Preview commander details before confirmation.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 +async def build_step1_inspect(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) info = orch.commander_inspect(name) resp = templates.TemplateResponse( "build/_step1.html", @@ -290,9 +274,8 @@ async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLRe @router.post("/step1/confirm", response_class=HTMLResponse) -async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: - """Confirm commander selection and proceed to step 2.""" - res = orch.commander_select(name) +async def build_step1_confirm(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) if not res.get("ok"): sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) @@ -381,23 +364,7 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe @router.post("/reset-all", response_class=HTMLResponse) async def build_reset_all(request: Request) -> HTMLResponse: - """Clear all build-related session state and return Step 1.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - keys = [ - "commander","tags","tag_mode","bracket","ideals","build_ctx","last_step", - "locks","replace_mode" - ] - for k in keys: - try: - if k in sess: - del sess[k] - except Exception: - pass - sess["last_step"] = 1 - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp + return RedirectResponse("/build", status_code=302) # ============================================================================ @@ -406,11 +373,7 @@ async def build_reset_all(request: Request) -> HTMLResponse: @router.get("/step2", response_class=HTMLResponse) async def build_step2_get(request: Request) -> HTMLResponse: - """Display theme picker and partner selection.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 - commander = sess.get("commander") + return RedirectResponse("/build", status_code=302) if not commander: # Fallback to step1 if no commander in session resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) @@ -513,24 +476,8 @@ async def build_step2_get(request: Request) -> HTMLResponse: @router.post("/step2", response_class=HTMLResponse) -async def build_step2_submit( - request: Request, - commander: str = Form(...), - primary_tag: str | None = Form(None), - secondary_tag: str | None = Form(None), - tertiary_tag: str | None = Form(None), - tag_mode: str | None = Form("AND"), - bracket: int = Form(...), - partner_enabled: str | None = Form(None), - secondary_commander: str | None = Form(None), - background: str | None = Form(None), - partner_selection_source: str | None = Form(None), - partner_auto_opt_out: str | None = Form(None), -) -> HTMLResponse: - """Submit theme and partner selections, proceed to step 3.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 +async def build_step2_submit(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) partner_feature_enabled = ENABLE_PARTNER_MECHANICS partner_flag = False @@ -776,11 +723,7 @@ async def build_step2_submit( @router.get("/step3", response_class=HTMLResponse) async def build_step3_get(request: Request) -> HTMLResponse: - """Display ideal card count sliders.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 3 - defaults = orch.ideal_defaults() + return RedirectResponse("/build", status_code=302) values = sess.get("ideals") or defaults # Check if any skip flags are enabled to show skeleton automation page @@ -850,19 +793,8 @@ async def build_step3_get(request: Request) -> HTMLResponse: @router.post("/step3", response_class=HTMLResponse) -async def build_step3_submit( - request: Request, - ramp: int = Form(...), - lands: int = Form(...), - basic_lands: int = Form(...), - creatures: int = Form(...), - removal: int = Form(...), - wipes: int = Form(...), - card_advantage: int = Form(...), - protection: int = Form(...), -) -> HTMLResponse: - """Submit ideal card counts, proceed to step 4.""" - labels = orch.ideal_labels() +async def build_step3_submit(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) submitted = { "ramp": ramp, "lands": lands, @@ -944,11 +876,7 @@ async def build_step3_submit( @router.get("/step4", response_class=HTMLResponse) async def build_step4_get(request: Request) -> HTMLResponse: - """Display review page with owned card preferences.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 4 - labels = orch.ideal_labels() + return RedirectResponse("/build", status_code=302) values = sess.get("ideals") or orch.ideal_defaults() commander = sess.get("commander") return templates.TemplateResponse( @@ -966,17 +894,8 @@ async def build_step4_get(request: Request) -> HTMLResponse: @router.post("/toggle-owned-review", response_class=HTMLResponse) -async def build_toggle_owned_review( - request: Request, - use_owned_only: str | None = Form(None), - prefer_owned: str | None = Form(None), - swap_mdfc_basics: str | None = Form(None), -) -> HTMLResponse: - """Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 4 - only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False +async def build_toggle_owned_review(request: Request) -> HTMLResponse: + return RedirectResponse("/build", status_code=302) pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False sess["use_owned_only"] = only_val @@ -1024,19 +943,12 @@ async def build_step5_get(request: Request) -> HTMLResponse: @router.get("/step5/start", response_class=HTMLResponse) async def build_step5_start_get(request: Request) -> HTMLResponse: - """Allow GET as a fallback to start the build (delegates to POST handler).""" - return await build_step5_start(request) + return RedirectResponse("/build", status_code=302) @router.post("/step5/start", response_class=HTMLResponse) async def build_step5_start(request: Request) -> HTMLResponse: - """Initialize build context and run first stage.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - if "replace_mode" not in sess: - sess["replace_mode"] = True - # Validate commander exists before starting - commander = sess.get("commander") + return RedirectResponse("/build", status_code=302) if not commander: resp = templates.TemplateResponse( "build/_step1.html", diff --git a/code/web/services/tasks.py b/code/web/services/tasks.py index a548252..9e217e5 100644 --- a/code/web/services/tasks.py +++ b/code/web/services/tasks.py @@ -195,3 +195,18 @@ def cleanup_expired() -> int: Number of sessions cleaned up """ return _get_manager().cleanup_state() + + +class _SessionsProxy: + """Dict-like proxy exposing internal session state — for test isolation only.""" + def clear(self) -> None: + _get_manager()._state.clear() + + def get(self, key: str, default: Any = None) -> Any: + return _get_manager()._state.get(key, default) + + def pop(self, key: str, *args: Any) -> Any: + return _get_manager()._state.pop(key, *args) + + +_SESSIONS = _SessionsProxy() diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index 0000fcd..b3f8111 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -241,6 +241,56 @@ + {% if allow_must_haves and multi_copy_archetypes_js %} + {# Multi-copy archetype confirmation dialog — shown when user types an archetype in must-include #} + + {# Conflict dialog — shown when a selected multicopy archetype also appears in the exclude list #} + + + {% endif %} @@ -1559,4 +1609,155 @@ } } })(); + + + \ No newline at end of file diff --git a/code/web/templates/partials/multicopy_exclude_warning.html b/code/web/templates/partials/multicopy_exclude_warning.html new file mode 100644 index 0000000..d4ef323 --- /dev/null +++ b/code/web/templates/partials/multicopy_exclude_warning.html @@ -0,0 +1,70 @@ + +
+ + + + diff --git a/code/web/templates/partials/multicopy_include_dialog.html b/code/web/templates/partials/multicopy_include_dialog.html new file mode 100644 index 0000000..97644e7 --- /dev/null +++ b/code/web/templates/partials/multicopy_include_dialog.html @@ -0,0 +1,98 @@ + +
+ + + +