From 9ab3835e2adb54bd9b421d5e04877f4154de11ae Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 14 Oct 2025 16:09:58 -0700 Subject: [PATCH 1/2] feat: stage reordering, skip controls, quick build, and commander session cleanup --- .env.example | 3 + CHANGELOG.md | 24 +- DOCKER.md | 1 + README.md | 3 + RELEASE_NOTES_TEMPLATE.md | 24 +- .../phases/phase2_lands_basics.py | 15 +- code/web/routes/build.py | 391 +++++++++++++++++- code/web/services/build_utils.py | 2 + code/web/services/orchestrator.py | 265 +++++++++++- code/web/templates/build/_new_deck_modal.html | 30 +- .../build/_new_deck_skip_controls.html | 279 +++++++++++++ .../build/_quick_build_progress.html | 16 + .../build/_quick_build_progress_content.html | 15 + docker-compose.yml | 3 + dockerhub-docker-compose.yml | 3 + 15 files changed, 1040 insertions(+), 34 deletions(-) create mode 100644 code/web/templates/build/_new_deck_skip_controls.html create mode 100644 code/web/templates/build/_quick_build_progress.html create mode 100644 code/web/templates/build/_quick_build_progress_content.html diff --git a/.env.example b/.env.example index 9e25cc7..d9c19cd 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,9 @@ WEB_TAG_PARALLEL=1 # dockerhub: WEB_TAG_PARALLEL="1" WEB_TAG_WORKERS=2 # dockerhub: WEB_TAG_WORKERS="4" WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0" +# Build Stage Ordering +WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill + # Tagging Refinement Feature Flags TAG_NORMALIZE_KEYWORDS=1 # dockerhub: TAG_NORMALIZE_KEYWORDS="1" # Normalize keywords & filter specialty mechanics TAG_PROTECTION_GRANTS=1 # dockerhub: TAG_PROTECTION_GRANTS="1" # Protection tag only for cards granting shields diff --git a/CHANGELOG.md b/CHANGELOG.md index 57deb9e..860dcb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,32 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -- _No unreleased changes yet._ +- Enhanced deck building workflow with improved stage ordering, granular skip controls, and one-click Quick Build automation. +- Stage execution order now prioritizes creatures and spells before lands for better mana curve analysis. +- New wizard-only skip controls allow auto-advancing through specific stages (lands, creatures, spells) without approval prompts. +- Quick Build button provides one-click full automation with clean 5-phase progress indicator. ### Added -- _No unreleased changes yet._ +- **Quick Build**: One-click automation button in New Deck wizard with live progress tracking (5 phases: Creatures, Spells, Lands, Final Touches, Summary). +- **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: all land steps, creature stages, spell categories). + - Individual land step controls: basics, staples, fetches, duals, triomes, kindred, misc lands. + - Spell category controls: ramp, removal, wipes, card advantage, protection, theme fill. + - Creature stage controls: all creatures, primary, secondary, fill. + - Mutual exclusivity enforcement: "Skip All Lands" disables individual land toggles; "Skip to Misc Lands" skips early land steps. +- **Stage Reordering**: New default build order executes creatures → spells → lands for improved pip analysis (configurable via `WEB_STAGE_ORDER` environment variable). +- Background task execution for Quick Build with HTMX polling progress updates. +- Mobile-friendly Quick Build with touch device confirmation dialog. ### Changed -- _No unreleased changes yet._ +- **Default Stage Order**: Creatures and ideal spells now execute before land stages (lands can analyze actual pip requirements instead of estimates). +- Skip controls only available in New Deck wizard (disabled during build execution for consistency). +- Skip behavior auto-advances through stages without approval prompts (cards still added, just not gated). +- Post-spell land adjustment automatically skipped when any skip flag enabled. ### Fixed -- _No unreleased changes yet._ +- Session context properly injected into Quick Build so skip configuration works correctly. +- HTMX polling uses continuous trigger (`every 500ms`) instead of one-time (`load delay`) for reliable progress updates. +- Progress indicator stops cleanly when build completes (out-of-band swap removes poller div). ## [2.6.1] - 2025-10-13 ### Summary diff --git a/DOCKER.md b/DOCKER.md index 9ac2c7b..6447c0c 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -254,6 +254,7 @@ See `.env.example` for the full catalog. Common knobs: | `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. | | `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | +| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | ### Random build controls diff --git a/README.md b/README.md index 6089672..6e5b757 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Every tile on the homepage connects to a workflow. Use these sections as your to ### Build a Deck Start here for interactive deck creation. - Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal. +- **Quick Build**: One-click automation runs the full workflow with live progress (Creatures → Spells → Lands → Final Touches → Summary). Available in New Deck wizard. +- **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: land steps, creature stages, spell categories). Auto-advance without approval prompts. - Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`. - Partner mechanics (ENABLE_PARTNER_MECHANICS): Step 2 and the quick-start modal auto-enable partner controls for eligible commanders, show only legal partner/background/Doctor options, and keep previews, warnings, and theme chips in sync. - Partner suggestions (ENABLE_PARTNER_SUGGESTIONS): ranked chips appear beside the partner selector, recommending popular partner/background/Doctor pairings based on the analytics dataset; selections respect existing partner mode and lock states. @@ -89,6 +91,7 @@ Start here for interactive deck creation. - Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set. CSV/TXT headers now include commander metadata (names, partner mode, colors) so downstream tools can pick up dual-commander context without extra parsing. - `ALLOW_MUST_HAVES=1` (default) enables include/exclude enforcement. - `WEB_AUTO_ENFORCE=1` re-runs bracket enforcement automatically after each build. +- `WEB_STAGE_ORDER=new` (default) runs creatures/spells before lands for better pip analysis. Use `legacy` for original lands-first order. ### Run a JSON Config Execute saved configs without manual input. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 09819b0..37db04c 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,13 +1,29 @@ # MTG Python Deckbuilder ${VERSION} ### Summary -- _No unreleased changes yet._ +- Enhanced deck building workflow with improved stage ordering, granular skip controls, and one-click Quick Build automation. +- Stage execution order now prioritizes creatures and spells before lands for better mana curve analysis. +- New wizard-only skip controls allow auto-advancing through specific stages (lands, creatures, spells) without approval prompts. +- Quick Build button provides one-click full automation with clean 5-phase progress indicator. ### Added -- _No unreleased changes yet._ +- **Quick Build**: One-click automation button in New Deck wizard with live progress tracking (5 phases: Creatures, Spells, Lands, Final Touches, Summary). +- **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: all land steps, creature stages, spell categories). + - Individual land step controls: basics, staples, fetches, duals, triomes, kindred, misc lands. + - Spell category controls: ramp, removal, wipes, card advantage, protection, theme fill. + - Creature stage controls: all creatures, primary, secondary, fill. + - Mutual exclusivity enforcement: "Skip All Lands" disables individual land toggles; "Skip to Misc Lands" skips early land steps. +- **Stage Reordering**: New default build order executes creatures → spells → lands for improved pip analysis (configurable via `WEB_STAGE_ORDER` environment variable). +- Background task execution for Quick Build with HTMX polling progress updates. +- Mobile-friendly Quick Build with touch device confirmation dialog. ### Changed -- _No unreleased changes yet._ +- **Default Stage Order**: Creatures and ideal spells now execute before land stages (lands can analyze actual pip requirements instead of estimates). +- Skip controls only available in New Deck wizard (disabled during build execution for consistency). +- Skip behavior auto-advances through stages without approval prompts (cards still added, just not gated). +- Post-spell land adjustment automatically skipped when any skip flag enabled. ### Fixed -- _No unreleased changes yet._ +- Session context properly injected into Quick Build so skip configuration works correctly. +- HTMX polling uses continuous trigger (`every 500ms`) instead of one-time (`load delay`) for reliable progress updates. +- Progress indicator stops cleanly when build completes (out-of-band swap removes poller div). diff --git a/code/deck_builder/phases/phase2_lands_basics.py b/code/deck_builder/phases/phase2_lands_basics.py index 5f9788a..ccf0a3f 100644 --- a/code/deck_builder/phases/phase2_lands_basics.py +++ b/code/deck_builder/phases/phase2_lands_basics.py @@ -79,12 +79,15 @@ class LandBasicsMixin: land_total = getattr(bc, 'DEFAULT_LAND_COUNT', 35) # Target basics = 1.3 * minimum (rounded) but not exceeding total lands - target_basics = int(round(1.3 * basic_min)) - if target_basics > land_total: - target_basics = land_total - if target_basics <= 0: - self.output_func("Target basic land count is zero; skipping basics.") - return + # target_basics = int(round(1.3 * basic_min)) + # if target_basics > land_total: + # target_basics = land_total + # if target_basics <= 0: + # self.output_func("Target basic land count is zero; skipping basics.") + # return + + # Changing code to use minimum basics as target for simplicity + target_basics = basic_min colors = [c for c in getattr(self, 'color_identity', []) if c in ['W', 'U', 'B', 'R', 'G']] if not colors: # colorless special case -> Wastes only diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 676ae71..944c075 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1,8 +1,8 @@ from __future__ import annotations -from fastapi import APIRouter, Request, Form, Query +from fastapi import APIRouter, Request, Form, Query, BackgroundTasks from fastapi.responses import HTMLResponse, JSONResponse -from typing import Any, Iterable +from typing import Any, Dict, Iterable import json from ..app import ( ALLOW_MUST_HAVES, @@ -1324,6 +1324,29 @@ async def build_new_modal(request: Request) -> HTMLResponse: """Return the New Deck modal content (for an overlay).""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + + # Clear build context to allow skip controls to work + # (Otherwise toggle endpoint thinks build is in progress) + if "build_ctx" in sess: + try: + del sess["build_ctx"] + except Exception: + pass + + # M2: Clear all skip preferences for true "New Deck" + skip_keys = [ + "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", + "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", + "skip_all_creatures", + "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", + "skip_all_spells", + "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", + "skip_protection", "skip_spell_fill", + "skip_post_adjust" + ] + for key in skip_keys: + sess.pop(key, None) + theme_context = _custom_theme_context(request, sess) ctx = { "request": request, @@ -1622,9 +1645,265 @@ async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> return resp +@router.post("/new/toggle-skip", response_class=JSONResponse) +async def build_new_toggle_skip( + request: Request, + skip_key: str = Form(...), + enabled: str = Form(...), +) -> JSONResponse: + """Toggle a skip configuration flag (wizard-only, before build starts). + + Enforces mutual exclusivity: + - skip_lands and skip_to_misc are mutually exclusive with individual land flags + - Individual land flags are mutually exclusive with each other + """ + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + return JSONResponse({"error": "No session ID"}, status_code=400) + + sess = get_session(sid) + + # Wizard-only: reject if build has started + if "build_ctx" in sess: + return JSONResponse({"error": "Cannot modify skip settings after build has started"}, status_code=400) + + # Validate skip_key + valid_keys = { + "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", + "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", + "skip_all_creatures", + "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", + "skip_all_spells", + "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", + "skip_protection", "skip_spell_fill", + "skip_post_adjust" + } + + if skip_key not in valid_keys: + return JSONResponse({"error": f"Invalid skip key: {skip_key}"}, status_code=400) + + # Parse enabled flag + enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} + + # Mutual exclusivity rules + land_group_flags = {"skip_lands", "skip_to_misc"} + individual_land_flags = {"skip_basics", "skip_staples", "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes"} + creature_specific_flags = {"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill"} + spell_specific_flags = {"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", "skip_protection", "skip_spell_fill"} + + # If enabling a flag, check for conflicts + if enabled_flag: + # Rule 1: skip_lands/skip_to_misc disables all individual land flags + if skip_key in land_group_flags: + for key in individual_land_flags: + sess[key] = False + + # Rule 2: Individual land flags disable skip_lands/skip_to_misc + elif skip_key in individual_land_flags: + for key in land_group_flags: + sess[key] = False + + # Rule 3: skip_all_creatures disables specific creature flags + elif skip_key == "skip_all_creatures": + for key in creature_specific_flags: + sess[key] = False + + # Rule 4: Specific creature flags disable skip_all_creatures + elif skip_key in creature_specific_flags: + sess["skip_all_creatures"] = False + + # Rule 5: skip_all_spells disables specific spell flags + elif skip_key == "skip_all_spells": + for key in spell_specific_flags: + sess[key] = False + + # Rule 6: Specific spell flags disable skip_all_spells + elif skip_key in spell_specific_flags: + sess["skip_all_spells"] = False + + # Set the requested flag + sess[skip_key] = enabled_flag + + # Auto-enable skip_post_adjust when any other skip is enabled + if enabled_flag and skip_key != "skip_post_adjust": + sess["skip_post_adjust"] = True + + # Auto-disable skip_post_adjust when all other skips are disabled + if not enabled_flag: + any_other_skip = any( + sess.get(k, False) for k in valid_keys + if k != "skip_post_adjust" and k != skip_key + ) + if not any_other_skip: + sess["skip_post_adjust"] = False + + return JSONResponse({ + "success": True, + "skip_key": skip_key, + "enabled": enabled_flag, + "skip_post_adjust": bool(sess.get("skip_post_adjust", False)) + }) + + +def _get_descriptive_stage_label(stage: Dict[str, Any], ctx: Dict[str, Any]) -> str: + """Generate a more descriptive label for Quick Build progress display.""" + key = stage.get("key", "") + base_label = stage.get("label", "") + + # Land stages - show what type of lands + land_types = { + "land1": "Basics", + "land2": "Staples", + "land3": "Fetches", + "land4": "Duals", + "land5": "Triomes", + "land6": "Kindred", + "land7": "Misc Utility", + "land8": "Final Lands" + } + if key in land_types: + return f"Lands: {land_types[key]}" + + # Creature stages - show associated theme + if "creatures" in key: + tags = ctx.get("tags", []) + if key == "creatures_all_theme": + if tags: + all_tags = " + ".join(tags[:3]) # Show up to 3 tags + return f"Creatures: All Themes ({all_tags})" + return "Creatures: All Themes" + elif key == "creatures_primary" and len(tags) >= 1: + return f"Creatures: {tags[0]}" + elif key == "creatures_secondary" and len(tags) >= 2: + return f"Creatures: {tags[1]}" + elif key == "creatures_tertiary" and len(tags) >= 3: + return f"Creatures: {tags[2]}" + # Let creatures_fill use default "Creatures: Fill" label + + # Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme + if key == "spells_fill": + return "Theme Spell Fill" + + # Default: return original label + return base_label + + +def _run_quick_build_stages(sid: str): + """Background task: Run all stages for Quick Build and update progress in session.""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"[Quick Build] Starting background task for sid={sid}") + + sess = get_session(sid) + logger.info(f"[Quick Build] Retrieved session: {sess is not None}") + + ctx = sess.get("build_ctx") + if not ctx: + logger.error(f"[Quick Build] No build_ctx found in session") + sess["quick_build_progress"] = { + "running": False, + "current_stage": "Error: No build context", + "completed_stages": [] + } + return + + logger.info(f"[Quick Build] build_ctx found with {len(ctx.get('stages', []))} stages") + + # CRITICAL: Inject session reference into context so skip config can be read + ctx["session"] = sess + logger.info("[Quick Build] Injected session reference into context") + + stages = ctx.get("stages", []) + res = None + + # Initialize progress tracking + sess["quick_build_progress"] = { + "running": True, + "current_stage": "Starting build..." + } + + try: + logger.info("[Quick Build] Starting stage loop") + + # Track which phase we're in for simplified progress display + current_phase = None + + while True: + current_idx = ctx.get("idx", 0) + if current_idx >= len(stages): + logger.info(f"[Quick Build] Reached end of stages (idx={current_idx})") + break + + current_stage = stages[current_idx] + stage_key = current_stage.get("key", "") + logger.info(f"[Quick Build] Stage {current_idx} key: {stage_key}") + + # Determine simplified phase label + if stage_key.startswith("creatures"): + new_phase = "Adding Creatures" + elif stage_key.startswith("spells") or stage_key in ["spells_ramp", "spells_removal", "spells_wipes", "spells_card_advantage", "spells_protection", "spells_fill"]: + new_phase = "Adding Spells" + elif stage_key.startswith("land"): + new_phase = "Adding Lands" + elif stage_key in ["post_spell_land_adjust", "reporting"]: + new_phase = "Doing Some Final Touches" + else: + new_phase = "Building Deck" + + # Only update progress if phase changed + if new_phase != current_phase: + current_phase = new_phase + sess["quick_build_progress"]["current_stage"] = current_phase + logger.info(f"[Quick Build] Phase: {current_phase}") + + # Run stage with show_skipped=False + res = orch.run_stage(ctx, rerun=False, show_skipped=False) + logger.info(f"[Quick Build] Stage {stage_key} completed, done={res.get('done')}") + + # Handle Multi-Copy package marking + try: + if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + except Exception: + pass + + # Check if build is done (reporting stage marks done=True) + if res.get("done"): + break + + # run_stage() advances ctx["idx"] internally when stage completes successfully + # If stage is gated, it also advances the index, so we just continue the loop + + # Show summary generation message (stay here for a moment) + sess["quick_build_progress"]["current_stage"] = "Generating Summary" + import time + time.sleep(2) # Pause briefly so user sees this stage + + # Store final result for polling endpoint + sess["last_result"] = res or {} + sess["last_step"] = 5 + + # Small delay to show finishing message + import time + time.sleep(1.5) + + except Exception as e: + # Store error state + logger.exception(f"[Quick Build] Error during stage execution: {e}") + sess["quick_build_progress"]["current_stage"] = f"Error: {str(e)}" + finally: + # Mark build as complete + logger.info("[Quick Build] Background task completed") + sess["quick_build_progress"]["running"] = False + sess["quick_build_progress"]["current_stage"] = "Complete" + + @router.post("/new", response_class=HTMLResponse) async def build_new_submit( request: Request, + background_tasks: BackgroundTasks, name: str = Form("") , commander: str = Form(...), primary_tag: str | None = Form(None), @@ -1662,6 +1941,8 @@ async def build_new_submit( enforcement_mode: str = Form("warn"), allow_illegal: bool = Form(False), fuzzy_matching: bool = Form(True), + # Quick Build flag + quick_build: str | None = Form(None), ) -> HTMLResponse: """Handle New Deck modal submit and immediately start the build (skip separate review page).""" sid = request.cookies.get("sid") or new_sid() @@ -2186,20 +2467,52 @@ async def build_new_submit( sess["replace_mode"] = True # Centralized staged context creation sess["build_ctx"] = start_ctx_from_session(sess) - res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) - # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue - try: - if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - status = "Build complete" if res.get("done") else "Stage complete" - sess["last_step"] = 5 - ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) - resp = templates.TemplateResponse("build/_step5.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp + + # Check if Quick Build was requested + is_quick_build = (quick_build or "").strip() == "1" + + if is_quick_build: + # Quick Build: Start background task and return progress template immediately + ctx = sess["build_ctx"] + + # Initialize progress tracking with dynamic counting (total starts at 0) + sess["quick_build_progress"] = { + "running": True, + "total": 0, + "completed": 0, + "current_stage": "Starting build..." + } + + # Start background task to run all stages + background_tasks.add_task(_run_quick_build_stages, sid) + + # Return progress template immediately + progress_ctx = { + "request": request, + "progress_pct": 0, + "completed": 0, + "total": 0, + "current_stage": "Starting build..." + } + resp = templates.TemplateResponse("build/_quick_build_progress.html", progress_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + else: + # Normal build: Run first stage and wait for user input + res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) + # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue + try: + if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + except Exception: + pass + status = "Build complete" if res.get("done") else "Stage complete" + sess["last_step"] = 5 + ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) + resp = templates.TemplateResponse("build/_step5.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.get("/step1", response_class=HTMLResponse) @@ -3155,6 +3468,10 @@ async def build_step5_continue(request: Request) -> HTMLResponse: try: res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) status = "Build complete" if res.get("done") else "Stage complete" + # Clear commander from session after build completes + if res.get("done"): + sess.pop("commander", None) + sess.pop("commander_name", None) except Exception as e: sess["last_step"] = 5 err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}") @@ -3418,6 +3735,48 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe response.set_cookie("sid", sid, httponly=True, samesite="lax") return response + +@router.get("/quick-progress") +def quick_build_progress(request: Request): + """Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5.""" + import logging + logger = logging.getLogger(__name__) + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + progress = sess.get("quick_build_progress") + logger.info(f"[Progress Poll] sid={sid}, progress={progress is not None}, running={progress.get('running') if progress else None}") + + if not progress or not progress.get("running"): + # Build complete - return Step 5 content + remove the polling div + res = sess.get("last_result") + if res and res.get("done"): + ctx = step5_ctx_from_result(request, sess, res) + # Render Step 5, then add script to remove polling div + step5_html = templates.get_template("build/_step5.html").render(ctx) + # Return Step 5 content + a script that removes the poller and replaces #wizard + final_html = f''' + {step5_html} +
+ ''' + response = HTMLResponse(final_html) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + # Fallback if no result yet + return HTMLResponse('Build complete. Please refresh.') + + # Build still running - return progress content partial only (innerHTML swap) + current_stage = progress.get("current_stage", "Processing...") + + ctx = { + "request": request, + "current_stage": current_stage + } + response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index ee97e43..6117d8d 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -175,6 +175,8 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s ctx["partner_mode"] = sess.get("partner_mode") ctx["combined_commander"] = sess.get("combined_commander") ctx["partner_warnings"] = list(sess.get("partner_warnings", []) or []) + # M2: Attach session reference to context for skip controls + ctx["session"] = sess return ctx diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 2179178..364cf03 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -1866,7 +1866,12 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # ----------------- # Step-by-step build session # ----------------- -def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: +def _make_stages_legacy(b: DeckBuilder) -> List[Dict[str, Any]]: + """Legacy stage order: lands → creatures → spells → theme fill → post-adjust. + + This is the original ordering where lands are added first, before creatures + and spells. Kept for backward compatibility via WEB_STAGE_ORDER=legacy. + """ stages: List[Dict[str, Any]] = [] # Run Multi-Copy before land steps (per web-first flow preference) mc_selected = False @@ -1964,6 +1969,238 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: return stages +def _make_stages_new(b: DeckBuilder) -> List[Dict[str, Any]]: + """New stage order: creatures → ideal spells → lands → theme fill → post-adjust. + + This is the preferred ordering where creatures and core spells are added first, + then lands (which can now analyze actual pip requirements), then theme fill tops up. + """ + stages: List[Dict[str, Any]] = [] + # Run Multi-Copy first (if selected) + mc_selected = False + try: + mc_selected = bool(getattr(b, '_web_multi_copy', None)) + except Exception: + mc_selected = False + if mc_selected: + stages.append({"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}) + + # M3: Include injection first + if hasattr(b, '_inject_includes_after_lands') and getattr(b, 'include_cards', None): + stages.append({"key": "inject_includes", "label": "Include Cards", "runner_name": "__inject_includes__"}) + + # 1) CREATURES - All theme sub-stages + try: + combine_mode = getattr(b, 'tag_mode', 'AND') + except Exception: + combine_mode = 'AND' + has_two_tags = bool(getattr(b, 'primary_tag', None) and getattr(b, 'secondary_tag', None)) + if combine_mode == 'AND' and has_two_tags and hasattr(b, 'add_creatures_all_theme_phase'): + stages.append({"key": "creatures_all_theme", "label": "Creatures: All-Theme", "runner_name": "add_creatures_all_theme_phase"}) + if getattr(b, 'primary_tag', None) and hasattr(b, 'add_creatures_primary_phase'): + stages.append({"key": "creatures_primary", "label": "Creatures: Primary", "runner_name": "add_creatures_primary_phase"}) + if getattr(b, 'secondary_tag', None) and hasattr(b, 'add_creatures_secondary_phase'): + stages.append({"key": "creatures_secondary", "label": "Creatures: Secondary", "runner_name": "add_creatures_secondary_phase"}) + if getattr(b, 'tertiary_tag', None) and hasattr(b, 'add_creatures_tertiary_phase'): + stages.append({"key": "creatures_tertiary", "label": "Creatures: Tertiary", "runner_name": "add_creatures_tertiary_phase"}) + if hasattr(b, 'add_creatures_fill_phase'): + stages.append({"key": "creatures_fill", "label": "Creatures: Fill", "runner_name": "add_creatures_fill_phase"}) + + # 2) SPELLS - Ideal categories (granular) + spell_categories: List[Tuple[str, str, str]] = [ + ("ramp", "Ramp", "add_ramp"), + ("removal", "Removal", "add_removal"), + ("wipes", "Board Wipes", "add_board_wipes"), + ("card_advantage", "Card Advantage", "add_card_advantage"), + ("protection", "Protective Effects", "add_protection"), + ] + any_granular = any(callable(getattr(b, rn, None)) for _key, _label, rn in spell_categories) + if any_granular: + for key, label, runner in spell_categories: + if callable(getattr(b, runner, None)): + stages.append({"key": f"spells_{key}", "label": label, "runner_name": runner}) + + # Auto-Complete Combos (if preferred and allowed) + try: + prefer_c = bool(getattr(b, 'prefer_combos', False)) + except Exception: + prefer_c = False + allow_combos = True + try: + lim = getattr(b, 'bracket_limits', {}).get('two_card_combos') + if lim is not None and int(lim) == 0: + allow_combos = False + except Exception: + allow_combos = True + if prefer_c and allow_combos: + stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) + elif hasattr(b, 'add_spells_phase'): + # Monolithic spells with combos first + try: + prefer_c = bool(getattr(b, 'prefer_combos', False)) + allow_combos = True + try: + lim = getattr(b, 'bracket_limits', {}).get('two_card_combos') + if lim is not None and int(lim) == 0: + allow_combos = False + except Exception: + allow_combos = True + if prefer_c and allow_combos: + stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) + except Exception: + pass + stages.append({"key": "spells", "label": "Spells", "runner_name": "add_spells_phase"}) + + # 3) LANDS - Steps 1..8 (after spells so pip counts are known) + for i in range(1, 9): + fn = getattr(b, f"run_land_step{i}", None) + if callable(fn): + stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"}) + + # 4) THEME FILL - Final spell topper + if callable(getattr(b, 'fill_remaining_theme_spells', None)): + stages.append({"key": "spells_fill", "label": "Theme Spell Fill", "runner_name": "fill_remaining_theme_spells"}) + + # 5) LAND ADJUSTMENTS - Post-spell rebalance (same as legacy) + if hasattr(b, 'post_spell_land_adjust'): + stages.append({"key": "post_adjust", "label": "Post-Spell Land Adjust", "runner_name": "post_spell_land_adjust"}) + + # Reporting (always last) + if hasattr(b, 'run_reporting_phase'): + stages.append({"key": "reporting", "label": "Reporting", "runner_name": "run_reporting_phase"}) + + return stages + + +def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: + """Dispatcher: choose stage ordering based on WEB_STAGE_ORDER environment variable. + + - 'new' (default): creatures → ideal spells → lands → theme fill + - 'legacy': lands → creatures → spells → theme fill (original order) + """ + stage_order = os.getenv('WEB_STAGE_ORDER', 'new').strip().lower() + + if stage_order == 'legacy': + return _make_stages_legacy(b) + else: + # Default to new order + return _make_stages_new(b) + + +def _get_stage_skip_config(sess: Dict[str, Any]) -> Dict[str, bool]: + """Extract skip configuration flags from session. + + Returns dict with all skip flags (default False if not present): + - skip_lands: Skip all land stages + - skip_to_misc: Skip to misc lands (duals/triomes/misc/optimize) + - skip_basics: Skip basic lands (land1) + - skip_staples: Skip staple lands (land2) + - skip_kindred: Skip kindred/tribal lands (land3) + - skip_fetches: Skip fetch lands (land4) + - skip_duals: Skip dual/shock lands (land5) + - skip_triomes: Skip triome/tri-color lands (land6) + - skip_all_creatures: Skip all creature stages + - skip_creature_primary: Skip creature primary stage + - skip_creature_secondary: Skip creature secondary stage + - skip_creature_fill: Skip creature fill stage + - skip_all_spells: Skip all spell stages + - skip_ramp: Skip ramp spells + - skip_removal: Skip removal spells + - skip_wipes: Skip board wipes + - skip_card_advantage: Skip card advantage + - skip_protection: Skip protection spells + - skip_spell_fill: Skip spell fill stage + - skip_post_adjust: Skip post-adjustment + """ + return { + "skip_lands": bool(sess.get("skip_lands", False)), + "skip_to_misc": bool(sess.get("skip_to_misc", False)), + "skip_basics": bool(sess.get("skip_basics", False)), + "skip_staples": bool(sess.get("skip_staples", False)), + "skip_kindred": bool(sess.get("skip_kindred", False)), + "skip_fetches": bool(sess.get("skip_fetches", False)), + "skip_duals": bool(sess.get("skip_duals", False)), + "skip_triomes": bool(sess.get("skip_triomes", False)), + "skip_all_creatures": bool(sess.get("skip_all_creatures", False)), + "skip_creature_primary": bool(sess.get("skip_creature_primary", False)), + "skip_creature_secondary": bool(sess.get("skip_creature_secondary", False)), + "skip_creature_fill": bool(sess.get("skip_creature_fill", False)), + "skip_all_spells": bool(sess.get("skip_all_spells", False)), + "skip_ramp": bool(sess.get("skip_ramp", False)), + "skip_removal": bool(sess.get("skip_removal", False)), + "skip_wipes": bool(sess.get("skip_wipes", False)), + "skip_card_advantage": bool(sess.get("skip_card_advantage", False)), + "skip_protection": bool(sess.get("skip_protection", False)), + "skip_spell_fill": bool(sess.get("skip_spell_fill", False)), + "skip_post_adjust": bool(sess.get("skip_post_adjust", False)), + } + + +def _check_stage_skip(stage_id: str, skip_config: Dict[str, bool]) -> bool: + """Check if a stage should be skipped based on skip configuration. + + Land stage mapping: + land1 = basics, land2 = staples, land3 = kindred, land4 = fetches, + land5 = duals/shocks, land6 = triomes, land7 = misc, land8 = optimize + + Args: + stage_id: Stage identifier (e.g., 'land1', 'creatures_primary', 'spells_ramp') + skip_config: Skip configuration dict from _get_stage_skip_config() + + Returns: + True if stage should be skipped, False otherwise + """ + # Land stages + if stage_id == "land1": + return skip_config.get("skip_basics", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) + elif stage_id == "land2": + return skip_config.get("skip_staples", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) + elif stage_id == "land3": + return skip_config.get("skip_kindred", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) + elif stage_id == "land4": + return skip_config.get("skip_fetches", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) + elif stage_id == "land5": + return skip_config.get("skip_duals", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) + elif stage_id == "land6": + return skip_config.get("skip_triomes", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) + elif stage_id == "land7": + # land7 = misc lands - skip_to_misc should STOP here and gate, not skip through + return skip_config.get("skip_lands", False) + elif stage_id == "land8": + # land8 = optimize - skip if skip_lands is enabled + return skip_config.get("skip_lands", False) + + # Creature stages + elif stage_id == "creatures_all_theme": + return skip_config.get("skip_all_creatures", False) + elif stage_id == "creatures_primary": + return skip_config.get("skip_creature_primary", False) or skip_config.get("skip_all_creatures", False) + elif stage_id == "creatures_secondary": + return skip_config.get("skip_creature_secondary", False) or skip_config.get("skip_all_creatures", False) + elif stage_id == "creatures_fill": + return skip_config.get("skip_creature_fill", False) or skip_config.get("skip_all_creatures", False) + + # Spell stages + elif stage_id == "spells_ramp": + return skip_config.get("skip_ramp", False) or skip_config.get("skip_all_spells", False) + elif stage_id == "spells_removal": + return skip_config.get("skip_removal", False) or skip_config.get("skip_all_spells", False) + elif stage_id == "spells_wipes": + return skip_config.get("skip_wipes", False) or skip_config.get("skip_all_spells", False) + elif stage_id == "spells_card_advantage": + return skip_config.get("skip_card_advantage", False) or skip_config.get("skip_all_spells", False) + elif stage_id == "spells_protection": + return skip_config.get("skip_protection", False) or skip_config.get("skip_all_spells", False) + elif stage_id == "spells_fill": + return skip_config.get("skip_spell_fill", False) or skip_config.get("skip_all_spells", False) + + # Post-adjust stage + elif stage_id == "post_adjust": + return skip_config.get("skip_post_adjust", False) + + return False + + def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) -> None: """Attach combined commander metadata to the builder.""" @@ -2516,6 +2753,13 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal stage = stages[i] label = stage["label"] runner_name = stage["runner_name"] + stage_id = stage.get("key", "") + + # Check if stage should be skipped (M2: Skip Controls) + # Note: Skip means "auto-continue without user input", not "skip execution" + # The stage still runs and adds cards, but we don't gate on it + skip_config = _get_stage_skip_config(ctx.get("session", {})) + should_skip = _check_stage_skip(stage_id, skip_config) # Take snapshot before executing; for rerun with replace, restore first if we have one if rerun and replace and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1): @@ -3018,7 +3262,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: pass - # If this stage added cards, present it and advance idx + # If this stage added cards, gate for user review UNLESS skip is enabled if added_cards: # Progress counts try: @@ -3047,6 +3291,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass ctx["idx"] = i + 1 ctx["last_visible_idx"] = i + 1 + + # M2 Skip Controls: If stage is skipped, auto-advance instead of gating + if should_skip: + # Track that this stage was auto-skipped + try: + skipped_list = ctx.get("skipped_stages", []) + if stage_id not in skipped_list: + skipped_list.append(stage_id) + ctx["skipped_stages"] = skipped_list + except Exception: + pass + # Log the skip and continue to next stage + logs.append(f"Auto-continued through '{label}' (skip enabled)") + i += 1 + continue + + # Normal gating: return stage result for user review return { "done": False, "label": label, diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index 56c4d07..3aaee52 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -212,6 +212,7 @@ {% endif %} {% endif %} + {% include "build/_new_deck_skip_controls.html" %}
Advanced options (ideals)
@@ -222,15 +223,40 @@ {% endfor %}
- diff --git a/code/web/templates/build/_quick_build_progress.html b/code/web/templates/build/_quick_build_progress.html new file mode 100644 index 0000000..415b2aa --- /dev/null +++ b/code/web/templates/build/_quick_build_progress.html @@ -0,0 +1,16 @@ +{# Quick Build Progress Indicator - Current Stage + Completed List #} + +
+
+ {% include "build/_quick_build_progress_content.html" %} +
+ + {# Auto-polling with HTMX - updates content while running, stops when Step 5 returned #} + +
diff --git a/code/web/templates/build/_quick_build_progress_content.html b/code/web/templates/build/_quick_build_progress_content.html new file mode 100644 index 0000000..7669f24 --- /dev/null +++ b/code/web/templates/build/_quick_build_progress_content.html @@ -0,0 +1,15 @@ +{# Quick Build Progress Content (inner content only, for HTMX updates) #} +
+

Automatic Build in Progress

+

Building your deck automatically without approval steps...

+
+ +{# Simplified Phase Indicator #} +
+
Current Phase
+
{{ current_stage }}
+
+ +
+

This may take 10-30 seconds depending on deck complexity...

+
diff --git a/docker-compose.yml b/docker-compose.yml index 5a47ef9..9f738e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,6 +100,9 @@ services: WEB_TAG_PARALLEL: "1" # 1=parallelize tagging WEB_TAG_WORKERS: "4" # Worker count when parallel tagging + # Build Stage Ordering + WEB_STAGE_ORDER: "new" # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill + # Tagging Refinement Feature Flags TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended) TAG_PROTECTION_GRANTS: "1" # 1=Protection tag only for cards granting shields (recommended) diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml index ba712e4..4b7eb69 100644 --- a/dockerhub-docker-compose.yml +++ b/dockerhub-docker-compose.yml @@ -102,6 +102,9 @@ services: WEB_TAG_PARALLEL: "1" # 1=parallelize tagging WEB_TAG_WORKERS: "4" # Worker count when parallel tagging + # Build Stage Ordering + WEB_STAGE_ORDER: "new" # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill + # Tagging Refinement Feature Flags TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended) TAG_PROTECTION_GRANTS: "1" # 1=Protection tag only for cards granting shields (recommended) From 35bff901d26600ae5173f5392fe56c436c1c6505 Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 14 Oct 2025 16:45:49 -0700 Subject: [PATCH 2/2] feat: add ideal counts slider UI with smart validation --- .env.example | 3 + CHANGELOG.md | 12 ++ DOCKER.md | 1 + README.md | 1 + RELEASE_NOTES_TEMPLATE.md | 9 +- code/web/app.py | 1 + code/web/routes/build.py | 11 ++ code/web/static/styles.css | 49 +++++++ .../web/templates/build/_new_deck_ideals.html | 124 ++++++++++++++++++ code/web/templates/build/_new_deck_modal.html | 11 +- docker-compose.yml | 3 + dockerhub-docker-compose.yml | 3 + 12 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 code/web/templates/build/_new_deck_ideals.html diff --git a/.env.example b/.env.example index d9c19cd..c01736e 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,9 @@ WEB_AUTO_ENFORCE=0 # dockerhub: WEB_AUTO_ENFORCE="0" # Build Stage Ordering WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill +# Ideals UI Mode +WEB_IDEALS_UI=slider # input|slider. 'slider' (default): range sliders with live value display. 'input': text input boxes + # Tagging Refinement Feature Flags TAG_NORMALIZE_KEYWORDS=1 # dockerhub: TAG_NORMALIZE_KEYWORDS="1" # Normalize keywords & filter specialty mechanics TAG_PROTECTION_GRANTS=1 # dockerhub: TAG_PROTECTION_GRANTS="1" # Protection tag only for cards granting shields diff --git a/CHANGELOG.md b/CHANGELOG.md index 860dcb4..f0c04eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,18 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary - Enhanced deck building workflow with improved stage ordering, granular skip controls, and one-click Quick Build automation. +- New Ideal Counts section with interactive sliders or text inputs for customizing deck composition targets. - Stage execution order now prioritizes creatures and spells before lands for better mana curve analysis. - New wizard-only skip controls allow auto-advancing through specific stages (lands, creatures, spells) without approval prompts. - Quick Build button provides one-click full automation with clean 5-phase progress indicator. ### Added +- **Ideal Counts UI**: Dedicated section in New Deck wizard for setting ideal card counts (ramp, lands, creatures, removal, wipes, card advantage, protection). + - **Slider Mode** (default): Interactive range sliders with live value display and expanded ranges (e.g., creatures: 0-70, lands: 25-45). + - **Input Mode**: Text input boxes with placeholder defaults (e.g., "10 (Default)"). + - Smart validation warns when estimated total exceeds 99 cards (accounts for overlap: `Lands + Creatures + Spells/2`). + - Sliders start at recommended defaults and remember user preferences across builds. + - Configurable via `WEB_IDEALS_UI` environment variable (`slider` or `input`). - **Quick Build**: One-click automation button in New Deck wizard with live progress tracking (5 phases: Creatures, Spells, Lands, Final Touches, Summary). - **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: all land steps, creature stages, spell categories). - Individual land step controls: basics, staples, fetches, duals, triomes, kindred, misc lands. @@ -24,9 +31,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - **Stage Reordering**: New default build order executes creatures → spells → lands for improved pip analysis (configurable via `WEB_STAGE_ORDER` environment variable). - Background task execution for Quick Build with HTMX polling progress updates. - Mobile-friendly Quick Build with touch device confirmation dialog. +- Commander session cleanup: Commander selection automatically cleared after build completes. ### Changed - **Default Stage Order**: Creatures and ideal spells now execute before land stages (lands can analyze actual pip requirements instead of estimates). +- **Ideal Counts Display**: Removed collapsible "Advanced options (ideals)" section; replaced with prominent fieldset with slider/input modes. +- Slider ranges expanded to support edge-case strategies (e.g., creature-heavy tribal, spell-heavy control). - Skip controls only available in New Deck wizard (disabled during build execution for consistency). - Skip behavior auto-advances through stages without approval prompts (cards still added, just not gated). - Post-spell land adjustment automatically skipped when any skip flag enabled. @@ -35,6 +45,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Session context properly injected into Quick Build so skip configuration works correctly. - HTMX polling uses continuous trigger (`every 500ms`) instead of one-time (`load delay`) for reliable progress updates. - Progress indicator stops cleanly when build completes (out-of-band swap removes poller div). +- Ideal counts now properly populate from session state, allowing sliders to start at defaults and remember user preferences. +- Commander and commander_name cleared from session after build completes to prevent carryover to next build. ## [2.6.1] - 2025-10-13 ### Summary diff --git a/DOCKER.md b/DOCKER.md index 6447c0c..9ce253d 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -255,6 +255,7 @@ See `.env.example` for the full catalog. Common knobs: | `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | | `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | +| `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | ### Random build controls diff --git a/README.md b/README.md index 6e5b757..e12e294 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Start here for interactive deck creation. - `ALLOW_MUST_HAVES=1` (default) enables include/exclude enforcement. - `WEB_AUTO_ENFORCE=1` re-runs bracket enforcement automatically after each build. - `WEB_STAGE_ORDER=new` (default) runs creatures/spells before lands for better pip analysis. Use `legacy` for original lands-first order. +- `WEB_IDEALS_UI=slider` (default) shows interactive range sliders for ideal counts with live validation. Use `input` for traditional text boxes. ### Run a JSON Config Execute saved configs without manual input. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 37db04c..6792d98 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,10 +1,11 @@ # MTG Python Deckbuilder ${VERSION} ### Summary -- Enhanced deck building workflow with improved stage ordering, granular skip controls, and one-click Quick Build automation. +- Enhanced deck building workflow with improved stage ordering, granular skip controls, one-click Quick Build automation, and interactive Ideal Counts UI. - Stage execution order now prioritizes creatures and spells before lands for better mana curve analysis. - New wizard-only skip controls allow auto-advancing through specific stages (lands, creatures, spells) without approval prompts. - Quick Build button provides one-click full automation with clean 5-phase progress indicator. +- Ideal Counts now feature interactive slider UI with live validation and smart overlap detection. ### Added - **Quick Build**: One-click automation button in New Deck wizard with live progress tracking (5 phases: Creatures, Spells, Lands, Final Touches, Summary). @@ -14,6 +15,12 @@ - Creature stage controls: all creatures, primary, secondary, fill. - Mutual exclusivity enforcement: "Skip All Lands" disables individual land toggles; "Skip to Misc Lands" skips early land steps. - **Stage Reordering**: New default build order executes creatures → spells → lands for improved pip analysis (configurable via `WEB_STAGE_ORDER` environment variable). +- **Ideal Counts UI**: Interactive slider interface with live value display and smart validation (configurable via `WEB_IDEALS_UI` environment variable). + - Slider Mode (default): Range sliders for all ideal counts with expanded ranges (creatures: 0-70, lands: 25-45). + - Input Mode: Traditional text inputs with placeholder defaults showing recommended values. + - Smart Validation: Real-time deck size estimation using overlap-aware calculation (`Lands + Creatures + Spells/2`). + - Visual Warnings: Red alert (>99 cards), orange warning (90-99), no warning (<90). + - Session Persistence: Values persist across builds and initialize at defaults on first wizard load. - Background task execution for Quick Build with HTMX polling progress updates. - Mobile-friendly Quick Build with touch device confirmation dialog. diff --git a/code/web/app.py b/code/web/app.py index afdfc49..3c17093 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -128,6 +128,7 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False) ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) +WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider' ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True) ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True) RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 944c075..a3fca96 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -13,6 +13,7 @@ from ..app import ( _sanitize_theme, ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS, + WEB_IDEALS_UI, ) from ..services.build_utils import ( step5_base_ctx, @@ -1356,6 +1357,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, + "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "form": { "prefer_combos": bool(sess.get("prefer_combos")), "combo_count": sess.get("combo_target_count"), @@ -1364,6 +1366,15 @@ async def build_new_modal(request: Request) -> HTMLResponse: "use_owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + # Add ideal values from session (will be None on first load, triggering defaults) + "ramp": sess.get("ideals", {}).get("ramp"), + "lands": sess.get("ideals", {}).get("lands"), + "basic_lands": sess.get("ideals", {}).get("basic_lands"), + "creatures": sess.get("ideals", {}).get("creatures"), + "removal": sess.get("ideals", {}).get("removal"), + "wipes": sess.get("ideals", {}).get("wipes"), + "card_advantage": sess.get("ideals", {}).get("card_advantage"), + "protection": sess.get("ideals", {}).get("protection"), }, "tag_slot_html": None, } diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 6992feb..4c610c3 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -678,3 +678,52 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } + +/* Ideals Slider Styling */ +.ideals-slider { + -webkit-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; +} + +.ideals-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--ring); + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.ideals-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--ring); + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; +} + +.ideals-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} + +.slider-value { + display: inline-block; + padding: 0.25rem 0.5rem; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; +} diff --git a/code/web/templates/build/_new_deck_ideals.html b/code/web/templates/build/_new_deck_ideals.html new file mode 100644 index 0000000..38304c8 --- /dev/null +++ b/code/web/templates/build/_new_deck_ideals.html @@ -0,0 +1,124 @@ +
+ Ideal Counts +
+ Sliders start at recommended defaults. Adjust as needed for your deck strategy. + +
+
+ {% set use_sliders = ideals_ui_mode == 'slider' %} + {% set ideals_data = [ + ('ramp', labels.ramp, defaults.ramp, 0, 30), + ('lands', labels.lands, defaults.lands, 25, 45), + ('basic_lands', labels.basic_lands, defaults.basic_lands, 0, 40), + ('creatures', labels.creatures, defaults.creatures, 0, 70), + ('removal', labels.removal, defaults.removal, 0, 30), + ('wipes', labels.wipes, defaults.wipes, 0, 15), + ('card_advantage', labels.card_advantage, defaults.card_advantage, 0, 30), + ('protection', labels.protection, defaults.protection, 0, 20) + ] %} + + {% for field_name, field_label, default_val, min_val, max_val in ideals_data %} +
+ +
+ {% endfor %} +
+ + +
diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index 3aaee52..0ae200e 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -110,6 +110,7 @@ + {% include "build/_new_deck_ideals.html" %} {% if allow_must_haves %}
Include/Exclude Cards @@ -213,16 +214,6 @@ {% endif %} {% endif %} {% include "build/_new_deck_skip_controls.html" %} -
- Advanced options (ideals) -
- {% for key, label in labels.items() %} - - {% endfor %} -
-