feat: stage reordering, skip controls, quick build, and commander session cleanup

This commit is contained in:
matt 2025-10-14 16:09:58 -07:00
parent f6a6f72950
commit 9ab3835e2a
15 changed files with 1040 additions and 34 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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).

View file

@ -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

View file

@ -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}
<div id="quick-build-poller" hx-swap-oob="outerHTML"></div>
'''
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")

View file

@ -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

View file

@ -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,

View file

@ -212,6 +212,7 @@
</div>
{% endif %}
{% endif %}
{% include "build/_new_deck_skip_controls.html" %}
<details style="margin-top:.5rem;">
<summary>Advanced options (ideals)</summary>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
@ -222,15 +223,40 @@
{% endfor %}
</div>
</details>
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:space-between; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<button type="submit" class="btn-continue">Create</button>
<div style="display:flex; gap:.5rem;">
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
<button type="submit" class="btn-continue">Create</button>
</div>
</div>
</form>
</div>
</div>
<script>
// Quick Build confirmation for mobile devices
(function() {
var quickBuildBtn = document.getElementById('quick-build-btn');
if (quickBuildBtn) {
// Detect mobile/tablet device (true touch-primary devices)
// Check for touch AND small screen or mobile user agent
var isMobile = ('ontouchstart' in window) &&
(window.innerWidth <= 1024 || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent));
if (isMobile) {
quickBuildBtn.addEventListener('click', function(e) {
var confirmed = confirm('Quick Build will create a complete deck automatically without approval steps. Continue?');
if (!confirmed) {
e.preventDefault();
e.stopPropagation();
return false;
}
});
}
}
})();
// Utility function for parsing card lists
function parseCardList(content) {
const newlineRegex = /\r?\n/;

View file

@ -0,0 +1,279 @@
{# M3: Skip Controls UI - Collapsible section with 16+ checkboxes #}
<details id="skip-controls-section" style="margin-top:.75rem;">
<summary style="cursor:pointer; font-weight:500; user-select:none;">⏭️ Skip Build Steps <span class="muted" style="font-weight:400; font-size:12px;">(optional)</span></summary>
<div style="margin-top:.75rem; padding:.75rem; border:1px solid var(--border); border-radius:8px;">
<div class="muted" style="font-size:12px; margin-bottom:.75rem;">
Skip specific build stages to speed up deck creation. The builder will auto-fill skipped stages with optimal choices.
</div>
{# Land Controls #}
<div style="margin-bottom:1rem;">
<div style="font-weight:500; margin-bottom:.5rem; font-size:13px;">🏔️ Land Stages</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:.5rem; margin-left:1rem;">
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip all land selection stages">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_lands" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip all lands</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip to misc/utility lands (skip basics, staples, kindred, fetches, duals, triomes)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_to_misc" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip to misc lands</span>
</label>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:.5rem; margin-left:2rem; margin-top:.5rem;">
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip basic lands (step 1)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_basics" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip basics</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip staple lands (step 2)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_staples" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip staples</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip kindred/tribal lands (step 3)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_kindred" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip kindred</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip fetch lands (step 4)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_fetches" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip fetches</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip dual lands - shocks, typed duals (step 5)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_duals" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip duals</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip triome/tri-color lands (step 6)">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_triomes" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip triomes</span>
</label>
</div>
</div>
{# Creature Controls #}
<div style="margin-bottom:1rem;">
<div style="font-weight:500; margin-bottom:.5rem; font-size:13px;">🦁 Creature Stages</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:.5rem; margin-left:1rem;">
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip all creature selection stages">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_all_creatures" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip all creatures</span>
</label>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:.5rem; margin-left:2rem; margin-top:.5rem;">
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip primary theme creatures">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_creature_primary" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip primary creatures</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip secondary theme creatures">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_creature_secondary" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip secondary creatures</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip creature fill stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_creature_fill" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip creature fill</span>
</label>
</div>
</div>
{# Spell Controls #}
<div style="margin-bottom:1rem;">
<div style="font-weight:500; margin-bottom:.5rem; font-size:13px;">⚡ Spell Stages</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:.5rem; margin-left:1rem;">
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip all spell selection stages">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_all_spells" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip all spells</span>
</label>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap:.5rem; margin-left:2rem; margin-top:.5rem;">
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip ramp spells stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_ramp" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip ramp</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip removal spells stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_removal" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip removal</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip board wipes stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_wipes" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip board wipes</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip card advantage stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_card_advantage" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip card advantage</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip protection spells stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_protection" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip protection</span>
</label>
<label class="skip-control-label" style="display:grid; grid-template-columns:auto 1fr; align-items:center; column-gap:0.5rem; cursor:pointer;" title="Skip spell fill stage">
<input type="checkbox" class="skip-checkbox" data-skip-key="skip_spell_fill" style="margin:0; cursor:pointer;" />
<span style="font-size:12px;">Skip spell fill</span>
</label>
</div>
</div>
{# Post-Adjust Control (read-only, auto-managed) #}
<div>
<div class="muted" style="font-size:11px; font-style:italic; padding:.5rem; background:rgba(107, 114, 128, 0.1); border-radius:6px;">
💡 Post-adjustment will be automatically skipped when any stages are skipped above.
</div>
</div>
</div>
</details>
<script>
// M3: Skip Controls - HTMX toggle with mutual exclusivity
(function() {
const skipControls = document.getElementById('skip-controls-section');
if (!skipControls) return;
const checkboxes = skipControls.querySelectorAll('.skip-checkbox');
checkboxes.forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
const skipKey = this.getAttribute('data-skip-key');
const enabled = this.checked ? '1' : '0';
// Send toggle request to backend
fetch('/build/new/toggle-skip', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'skip_key=' + encodeURIComponent(skipKey) + '&enabled=' + encodeURIComponent(enabled)
})
.then(function(response) {
return response.json();
})
.then(function(data) {
if (data.error) {
console.error('Skip toggle error:', data.error);
// Revert checkbox state on error
checkbox.checked = !checkbox.checked;
return;
}
// Apply mutual exclusivity UI updates
applyMutualExclusivity();
})
.catch(function(error) {
console.error('Skip toggle failed:', error);
// Revert checkbox state on error
checkbox.checked = !checkbox.checked;
});
});
});
function applyMutualExclusivity() {
const landGroupFlags = ['skip_lands', 'skip_to_misc'];
const individualLandFlags = ['skip_basics', 'skip_staples', 'skip_kindred', 'skip_fetches', 'skip_duals', 'skip_triomes'];
const creatureGroupFlags = ['skip_all_creatures'];
const creatureSpecificFlags = ['skip_creature_primary', 'skip_creature_secondary', 'skip_creature_fill'];
const spellGroupFlags = ['skip_all_spells'];
const spellSpecificFlags = ['skip_ramp', 'skip_removal', 'skip_wipes', 'skip_card_advantage', 'skip_protection', 'skip_spell_fill'];
// Check which flags are enabled
const enabledFlags = {};
checkboxes.forEach(function(cb) {
const key = cb.getAttribute('data-skip-key');
enabledFlags[key] = cb.checked;
});
// Rule 1: If land group flags enabled, disable individual land flags
const landGroupEnabled = landGroupFlags.some(function(key) { return enabledFlags[key]; });
individualLandFlags.forEach(function(key) {
const cb = skipControls.querySelector('[data-skip-key="' + key + '"]');
if (cb) {
const label = cb.closest('.skip-control-label');
if (landGroupEnabled) {
cb.disabled = true;
if (label) label.style.opacity = '0.5';
} else {
cb.disabled = false;
if (label) label.style.opacity = '1';
}
}
});
// Rule 2: If individual land flags enabled, disable land group flags
const individualLandEnabled = individualLandFlags.some(function(key) { return enabledFlags[key]; });
landGroupFlags.forEach(function(key) {
const cb = skipControls.querySelector('[data-skip-key="' + key + '"]');
if (cb) {
const label = cb.closest('.skip-control-label');
if (individualLandEnabled) {
cb.disabled = true;
if (label) label.style.opacity = '0.5';
} else {
cb.disabled = false;
if (label) label.style.opacity = '1';
}
}
});
// Rule 3: If creature group enabled, disable specific creature flags
const creatureGroupEnabled = creatureGroupFlags.some(function(key) { return enabledFlags[key]; });
creatureSpecificFlags.forEach(function(key) {
const cb = skipControls.querySelector('[data-skip-key="' + key + '"]');
if (cb) {
const label = cb.closest('.skip-control-label');
if (creatureGroupEnabled) {
cb.disabled = true;
if (label) label.style.opacity = '0.5';
} else {
cb.disabled = false;
if (label) label.style.opacity = '1';
}
}
});
// Rule 4: If specific creature flags enabled, disable creature group
const creatureSpecificEnabled = creatureSpecificFlags.some(function(key) { return enabledFlags[key]; });
creatureGroupFlags.forEach(function(key) {
const cb = skipControls.querySelector('[data-skip-key="' + key + '"]');
if (cb) {
const label = cb.closest('.skip-control-label');
if (creatureSpecificEnabled) {
cb.disabled = true;
if (label) label.style.opacity = '0.5';
} else {
cb.disabled = false;
if (label) label.style.opacity = '1';
}
}
});
// Rule 5: If spell group enabled, disable specific spell flags
const spellGroupEnabled = spellGroupFlags.some(function(key) { return enabledFlags[key]; });
spellSpecificFlags.forEach(function(key) {
const cb = skipControls.querySelector('[data-skip-key="' + key + '"]');
if (cb) {
const label = cb.closest('.skip-control-label');
if (spellGroupEnabled) {
cb.disabled = true;
if (label) label.style.opacity = '0.5';
} else {
cb.disabled = false;
if (label) label.style.opacity = '1';
}
}
});
// Rule 6: If specific spell flags enabled, disable spell group
const spellSpecificEnabled = spellSpecificFlags.some(function(key) { return enabledFlags[key]; });
spellGroupFlags.forEach(function(key) {
const cb = skipControls.querySelector('[data-skip-key="' + key + '"]');
if (cb) {
const label = cb.closest('.skip-control-label');
if (spellSpecificEnabled) {
cb.disabled = true;
if (label) label.style.opacity = '0.5';
} else {
cb.disabled = false;
if (label) label.style.opacity = '1';
}
}
});
}
// Apply initial state on load
applyMutualExclusivity();
})();
</script>

View file

@ -0,0 +1,16 @@
{# Quick Build Progress Indicator - Current Stage + Completed List #}
<div id="wizard" class="wizard-container" style="max-width:800px; margin:2rem auto; padding:1rem;">
<div id="wizard-content">
{% include "build/_quick_build_progress_content.html" %}
</div>
{# Auto-polling with HTMX - updates content while running, stops when Step 5 returned #}
<div id="quick-build-poller"
hx-get="/build/quick-progress"
hx-trigger="every 500ms"
hx-target="#wizard-content"
hx-swap="innerHTML"
style="display:none;">
</div>
</div>

View file

@ -0,0 +1,15 @@
{# Quick Build Progress Content (inner content only, for HTMX updates) #}
<div class="wizard-header" style="text-align:center; margin-bottom:3rem;">
<h2 style="margin:0 0 .5rem 0;">Automatic Build in Progress</h2>
<p class="muted" style="margin:0;">Building your deck automatically without approval steps...</p>
</div>
{# Simplified Phase Indicator #}
<div style="text-align:center; margin:4rem 0; padding:2rem; background:rgba(59,130,246,0.1); border:2px solid rgba(59,130,246,0.3); border-radius:8px;">
<div style="font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:#94a3b8; margin-bottom:0.75rem;">Current Phase</div>
<div style="font-size:24px; font-weight:600; color:#3b82f6;">{{ current_stage }}</div>
</div>
<div class="muted" style="text-align:center; font-size:12px; margin-top:2rem;">
<p style="margin:0;">This may take 10-30 seconds depending on deck complexity...</p>
</div>

View file

@ -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)

View file

@ -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)