From 9ab3835e2adb54bd9b421d5e04877f4154de11ae Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 14 Oct 2025 16:09:58 -0700 Subject: [PATCH] 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)