mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat: stage reordering, skip controls, quick build, and commander session cleanup
This commit is contained in:
parent
f6a6f72950
commit
9ab3835e2a
15 changed files with 1040 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
24
CHANGELOG.md
24
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/;
|
||||
|
|
|
|||
279
code/web/templates/build/_new_deck_skip_controls.html
Normal file
279
code/web/templates/build/_new_deck_skip_controls.html
Normal 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>
|
||||
16
code/web/templates/build/_quick_build_progress.html
Normal file
16
code/web/templates/build/_quick_build_progress.html
Normal 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>
|
||||
15
code/web/templates/build/_quick_build_progress_content.html
Normal file
15
code/web/templates/build/_quick_build_progress_content.html
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue