diff --git a/.env.example b/.env.example index d65f0c7..313fefa 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,106 @@ -# Copy this file to `.env` and adjust values to your needs. +###################################################################### +# MTG Python Deckbuilder – Environment Variables Reference +# +# Copy this file to `.env` and uncomment the lines you want to override. +# All lines are commented so copying it is safe; defaults apply otherwise. +###################################################################### -# Set to 'headless' to auto-run the non-interactive mode on container start -# DECK_MODE=headless +############################ +# Core Application Modes +############################ +# DECK_MODE=headless # headless|auto|. When set to 'headless' (or 'auto'), runs non-interactive build on start (CLI entrypoint). +# APP_MODE=web # (Not explicitly set in dockerhub compose; uncomment to force.) +# HOST=0.0.0.0 # Uvicorn bind host (only when APP_MODE=web). +# PORT=8080 # Uvicorn port. +# WORKERS=1 # Uvicorn worker count. +APP_VERSION=v2.2.9 # Matches dockerhub compose. -# Optional JSON config path (inside the container) -# If you mount ./config to /app/config, use: -# DECK_CONFIG=/app/config/deck.json +############################ +# Theming +############################ +THEME=system # system|light|dark (initial default; user preference persists in browser). -# Common knobs -# DECK_COMMANDER=Pantlaza -# DECK_PRIMARY_CHOICE=2 +############################ +# Paths & Directories (override discovery) +############################ +# DECK_CONFIG=/app/config/deck.json # File OR directory. File: run that config. Dir: discover JSON configs. CLI>ENV precedence. +# DECK_EXPORTS=/app/deck_files # Where finished deck exports are read by Web UI. +# OWNED_CARDS_DIR=/app/owned_cards # Preferred directory for owned inventory uploads. +# CARD_LIBRARY_DIR=/app/owned_cards # Back-compat alias for OWNED_CARDS_DIR. + +############################ +# Web UI Feature Flags +############################ +SHOW_SETUP=1 # dockerhub: SHOW_SETUP="1" +SHOW_LOGS=1 # dockerhub: SHOW_LOGS="1" +SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1" +ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="1" +ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0" +ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" +WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" +ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1" + +############################ +# Automation & Performance (Web) +############################ +WEB_AUTO_SETUP=1 # dockerhub: WEB_AUTO_SETUP="1" +WEB_AUTO_REFRESH_DAYS=7 # dockerhub: WEB_AUTO_REFRESH_DAYS="7" +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" +# WEB_CUSTOM_EXPORT_BASE= # Custom basename for exports (optional). + +############################ +# Headless Export Options +############################ +# HEADLESS_EXPORT_JSON=1 # 1=export resolved run config JSON alongside CSV/TXT (headless runs only). + +############################ +# Commander & Theme Selection (Headless / Env Overrides) +############################ +# DECK_COMMANDER=Pantlaza, Sun-Favored # Commander name query. +# (Index-based theme choices – mutually exclusive with *_TAG names per slot): +# DECK_PRIMARY_CHOICE=1 # DECK_SECONDARY_CHOICE=2 -# DECK_TERTIARY_CHOICE=2 -# DECK_ADD_CREATURES=true -# DECK_ADD_NON_CREATURE_SPELLS=true -# DECK_ADD_RAMP=true -# DECK_ADD_REMOVAL=true -# DECK_ADD_WIPES=true -# DECK_ADD_CARD_ADVANTAGE=true -# DECK_ADD_PROTECTION=true -# DECK_USE_MULTI_THEME=true -# DECK_ADD_LANDS=true -# DECK_FETCH_COUNT=3 -# DECK_DUAL_COUNT= -# DECK_TRIPLE_COUNT= -# DECK_UTILITY_COUNT= +# DECK_TERTIARY_CHOICE=3 +# (Name-based theme tags – preferred; resolved to indices automatically): +# DECK_PRIMARY_TAG=Tokens +# DECK_SECONDARY_TAG=Treasure +# DECK_TERTIARY_TAG=Sacrifice +# DECK_BRACKET_LEVEL=3 # 1–5 Power/Bracket selection. + +############################ +# Category Toggles (Spell / Creature / Land Inclusion) +############################ +# DECK_ADD_LANDS=1 # Include land-building sequence. +# DECK_ADD_CREATURES=1 # Add creatures. +# DECK_ADD_NON_CREATURE_SPELLS=1 # Bulk add for non-creatures (if supported); else individual toggles below. +# DECK_ADD_RAMP=1 +# DECK_ADD_REMOVAL=1 +# DECK_ADD_WIPES=1 +# DECK_ADD_CARD_ADVANTAGE=1 +# DECK_ADD_PROTECTION=1 + +############################ +# Land Count Requests / Adjustments +############################ +# DECK_FETCH_COUNT=3 # Requested fetch land count. +# DECK_DUAL_COUNT= # Requested dual land count (optional). +# DECK_TRIPLE_COUNT= # Requested triple land count (optional). +# DECK_UTILITY_COUNT= # Requested utility land count (optional). + +############################ +# Optional Convenience / Misc (normally container-set or not required) +############################ +PYTHONUNBUFFERED=1 # Improves real-time log flushing. +TERM=xterm-256color # Terminal color capability. +DEBIAN_FRONTEND=noninteractive # Suppress apt UI in Docker builds. + +###################################################################### +# Notes +# - CLI arguments override env vars; env overrides JSON config; JSON overrides defaults. +# - For include/exclude card functionality enable ALLOW_MUST_HAVES=1 (Web) and use UI or CLI flags. +# - Path overrides must point to mounted volumes inside the container. +# - Remove a value or leave it commented to fall back to internal defaults. +###################################################################### diff --git a/.github/workflows/dockerhub-publish.yml b/.github/workflows/dockerhub-publish.yml index 00ae561..08d89f4 100644 --- a/.github/workflows/dockerhub-publish.yml +++ b/.github/workflows/dockerhub-publish.yml @@ -15,7 +15,6 @@ jobs: outputs: version: ${{ steps.notes.outputs.version }} desc: ${{ steps.notes.outputs.desc }} - tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} steps: - name: Checkout @@ -38,14 +37,13 @@ jobs: echo "desc=$DESC" >> $GITHUB_OUTPUT echo "version=$VERSION_REF" >> $GITHUB_OUTPUT - - name: Extract Docker metadata + - name: Extract Docker metadata (latest only) id: meta uses: docker/metadata-action@v5.8.0 with: images: | mwisnowski/mtg-python-deckbuilder tags: | - type=semver,pattern={{version}} type=raw,value=latest labels: | org.opencontainers.image.title=MTG Python Deckbuilder @@ -65,18 +63,11 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 - - name: Prepare amd64 tag list - id: arch_tags + - name: Compute amd64 tag + id: arch_tag shell: bash run: | - echo "Generating amd64 tag variants" >&2 - TAGS='${{ needs.prepare.outputs.tags }}' - # Exclude 'latest' so we don't leave latest-amd64 dangling; only final manifest will produce plain 'latest' - echo "$TAGS" | grep -v ':latest$' | sed 's/$/-amd64/' | grep . > tags.txt - echo "Computed tags:" >&2; cat tags.txt >&2 - echo "tags<> $GITHUB_OUTPUT - cat tags.txt >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + echo "tag=mwisnowski/mtg-python-deckbuilder:${{ needs.prepare.outputs.version }}-amd64" >> $GITHUB_OUTPUT - name: Docker Hub login uses: docker/login-action@v3.5.0 @@ -114,7 +105,7 @@ jobs: file: ./Dockerfile push: true platforms: linux/amd64 - tags: ${{ steps.arch_tags.outputs.tags }} + tags: ${{ steps.arch_tag.outputs.tag }} labels: ${{ needs.prepare.outputs.labels }} build-args: | APP_VERSION=${{ needs.prepare.outputs.version }} @@ -129,17 +120,11 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 - - name: Prepare arm64 tag list (fallback) - id: arch_tags + - name: Compute arm64 tag + id: arch_tag shell: bash run: | - echo "Generating arm64 tag variants (fallback)" >&2 - TAGS='${{ needs.prepare.outputs.tags }}' - # Exclude 'latest' so only final manifest produces plain 'latest' - echo "$TAGS" | grep -v ':latest$' | sed 's/$/-arm64/' | grep . > tags.txt - echo "tags<> $GITHUB_OUTPUT - cat tags.txt >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + echo "tag=mwisnowski/mtg-python-deckbuilder:${{ needs.prepare.outputs.version }}-arm64" >> $GITHUB_OUTPUT - name: Docker Hub login uses: docker/login-action@v3.5.0 @@ -153,20 +138,20 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@v3.11.1 - - name: Build & push arch image (arm64 emulated) + - name: Build & push arch image (arm64) uses: docker/build-push-action@v6.18.0 with: context: . file: ./Dockerfile push: true platforms: linux/arm64 - tags: ${{ steps.arch_tags.outputs.tags }} + tags: ${{ steps.arch_tag.outputs.tag }} labels: ${{ needs.prepare.outputs.labels }} build-args: | APP_VERSION=${{ needs.prepare.outputs.version }} manifest: - name: Create multi-arch manifests + name: Create latest multi-arch manifest runs-on: ubuntu-latest needs: [prepare, build_amd64, build_arm64] steps: @@ -175,36 +160,16 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & push manifests + - name: Create & push latest multi-arch manifest shell: bash - env: - TAGS: ${{ needs.prepare.outputs.tags }} run: | - echo "Creating multi-arch manifests (amd64 + arm64)..." - VERSION_PRIMARY=$(echo "$TAGS" | grep -v ':latest$' | head -n1) - echo "$TAGS" | while read -r tag; do - [ -z "$tag" ] && continue - echo "Processing $tag" - # For 'latest', reuse the version tag's arch images (we did not push latest-amd64/-arm64) - if [[ "$tag" == *":latest" ]]; then - BASE="$VERSION_PRIMARY" - else - BASE="$tag" - fi - SOURCES="${BASE}-amd64" - if docker buildx imagetools inspect "${BASE}-arm64" >/dev/null 2>&1; then - echo "Found arm64 image tag: ${BASE}-arm64" - SOURCES="$SOURCES ${BASE}-arm64" - else - echo "No arm64 image found for $tag (skipping arm64 in manifest)" >&2 - fi - docker buildx imagetools create -t "$tag" $SOURCES - done - echo "Done." - - - name: Inspect primary tag - run: | - FIRST=$(echo "$TAGS" | head -n1) - docker buildx imagetools inspect "$FIRST" + set -euo pipefail + VERSION='${{ needs.prepare.outputs.version }}' + AMD_TAG="mwisnowski/mtg-python-deckbuilder:${VERSION}-amd64" + ARM_TAG="mwisnowski/mtg-python-deckbuilder:${VERSION}-arm64" + echo "Creating manifest: latest -> ${AMD_TAG} + ${ARM_TAG}" + SOURCES="$AMD_TAG $ARM_TAG" + docker buildx imagetools create -t mwisnowski/mtg-python-deckbuilder:latest $SOURCES + echo "Inspecting latest" + docker buildx imagetools inspect mwisnowski/mtg-python-deckbuilder:latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 9604c08..d87ec54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,13 +13,32 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -- (placeholder) +- Misc land step: dynamic EDHREC keep percentage range (roll between 75%-100%) via `MISC_LAND_EDHREC_KEEP_PERCENT_MIN/MAX` for more variety in utility land pools +- Alternatives: initial land support – when requesting alternatives for a land, endpoint now returns land-only suggestions (basics → other basics; non-basics → other non-basics) with heuristic sub-category narrowing on large pools. +- Land alternatives now randomize: 12 suggestions sampled each request from a randomly sized window within the top 60–100 ranked land candidates (per-card, no caching) for higher variety. +- Misc land debug CSV exports gated behind `MISC_LAND_DEBUG` or diagnostics flag; not produced in normal runs. ### Changed -- (placeholder) +- Misc land step now excludes all fetch lands outright (they're handled earlier); reason recorded as `fetch-skip-misc` in diagnostics CSV +- Legacy single-value `MISC_LAND_EDHREC_KEEP_PERCENT` retained as fallback if min/max not defined +- Documentation: README and compose files updated with misc land tuning env vars (`MISC_LAND_DEBUG`, dynamic EDHREC keep range, theme weighting multipliers) ### Fixed -- (placeholder) +- (placeholder) – no current unreleased land alternatives bugs logged + - Step 5 card grid scroll flicker at bottom: added overscroll containment and skip virtualization for small (<80 items) grids to prevent upward jump when reaching end + +## [2.2.9] - 2025-09-10 + +### Added +- Dynamic misc utility land EDHREC keep range env docs and theme weighting overrides +- Land alternatives randomization (12 suggestions from random top 60–100 window) and land-only parity filtering + +### Changed +- Compose and README updated with new misc land tuning environment variables + +### Fixed +- Step 5 scroll flicker at bottom for small grids (virtualization skip <80 items + overscroll containment) +- Fetch lands excluded from misc land step; mono-color rainbow filtering improvements ## [2.2.8] - 2025-09-10 diff --git a/README.md b/README.md index 4a4ce31..0b3840d 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index d4725f9..e542e12 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,82 +1,57 @@ # MTG Python Deckbuilder ${VERSION} ## Highlights -- **Quality & Observability Complete**: Comprehensive structured logging system with event tracking for include/exclude operations providing detailed diagnostics and operational insights. -- **Include/Exclude Cards Feature Complete**: Full implementation with enhanced web UI, intelligent fuzzy matching, color identity validation, and performance optimization. Users can now specify must-include and must-exclude cards with comprehensive EDH format compliance. -- **Enhanced CLI with Type Safety**: Comprehensive CLI enhancement with type indicators, ideal count arguments, and theme tag name support making headless operation more user-friendly and discoverable. -- **Theme Tag Name Selection**: Intelligent theme selection by name instead of index numbers, automatically resolving to correct choices accounting for selection ordering. -- **Enhanced Fuzzy Matching**: Advanced algorithm with 300+ Commander-legal card knowledge base, popular/iconic card prioritization, and dark theme confirmation modal for optimal user experience. -- **Mobile Responsive Design**: Optimized mobile experience with bottom-floating build controls, two-column grid layout, and horizontal scrolling prevention for improved thumb navigation. -- **Enhanced Visual Validation**: List size validation UI with warning icons (⚠️ over-limit, ⚡ approaching limit) and color coding providing clear feedback on usage limits. -- **Performance Optimized**: All operations exceed performance targets with 100% pass rate - exclude filtering 92% under target, UI operations 70% under target, full validation cycle 95% under target. -- **Dual Architecture Support**: Seamless functionality across both web interface (staging system) and CLI (direct build) with proper include injection timing. +- Dynamic misc utility land variety: EDHREC keep percentage now randomly rolls between configurable min/max each build (defaults 75%–100%). +- Land alternatives overhaul: land-aware suggestions (basics→basics, non-basics→non-basics) plus randomized 12-card window (random slice of top 60–100) for per-request variety. +- Cleaner mono-color utility land pools: rainbow/any-color filler and fetch lands excluded after their dedicated phases; explicit allow-list preserves strategic exceptions. +- Theme-aware misc land weighting with configurable multipliers (base + per-extra + cap) via new environment overrides. +- Production-friendly diagnostics: misc land debug CSVs gated behind `MISC_LAND_DEBUG` or diagnostics flag (off by default). +- UI polish & stability: eliminated Step 5 bottom-of-grid scroll flicker (overscroll containment + skip virtualization for small grids <80 items). +- Documentation & compose updates: all new tuning variables surfaced in README, compose files, and sample env. -## What's new -- **Quality & Observability** - - Structured logging with event types: EXCLUDE_FILTER, INCLUDE_EXCLUDE_CONFLICT, STRICT_MODE_SUCCESS/FAILURE, INCLUDE_COLOR_VIOLATION - - Comprehensive diagnostics for include/exclude operations with performance metrics and validation results - - Enhanced error tracking and operational visibility for debugging and monitoring -- **Enhanced CLI Experience** - - Type-safe help text with value indicators (PATH, NAME, INT, BOOL) and organized argument groups - - Ideal count CLI arguments: `--ramp-count`, `--land-count`, `--creature-count`, etc. for deck composition control - - Theme tag name support: `--primary-tag "Airbending"` instead of `--primary-choice 1` with intelligent resolution - - Include/exclude CLI parity: `--include-cards`, `--exclude-cards` with semicolon support for comma-containing card names - - Console summary output with detailed diagnostics and validation results for headless builds - - Priority system: CLI > JSON Config > Environment Variables > Defaults -- **Enhanced Visual Validation** - - List size validation UI with visual warning system using icons and color coding - - Live validation badges showing count/limit status with clear visual indicators - - Performance-optimized validation with all targets exceeded (100% pass rate) - - Backward compatibility verification ensuring existing modals/flows unchanged -- **Include/Exclude Lists** - - Must-include cards (max 10) and must-exclude cards (max 15) with strict/warn enforcement modes - - Enhanced fuzzy matching algorithm with 300+ Commander-legal card knowledge base - - Popular cards (184) and iconic cards (102) prioritization for improved matching accuracy - - Dark theme confirmation modal with card preview and top 3 alternatives for <90% confidence matches - - **EDH color identity validation**: Automatic checking of included cards against commander color identity with clear illegal status feedback - - File upload support (.txt) with deduplication and user feedback - - JSON export/import preserving all include/exclude configuration via permalink system -- **Web Interface Enhancement** - - Two-column layout with visual distinction: green for includes, red for excludes - - Chips/tag UI allowing per-card removal with real-time textarea synchronization - - Enhanced validation endpoint with comprehensive diagnostics and conflict detection - - Debounced validation (500ms) for improved performance during typing - - Enter key handling fixes preventing accidental form submission in textareas - - Mobile responsive design with bottom-floating build controls and two-column grid layout - - Mobile horizontal scrolling prevention and setup status optimization - - Expanded mobile viewport breakpoint (720px → 1024px) for broader device compatibility -- **Engine Integration** - - Include injection after land selection, before creature/spell fill ensuring proper deck composition - - Exclude re-entry prevention blocking filtered cards from re-entering via downstream heuristics - - Staging system architecture with custom `__inject_includes__` runner for web UI builds - - Comprehensive logging and diagnostics for observability and debugging +## Added +- Land alternatives: land-only mode with parity filtering (mono-color exclusions, rainbow text heuristics, fetch exclusion, World Tree legality check). +- Randomized land alternative selection: 12 suggestions from a random window size inside the top 60–100 ranked candidates (uncached for variety). +- Dynamic EDHREC keep range: `MISC_LAND_EDHREC_KEEP_PERCENT_MIN/MAX` (falls back to legacy single `MISC_LAND_EDHREC_KEEP_PERCENT` if min/max unset). +- Misc land theme weighting overrides: `MISC_LAND_THEME_MATCH_BASE`, `MISC_LAND_THEME_MATCH_PER_EXTRA`, `MISC_LAND_THEME_MATCH_CAP`. +- Debug gating: `MISC_LAND_DEBUG=1` to emit misc land candidate/post-filter CSVs (otherwise only when diagnostics enabled). -## Performance Benchmarks (Complete) -- **Exclude filtering**: 4.0ms (target: ≤50ms) - 92% under target ✅ -- **Fuzzy matching**: 0.1ms (target: ≤200ms) - 99.9% under target ✅ -- **Include injection**: 14.8ms (target: ≤100ms) - 85% under target ✅ -- **Full validation cycle**: 26.0ms (target: ≤500ms) - 95% under target ✅ -- **UI operations**: 15.0ms (target: ≤50ms) - 70% under target ✅ -- **Overall pass rate**: 5/5 (100%) with excellent performance margins +## Changed +- Fetch lands fully excluded from misc land (utility) step; they are handled earlier and no longer appear as filler. +- Mono-color pass prunes broad rainbow/any-color lands (except allow-list) using expanded text phrase heuristics. +- Alternatives endpoint skips caching for land role to preserve per-request randomness; non-land roles retain cache. +- Compose / README / .env example updated with new land tuning variables. +- Virtualization system now skips small grids (<80 items) to reduce overhead and prevent layout-induced scroll snapping. -## Technical Details -- **Architecture**: Dual implementation supporting web UI staging system and CLI direct build paths -- **Performance**: All operations well under target response times with comprehensive testing framework -- **Backward Compatibility**: Legacy endpoint transformation maintaining exact message formats for seamless integration -- **Feature Flag**: `ALLOW_MUST_HAVES=true` environment variable for controlled rollout +## Fixed +- Step 5 scroll flicker / bounce when reaching bottom of short grids (overscroll containment + virtualization threshold). +- Random land alternatives previously surfacing excluded or fetch lands—now aligned with misc step filters. -## Notes -- Include cards are injected after lands but before normal creature/spell selection to ensure optimal deck composition -- Exclude cards are globally filtered from all card pools preventing any possibility of inclusion -- Enhanced fuzzy matching handles common variations and prioritizes popular Commander staples like Lightning Bolt, Sol Ring, Counterspell -- Fuzzy match confirmation modal provides card preview and suggestions when confidence is below 90% -- Card knowledge base contains 300+ Commander-legal cards organized by function rather than competitive format -- Strict mode will abort builds if any valid include cards cannot be added; warn mode continues with diagnostics -- Visual warning system provides clear feedback when approaching or exceeding list size limits +## Environment Variables (new / updated) +| Variable | Purpose | Default | +|----------|---------|---------| +| MISC_LAND_EDHREC_KEEP_PERCENT_MIN | Lower bound for dynamic EDHREC keep % (0–1) | 0.75 | +| MISC_LAND_EDHREC_KEEP_PERCENT_MAX | Upper bound for dynamic EDHREC keep % (0–1) | 1.0 | +| MISC_LAND_EDHREC_KEEP_PERCENT | Legacy single fixed keep % (fallback) | 0.80 | +| MISC_LAND_DEBUG | Emit misc land debug CSVs | Off | +| MISC_LAND_THEME_MATCH_BASE | Base multiplier for first theme match | 1.4 | +| MISC_LAND_THEME_MATCH_PER_EXTRA | Increment per additional matching theme | 0.15 | +| MISC_LAND_THEME_MATCH_CAP | Cap on total theme multiplier | 2.0 | -## Fixes -- Resolved critical architecture mismatch where web UI and CLI used different build paths -- Fixed form submission issues where include cards weren't saving properly -- Corrected comma parsing that was breaking card names containing commas -- Fixed backward compatibility test failures with warning message format standardization -- Eliminated debug and emergency logging messages for production readiness +## Upgrade Notes +1. No migration steps required; defaults mirror prior behavior but introduce controlled randomness for utility land variety. +2. To restore pre-random behavior, set MIN=MAX=1.0 (or rely on legacy `MISC_LAND_EDHREC_KEEP_PERCENT`). +3. If deterministic land alternatives are needed for testing, consider temporarily disabling randomness (future flag can be added). +4. To analyze utility land selection, enable diagnostics or set `MISC_LAND_DEBUG=1` before running a build; CSVs appear under `logs/` (or diagnostic export path) only when enabled. + +## Testing & Quality +- Existing fast test suite passes (include/exclude + summary utilities). Additional targeted tests for randomized window selection can be added in a follow-up if deterministic mode is introduced. +- Manual validation: multiple builds confirm varied utility land pools and land alternatives without fetch/rainbow leakage. + +## Future Follow-ups (Optional) +- Deterministic toggle for land alternative randomization (e.g., `LAND_ALTS_DETERMINISTIC=1`). +- Unit tests focusing on edge-case mono-color filtering and theme weighting bounds. +- Potential adaptive virtualization row-height measurement per column for further smoothness (currently fixed estimate works acceptably). + +--- +Generated template ready for tagging release `${VERSION}` (update actual version number in CI/CD pipeline or tagging script). diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index c916d60..78e5749 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -167,6 +167,77 @@ MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from MISC_LAND_TOP_POOL_SIZE: Final[int] = 30 # For utility step: sample from top N by EDHREC rank MISC_LAND_COLOR_FIX_PRIORITY_WEIGHT: Final[int] = 2 # Weight multiplier for color-fixing candidates +MISC_LAND_USE_FULL_POOL: Final[bool] = True # If True, ignore TOP_POOL_SIZE and use entire remaining land pool for misc step +MISC_LAND_EDHREC_KEEP_PERCENT: Final[float] = 0.80 # Legacy single-value fallback if min/max not set +# When both min & max are defined (0 bool: distinct = {cw for cw in bc.COLORED_MANA_SYMBOLS if cw in text_lower} return len(distinct) >= 2 - # --------------------------------------------------------------------------- # Weighted sampling & fetch helpers # --------------------------------------------------------------------------- @@ -395,6 +394,43 @@ def weighted_sample_without_replacement(pool: list[tuple[str, int | float]], k: chosen.append(nm) return chosen +# ----------------------------- +# Land Debug Export Helper +# ----------------------------- +def export_current_land_pool(builder, label: str) -> None: + """Write a CSV snapshot of current land candidates (full dataframe filtered to lands). + + Outputs to logs/debug/land_step_{label}_test.csv. Guarded so it only runs if the combined + dataframe exists. Designed for diagnosing filtering shrinkage between land steps. + """ + try: # pragma: no cover - diagnostics + df = getattr(builder, '_combined_cards_df', None) + if df is None or getattr(df, 'empty', True): + return + col = 'type' if 'type' in df.columns else ('type_line' if 'type_line' in df.columns else None) + if not col: + return + land_df = df[df[col].fillna('').str.contains('Land', case=False, na=False)].copy() + if land_df.empty: + return + import os + os.makedirs(os.path.join('logs','debug'), exist_ok=True) + export_cols = [c for c in ['name','type','type_line','manaValue','edhrecRank','colorIdentity','manaCost','themeTags','oracleText'] if c in land_df.columns] + path = os.path.join('logs','debug', f'land_step_{label}_test.csv') + try: + if export_cols: + land_df[export_cols].to_csv(path, index=False, encoding='utf-8') + else: + land_df.to_csv(path, index=False, encoding='utf-8') + except Exception: + land_df.to_csv(path, index=False) + try: + builder.output_func(f"[DEBUG] Wrote land_step_{label}_test.csv ({len(land_df)} rows)") + except Exception: + pass + except Exception: + pass + def count_existing_fetches(card_library: dict) -> int: bc = __import__('deck_builder.builder_constants', fromlist=['FETCH_LAND_MAX_CAP']) @@ -439,6 +475,74 @@ def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: i return out[:top_n] +# --------------------------------------------------------------------------- +# Misc land filtering helpers (mono-color exclusions & tribal weighting) +# --------------------------------------------------------------------------- +def is_mono_color(builder) -> bool: + try: + ci = getattr(builder, 'color_identity', []) or [] + return len([c for c in ci if c in ('W','U','B','R','G')]) == 1 + except Exception: + return False + + +def has_kindred_theme(builder) -> bool: + try: + tags = [t.lower() for t in (getattr(builder, 'selected_tags', []) or [])] + return any(('kindred' in t or 'tribal' in t) for t in tags) + except Exception: + return False + + +def is_kindred_land(name: str) -> bool: + """Return True if the land is considered kindred-oriented (unified constant).""" + from . import builder_constants as bc # local import to avoid cycles + kindred = set(getattr(bc, 'KINDRED_LAND_NAMES', [])) or {d['name'] for d in getattr(bc, 'KINDRED_STAPLE_LANDS', [])} + return name in kindred + + +def misc_land_excluded_in_mono(builder, name: str) -> bool: + """Return True if a land should be excluded in mono-color decks per constant list. + + Exclusion rules: + - Only applies if deck is mono-color. + - Never exclude items in MONO_COLOR_MISC_LAND_KEEP_ALWAYS. + - Never exclude tribal/kindred lands (they may be down-weighted separately if no theme). + - Always exclude The World Tree if not 5-color identity. + """ + from . import builder_constants as bc + try: + ci = getattr(builder, 'color_identity', []) or [] + # World Tree legality check (needs all five colors in identity) + if name == 'The World Tree' and set(ci) != {'W','U','B','R','G'}: + return True + if not is_mono_color(builder): + return False + if name in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', []): + return False + if is_kindred_land(name): + return False + if name in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', []): + return True + except Exception: + return False + return False + + +def adjust_misc_land_weight(builder, name: str, base_weight: int | float) -> int | float: + """Adjust weight for tribal lands when no tribal theme present. + + If land is tribal and no kindred theme, weight is reduced (min 1) by factor. + """ + if is_kindred_land(name) and not has_kindred_theme(builder): + try: + # Ensure we don't drop below 1 (else risk exclusion by sampling step) + return max(1, int(base_weight * 0.5)) + except Exception: + return base_weight + return base_weight + + # --------------------------------------------------------------------------- # Generic DataFrame helpers (tag normalization & sorting) # --------------------------------------------------------------------------- diff --git a/code/deck_builder/phases/phase2_lands_basics.py b/code/deck_builder/phases/phase2_lands_basics.py index 5b93c0d..5f9788a 100644 --- a/code/deck_builder/phases/phase2_lands_basics.py +++ b/code/deck_builder/phases/phase2_lands_basics.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Dict, Optional from .. import builder_constants as bc +import os """Phase 2 (part 1): Basic land addition logic (Land Step 1). @@ -39,6 +40,33 @@ class LandBasicsMixin: self.output_func(f"Cannot add basics until color identity resolved: {e}") return + # DEBUG EXPORT: write full land pool snapshot the first time basics are added + # Purpose: allow inspection of all candidate land cards before other land steps mutate state. + try: # pragma: no cover (diagnostic aid) + full_df = getattr(self, '_combined_cards_df', None) + marker_attr = '_land_debug_export_done' + if full_df is not None and not getattr(self, marker_attr, False): + land_df = full_df + # Prefer 'type' column (common) else attempt 'type_line' + col = 'type' if 'type' in land_df.columns else ('type_line' if 'type_line' in land_df.columns else None) + if col: + work = land_df[land_df[col].fillna('').str.contains('Land', case=False, na=False)].copy() + if not work.empty: + os.makedirs(os.path.join('logs', 'debug'), exist_ok=True) + export_cols = [c for c in ['name','type','type_line','manaValue','edhrecRank','colorIdentity','manaCost','themeTags','oracleText'] if c in work.columns] + path = os.path.join('logs','debug','land_test.csv') + try: + if export_cols: + work[export_cols].to_csv(path, index=False, encoding='utf-8') + else: + work.to_csv(path, index=False, encoding='utf-8') + except Exception: + work.to_csv(path, index=False) + self.output_func(f"[DEBUG] Wrote land_test.csv ({len(work)} rows)") + setattr(self, marker_attr, True) + except Exception: + pass + # Ensure ideal counts (for min basics & total lands) basic_min: Optional[int] = None land_total: Optional[int] = None @@ -108,6 +136,11 @@ class LandBasicsMixin: def run_land_step1(self): # type: ignore[override] """Public wrapper to execute land building step 1 (basics).""" self.add_basic_lands() + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '1') + except Exception: + pass __all__ = [ diff --git a/code/deck_builder/phases/phase2_lands_duals.py b/code/deck_builder/phases/phase2_lands_duals.py index 491513a..7db15f2 100644 --- a/code/deck_builder/phases/phase2_lands_duals.py +++ b/code/deck_builder/phases/phase2_lands_duals.py @@ -212,6 +212,11 @@ class LandDualsMixin: def run_land_step5(self, requested_count: int | None = None): # type: ignore[override] self.add_dual_lands(requested_count=requested_count) self._enforce_land_cap(step_label="Duals (Step 5)") # type: ignore[attr-defined] + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '5') + except Exception: + pass __all__ = [ 'LandDualsMixin' diff --git a/code/deck_builder/phases/phase2_lands_fetch.py b/code/deck_builder/phases/phase2_lands_fetch.py index 342b709..57de480 100644 --- a/code/deck_builder/phases/phase2_lands_fetch.py +++ b/code/deck_builder/phases/phase2_lands_fetch.py @@ -156,6 +156,11 @@ class LandFetchMixin: desired = requested_count self.add_fetch_lands(requested_count=desired) self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined] + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '4') + except Exception: + pass __all__ = [ 'LandFetchMixin' diff --git a/code/deck_builder/phases/phase2_lands_kindred.py b/code/deck_builder/phases/phase2_lands_kindred.py index dffcd61..bca1827 100644 --- a/code/deck_builder/phases/phase2_lands_kindred.py +++ b/code/deck_builder/phases/phase2_lands_kindred.py @@ -145,6 +145,11 @@ class LandKindredMixin: """Public wrapper to add kindred-focused lands.""" self.add_kindred_lands() self._enforce_land_cap(step_label="Kindred (Step 3)") # type: ignore[attr-defined] + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '3') + except Exception: + pass __all__ = [ diff --git a/code/deck_builder/phases/phase2_lands_misc.py b/code/deck_builder/phases/phase2_lands_misc.py index cfd7371..f4cf589 100644 --- a/code/deck_builder/phases/phase2_lands_misc.py +++ b/code/deck_builder/phases/phase2_lands_misc.py @@ -1,6 +1,8 @@ from __future__ import annotations from typing import Optional, List, Dict +import os +import csv from .. import builder_constants as bc from .. import builder_utils as bu @@ -9,15 +11,16 @@ from .. import builder_utils as bu class LandMiscUtilityMixin: """Mixin for Land Building Step 7: Misc / Utility Lands. - Provides: - - add_misc_utility_lands - - run_land_step7 - - tag-driven suggestion queue helpers (_build_tag_driven_land_suggestions, _apply_land_suggestions_if_room) - - Extracted verbatim (with light path adjustments) from original monolithic builder. + Clean, de-duplicated implementation with: + - Dynamic EDHREC percent (roll between MIN/MAX for variety) + - Theme weighting + - Mono-color rainbow text filtering + - Exclusion of all fetch lands (fetch step handles them earlier) + - Diagnostics & CSV exports """ def add_misc_utility_lands(self, requested_count: Optional[int] = None): # type: ignore[override] + # --- Initialization & candidate collection --- if not getattr(self, 'files_to_load', None): try: self.determine_color_identity() @@ -29,54 +32,191 @@ class LandMiscUtilityMixin: if df is None or df.empty: self.output_func("Misc Lands: No card pool loaded.") return - land_target = getattr(self, 'ideal_counts', {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) if getattr(self, 'ideal_counts', None) else getattr(bc, 'DEFAULT_LAND_COUNT', 35) current = self._current_land_count() - remaining_capacity = max(0, land_target - current) - if remaining_capacity <= 0: - remaining_capacity = 0 - min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if hasattr(self, 'ideal_counts') and self.ideal_counts: min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) basic_floor = self._basic_floor(min_basic_cfg) - - if requested_count is not None: - desired = max(0, int(requested_count)) - else: - desired = max(0, land_target - current) + desired = max(0, int(requested_count)) if requested_count is not None else max(0, land_target - current) if desired == 0: self.output_func("Misc Lands: No remaining land capacity; skipping.") return - basics = self._basic_land_names() already = set(self.card_library.keys()) top_n = getattr(bc, 'MISC_LAND_TOP_POOL_SIZE', 30) - top_candidates = bu.select_top_land_candidates(df, already, basics, top_n) + use_full = getattr(bc, 'MISC_LAND_USE_FULL_POOL', False) + effective_n = 999999 if use_full else top_n + top_candidates = bu.select_top_land_candidates(df, already, basics, effective_n) + # Dynamic EDHREC keep percent + pct_min = getattr(bc, 'MISC_LAND_EDHREC_KEEP_PERCENT_MIN', None) + pct_max = getattr(bc, 'MISC_LAND_EDHREC_KEEP_PERCENT_MAX', None) + if isinstance(pct_min, float) and isinstance(pct_max, float) and 0 < pct_min <= pct_max <= 1: + rng = getattr(self, 'rng', None) + keep_pct = rng.uniform(pct_min, pct_max) if rng else (pct_min + pct_max) / 2.0 + else: + keep_pct = getattr(bc, 'MISC_LAND_EDHREC_KEEP_PERCENT', 1.0) + if 0 < keep_pct < 1 and top_candidates: + orig_len = len(top_candidates) + trimmed_len = max(1, int(orig_len * keep_pct)) + if trimmed_len < orig_len: + top_candidates = top_candidates[:trimmed_len] + if getattr(self, 'show_diagnostics', False): + self.output_func(f"[Diagnostics] Misc Step EDHREC top% applied: kept {trimmed_len}/{orig_len} (rolled pct={keep_pct:.3f})") + if use_full and getattr(self, 'show_diagnostics', False): + self.output_func(f"[Diagnostics] Misc Step using FULL pool (size request={effective_n}, actual candidates={len(top_candidates)})") if not top_candidates: self.output_func("Misc Lands: No remaining candidate lands.") return - - weighted_pool: List[tuple[str,int]] = [] + # --- Setup weighting state --- base_weight_fix = getattr(bc, 'MISC_LAND_COLOR_FIX_PRIORITY_WEIGHT', 2) - fetch_names = set() + fetch_names: set[str] = set() for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): for nm in seq: fetch_names.add(nm) for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): fetch_names.add(nm) - existing_fetch_count = bu.count_existing_fetches(self.card_library) - fetch_cap = getattr(bc, 'FETCH_LAND_MAX_CAP', 99) - remaining_fetch_slots = max(0, fetch_cap - existing_fetch_count) - + colors = list(getattr(self, 'color_identity', []) or []) + mono = len(colors) <= 1 + selected_tags_lower = [t.lower() for t in (getattr(self, 'selected_tags', []) or [])] + kindred_deck = any('kindred' in t or 'tribal' in t for t in selected_tags_lower) + mono_exclude = set(getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])) + mono_keep_always = set(getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])) + kindred_all = set(getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])) + text_rainbow_enabled = getattr(bc, 'MONO_COLOR_EXCLUDE_RAINBOW_TEXT', True) + extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])] + any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])] + weighted_pool: List[tuple[str,int]] = [] + detail_rows: List[Dict[str,str]] = [] + filtered_out: List[str] = [] + considered = 0 + debug_entries: List[tuple[str,int,str]] = [] + dump_pool = getattr(self, 'show_diagnostics', False) or bool(os.getenv('SHOW_MISC_POOL')) + # Pre-filter export + debug_enabled = getattr(self, 'show_diagnostics', False) or bool(os.getenv('MISC_LAND_DEBUG')) + if debug_enabled: + try: # pragma: no cover + os.makedirs(os.path.join('logs','debug'), exist_ok=True) + cand_path = os.path.join('logs','debug','land_step7_candidates.csv') + with open(cand_path, 'w', newline='', encoding='utf-8') as fh: + wcsv = csv.writer(fh) + wcsv.writerow(['name','edhrecRank','type_line','has_color_fixing_terms']) + for edh_val, cname, ctline, ctext_lower in top_candidates: + wcsv.writerow([cname, edh_val, ctline, int(bu.is_color_fixing_land(ctline, ctext_lower))]) + except Exception: + pass + deck_theme_tags = [t.lower() for t in (getattr(self, 'selected_tags', []) or [])] + theme_enabled = getattr(bc, 'MISC_LAND_THEME_MATCH_ENABLED', True) and bool(deck_theme_tags) for edh_val, name, tline, text_lower in top_candidates: + considered += 1 + note_parts: List[str] = [] + if name in self.card_library: + note_parts.append('already-added') + if mono and name in mono_exclude and name not in mono_keep_always and name not in kindred_all: + filtered_out.append(name) + detail_rows.append({'name': name,'status':'filtered','reason':'mono-exclude','weight':'0'}) + continue + if mono and text_rainbow_enabled and name not in mono_keep_always and name not in kindred_all: + if any(p in text_lower for p in any_color_phrases + extra_rainbow_terms): + filtered_out.append(name) + detail_rows.append({'name': name,'status':'filtered','reason':'mono-rainbow-text','weight':'0'}) + continue + if name == 'The World Tree' and set(colors) != {'W','U','B','R','G'}: + filtered_out.append(name) + detail_rows.append({'name': name,'status':'filtered','reason':'world-tree-illegal','weight':'0'}) + continue + # Exclude all fetch lands entirely in this phase + if name in fetch_names: + filtered_out.append(name) + detail_rows.append({'name': name,'status':'filtered','reason':'fetch-skip-misc','weight':'0'}) + continue w = 1 if bu.is_color_fixing_land(tline, text_lower): w *= base_weight_fix - if name in fetch_names and remaining_fetch_slots <= 0: - continue + note_parts.append('fixing') + if 'already-added' in note_parts: + w = max(1, int(w * 0.2)) + if (not kindred_deck) and name in kindred_all and name not in mono_keep_always: + original = w + w = max(1, int(w * 0.3)) + if w < original: + note_parts.append('kindred-down') + if name == 'Yavimaya, Cradle of Growth' and 'G' not in colors: + original = w + w = max(1, int(w * 0.25)) + if w < original: + note_parts.append('offcolor-yavimaya') + if name == 'Urborg, Tomb of Yawgmoth' and 'B' not in colors: + original = w + w = max(1, int(w * 0.25)) + if w < original: + note_parts.append('offcolor-urborg') + adj = bu.adjust_misc_land_weight(self, name, w) + if adj != w: + note_parts.append('helper-adj') + w = adj + if theme_enabled: + try: + crow = df.loc[df['name'] == name].head(1) + if not crow.empty and 'themeTags' in crow.columns: + raw_tags = crow.iloc[0].get('themeTags', []) or [] + norm_tags: List[str] = [] + if isinstance(raw_tags, list): + for v in raw_tags: + s = str(v).strip().lower() + if s: + norm_tags.append(s) + elif isinstance(raw_tags, str): + rt = raw_tags.lower() + for ch in '[]"': + rt = rt.replace(ch, ' ') + norm_tags = [p.strip().strip("'\"") for p in rt.replace(';', ',').split(',') if p.strip()] + matches = [t for t in norm_tags if t in deck_theme_tags] + if matches: + base_mult = getattr(bc, 'MISC_LAND_THEME_MATCH_BASE', 1.4) + per_extra = getattr(bc, 'MISC_LAND_THEME_MATCH_PER_EXTRA', 0.15) + cap_mult = getattr(bc, 'MISC_LAND_THEME_MATCH_CAP', 2.0) + extra = max(0, len(matches) - 1) + mult = base_mult + extra * per_extra + if mult > cap_mult: + mult = cap_mult + themed_w = int(max(1, w * mult)) + if themed_w != w: + w = themed_w + note_parts.append(f"theme+{len(matches)}") + except Exception: + pass weighted_pool.append((name, w)) - + if dump_pool: + debug_entries.append((name, w, ','.join(note_parts) if note_parts else '')) + detail_rows.append({'name': name,'status':'kept','reason':','.join(note_parts) if note_parts else '', 'weight':str(w)}) + if dump_pool: + debug_entries.sort(key=lambda x: (-x[1], x[0])) + self.output_func("\nMisc Lands Pool (post-filter, top {} shown):".format(len(debug_entries))) + width = max((len(n) for n,_,_ in debug_entries), default=0) + for n, w, notes in debug_entries[:80]: + suffix = f" [{notes}]" if notes else '' + self.output_func(f" {n.ljust(width)} w={w}{suffix}") + if debug_enabled: + try: # pragma: no cover + os.makedirs(os.path.join('logs','debug'), exist_ok=True) + detail_path = os.path.join('logs','debug','land_step7_postfilter.csv') + kept = [r for r in detail_rows if r['status']=='kept'] + filt = [r for r in detail_rows if r['status']=='filtered'] + other = [r for r in detail_rows if r['status'] not in {'kept','filtered'}] + if detail_rows: + kept.sort(key=lambda r: (-int(r.get('weight','1')), r['name'])) + ordered = kept + filt + other + with open(detail_path,'w',newline='',encoding='utf-8') as fh: + wcsv = csv.writer(fh) + wcsv.writerow(['name','status','reason','weight']) + for r in ordered: + wcsv.writerow([r['name'], r['status'], r.get('reason',''), r.get('weight','')]) + except Exception: + pass + if getattr(self, 'show_diagnostics', False): + self.output_func(f"Misc Lands Debug: considered={considered} kept={len(weighted_pool)} filtered={len(filtered_out)}") + # Capacity adjustment (trim basics if needed) if self._current_land_count() >= land_target and desired > 0: slots_needed = desired freed = 0 @@ -88,25 +228,38 @@ class LandMiscUtilityMixin: if freed == 0 and self._current_land_count() >= land_target: self.output_func("Misc Lands: Cannot free capacity; skipping.") return - remaining_capacity = max(0, land_target - self._current_land_count()) desired = min(desired, remaining_capacity, len(weighted_pool)) if desired <= 0: self.output_func("Misc Lands: No capacity after trimming; skipping.") return - rng = getattr(self, 'rng', None) chosen = bu.weighted_sample_without_replacement(weighted_pool, desired, rng=rng) - added: List[str] = [] for nm in chosen: if self._current_land_count() >= land_target: break - # Misc utility lands baseline role self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7') added.append(nm) - - + if debug_enabled: + try: # pragma: no cover + os.makedirs(os.path.join('logs','debug'), exist_ok=True) + final_path = os.path.join('logs','debug','land_step7_final_selection.csv') + with open(final_path,'w',newline='',encoding='utf-8') as fh: + wcsv = csv.writer(fh) + wcsv.writerow(['name','weight','selected','reason']) + reason_map = {r['name']:(r.get('weight',''), r.get('reason','')) for r in detail_rows if r['status']=='kept'} + chosen_set = set(added) + for name, w in weighted_pool: + wt, rsn = reason_map.get(name,(str(w),'')) + wcsv.writerow([name, wt, 1 if name in chosen_set else 0, rsn]) + wcsv.writerow([]) + wcsv.writerow(['__meta__','desired', desired]) + wcsv.writerow(['__meta__','pool_size', len(weighted_pool)]) + wcsv.writerow(['__meta__','considered', considered]) + wcsv.writerow(['__meta__','filtered_out', len(filtered_out)]) + except Exception: + pass self.output_func("\nMisc Utility Lands Added (Step 7):") if not added: self.output_func(" (None added)") @@ -114,20 +267,36 @@ class LandMiscUtilityMixin: width = max(len(n) for n in added) for n in added: note = '' - row = next((r for r in top_candidates if r[1] == n), None) - if row: - for edh_val, name2, tline2, text_lower2 in top_candidates: - if name2 == n and bu.is_color_fixing_land(tline2, text_lower2): - note = '(fixing)' - break + for edh_val, name2, tline2, text_lower2 in top_candidates: + if name2 == n and bu.is_color_fixing_land(tline2, text_lower2): + note = '(fixing)' + break self.output_func(f" {n.ljust(width)} : 1 {note}") self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + if getattr(self, 'show_diagnostics', False) and filtered_out: + self.output_func(f" (Excluded candidates: {', '.join(filtered_out)})") + width = max(len(n) for n in added) + for n in added: + note = '' + for edh_val, name2, tline2, text_lower2 in top_candidates: + if name2 == n and bu.is_color_fixing_land(tline2, text_lower2): + note = '(fixing)' + break + self.output_func(f" {n.ljust(width)} : 1 {note}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + if getattr(self, 'show_diagnostics', False) and filtered_out: + self.output_func(f" (Mono-color excluded candidates: {', '.join(filtered_out)})") def run_land_step7(self, requested_count: Optional[int] = None): # type: ignore[override] self.add_misc_utility_lands(requested_count=requested_count) self._enforce_land_cap(step_label="Utility (Step 7)") self._build_tag_driven_land_suggestions() self._apply_land_suggestions_if_room() + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '7') + except Exception: + pass # ---- Tag-driven suggestion helpers (used after Step 7) ---- def _build_tag_driven_land_suggestions(self): # type: ignore[override] diff --git a/code/deck_builder/phases/phase2_lands_optimize.py b/code/deck_builder/phases/phase2_lands_optimize.py index a0af131..c74d411 100644 --- a/code/deck_builder/phases/phase2_lands_optimize.py +++ b/code/deck_builder/phases/phase2_lands_optimize.py @@ -151,3 +151,8 @@ class LandOptimizationMixin: self._enforce_land_cap(step_label="Tapped Opt (Step 8)") if self.color_source_matrix_baseline is None: self.color_source_matrix_baseline = self._compute_color_source_matrix() + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '8') + except Exception: + pass diff --git a/code/deck_builder/phases/phase2_lands_staples.py b/code/deck_builder/phases/phase2_lands_staples.py index 89b04d7..8d2e21c 100644 --- a/code/deck_builder/phases/phase2_lands_staples.py +++ b/code/deck_builder/phases/phase2_lands_staples.py @@ -143,6 +143,11 @@ class LandStaplesMixin: """Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" self.add_staple_lands() self._enforce_land_cap(step_label="Staples (Step 2)") # type: ignore[attr-defined] + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '2') + except Exception: + pass __all__ = [ diff --git a/code/deck_builder/phases/phase2_lands_triples.py b/code/deck_builder/phases/phase2_lands_triples.py index 1d5afd4..97fbcd5 100644 --- a/code/deck_builder/phases/phase2_lands_triples.py +++ b/code/deck_builder/phases/phase2_lands_triples.py @@ -230,3 +230,8 @@ class LandTripleMixin: def run_land_step6(self, requested_count: Optional[int] = None): self.add_triple_lands(requested_count=requested_count) self._enforce_land_cap(step_label="Triples (Step 6)") + try: + from .. import builder_utils as _bu + _bu.export_current_land_pool(self, '6') + except Exception: + pass diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index c7f04e4..1ab5872 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -4751,7 +4751,6 @@ def create_burn_damage_mask(df: pd.DataFrame) -> pd.Series: # Create general damage trigger patterns trigger_patterns = [ - 'deals combat damage', 'deals damage', 'deals noncombat damage', 'deals that much damage', diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 24cfecb..5885d85 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1786,6 +1786,13 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No "creatures": "creature", "primary": "creature", "secondary": "creature", + # Land-related hints + "land": "land", + "lands": "land", + "utility": "land", + "misc": "land", + "fetch": "land", + "dual": "land", } hinted_role = stage_map.get(stage_hint) if stage_hint else None lib = getattr(b, "card_library", {}) or {} @@ -1807,6 +1814,14 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No # Build role-specific pool from combined DataFrame items: list[dict] = [] used_role = role if isinstance(role, str) and role else None + # Promote to 'land' role when the seed card is a land (regardless of stored role) + try: + if entry and isinstance(entry, dict): + ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower() + if "land" in ctype: + used_role = "land" + except Exception: + pass df = getattr(b, "_combined_cards_df", None) # Compute current deck fingerprint to avoid stale cached alternatives after stage changes @@ -1821,7 +1836,8 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No # Use a cache key that includes the exclusions version and deck fingerprint cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp) - cached = _alts_get_cached(cache_key) + # Disable caching for land alternatives to keep randomness per request + cached = None if used_role == 'land' else _alts_get_cached(cache_key) if cached is not None: return HTMLResponse(cached) @@ -1832,10 +1848,12 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No "require_owned": require_owned, "items": _items, }) - try: - _alts_set_cached(cache_key, html_str) - except Exception: - pass + # Skip caching when used_role == land for per-call randomness + if used_role != 'land': + try: + _alts_set_cached(cache_key, html_str) + except Exception: + pass return HTMLResponse(html_str) # Helper: map display names @@ -1857,16 +1875,126 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No return out # If we have data and a recognized role, mirror the phase logic - if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature"}): + if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}): pool = df.copy() try: pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell) except Exception: # best-effort normalize pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else []) - # Exclude lands for all these roles - if "type" in pool.columns: - pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] + # Role-specific base filtering + if used_role != "land": + # Exclude lands for non-land roles + if "type" in pool.columns: + pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] + else: + # Keep only lands + if "type" in pool.columns: + pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)] + # Seed info to guide filtering + seed_is_basic = False + try: + seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])}) + except Exception: + seed_is_basic = False + if seed_is_basic: + # For basics: show other basics (different colors) to allow quick swaps + try: + pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] + except Exception: + pass + else: + # For non-basics: prefer other non-basics + try: + pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] + except Exception: + pass + # Apply mono-color misc land filters (no debug CSV dependency) + try: + colors = list(getattr(b, 'color_identity', []) or []) + mono = len(colors) <= 1 + mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])} + mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])} + kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])} + any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])] + extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])] + fetch_names = set() + for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): + for nm in seq: + fetch_names.add(nm.lower()) + for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): + fetch_names.add(nm.lower()) + # World Tree check needs all five colors + need_all_colors = {'w','u','b','r','g'} + def _illegal_world_tree(nm: str) -> bool: + return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors + # Text column fallback + text_col = 'text' + if text_col not in pool.columns: + for c in pool.columns: + if 'text' in c.lower(): + text_col = c + break + def _exclude_row(row) -> bool: + nm_l = str(row['name']).strip().lower() + if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all: + return True + if mono and nm_l not in mono_keep and nm_l not in kindred_all: + try: + txt = str(row.get(text_col, '') or '').lower() + if any(p in txt for p in any_color_phrases + extra_rainbow_terms): + return True + except Exception: + pass + if nm_l in fetch_names: + return True + if _illegal_world_tree(nm_l): + return True + return False + pool = pool[~pool.apply(_exclude_row, axis=1)] + except Exception: + pass + # Optional sub-role filtering (only if enough depth) + try: + subrole = str((entry or {}).get('SubRole') or '').strip().lower() + if subrole: + # Heuristic categories for grouping + cat_map = { + 'fetch': 'fetch', + 'dual': 'dual', + 'triple': 'triple', + 'misc': 'misc', + 'utility': 'misc', + 'basic': 'basic' + } + target_cat = None + for key, val in cat_map.items(): + if key in subrole: + target_cat = val + break + if target_cat and len(pool) > 25: + # Lightweight textual filter using known markers + def _cat_row(rname: str, rtype: str) -> str: + rl = rname.lower() + rt = rtype.lower() + if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')): + return 'fetch' + if 'triple' in rt or 'three' in rt: + return 'triple' + if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt: + # Basic-check crude + return 'basic' + return 'misc' + try: + tmp = pool.copy() + tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1) + sub_pool = tmp[tmp['_cat'] == target_cat] + if len(sub_pool) >= 10: + pool = sub_pool.drop(columns=['_cat']) + except Exception: + pass + except Exception: + pass # Exclude commander explicitly if "name" in pool.columns and commander_l: pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l] @@ -1904,44 +2032,90 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No pool = pool[pool["_ltags"].apply(_matches_selected)] except Exception: pass + elif used_role == "land": + # Already constrained to lands; no additional tag filter needed + pass # Sort by priority like the builder try: pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] except Exception: pass - # Exclusions and ownership + # Exclusions and ownership (for non-random roles this stays before slicing) pool = _exclude(pool) - # Prefer-owned bias: stable reorder to put owned first if user prefers owned try: if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None): pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())}) except Exception: pass - # Build final items - lower_pool: list[str] = [] - try: - lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist() - except Exception: - lower_pool = [] - display_map = _display_map_for(set(lower_pool)) - for nm_l in lower_pool: - is_owned = (nm_l in owned_set) - if require_owned and not is_owned: - continue - # Extra safety: exclude the seed card or anything already in deck - if nm_l == name_l or (in_deck and nm_l in in_deck): - continue - items.append({ - "name": display_map.get(nm_l, nm_l), - "name_lower": nm_l, - "owned": is_owned, - "tags": [], # can be filled from index below if needed - }) - if len(items) >= 10: - break - # If we collected role-aware items, render - if items: - return _render_and_cache(items) + # Land role: random 12 from top 60-100 window + if used_role == 'land': + import random as _rnd + total = len(pool) + if total == 0: + pass + else: + cap = min(100, total) + floor = min(60, cap) # if fewer than 60 just use all + if cap <= 12: + window_size = cap + else: + if cap == floor: + window_size = cap + else: + rng_obj = getattr(b, 'rng', None) + if rng_obj: + window_size = rng_obj.randint(floor, cap) + else: + window_size = _rnd.randint(floor, cap) + window_df = pool.head(window_size) + names = window_df['name'].astype(str).str.strip().tolist() + # Random sample up to 12 distinct names + sample_n = min(12, len(names)) + if sample_n > 0: + if getattr(b, 'rng', None): + chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names + else: + chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names + lower_map = {n.strip().lower(): n for n in chosen} + display_map = _display_map_for(set(k for k in lower_map.keys())) + for nm_lc, orig in lower_map.items(): + is_owned = (nm_lc in owned_set) + if require_owned and not is_owned: + continue + if nm_lc == name_l or (in_deck and nm_lc in in_deck): + continue + items.append({ + 'name': display_map.get(nm_lc, orig), + 'name_lower': nm_lc, + 'owned': is_owned, + 'tags': [] + }) + if items: + return _render_and_cache(items) + else: + # Default deterministic top-N (increase to 12 for parity) + lower_pool: list[str] = [] + try: + lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist() + except Exception: + lower_pool = [] + display_map = _display_map_for(set(lower_pool)) + for nm_l in lower_pool: + is_owned = (nm_l in owned_set) + if require_owned and not is_owned: + continue + if nm_l == name_l or (in_deck and nm_l in in_deck): + continue + items.append({ + "name": display_map.get(nm_l, nm_l), + "name_lower": nm_l, + "owned": is_owned, + "tags": [], + }) + if len(items) >= 12: + break + if items: + return _render_and_cache(items) # Fallback: tag-similarity suggestions (previous behavior) tags_idx = getattr(b, "_card_name_tags_index", {}) or {} diff --git a/code/web/static/app.js b/code/web/static/app.js index d002af9..69bb5de 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -483,6 +483,15 @@ var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null; if (ownedGrid) { source = ownedGrid; } var all = Array.prototype.slice.call(source.children); + // Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list. + // Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles) + // due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM + // is cheaper than the complexity for small sets. + var MIN_VIRT_ITEMS = 80; + if (all.length < MIN_VIRT_ITEMS){ + // Mark as processed so we don't attempt again on HTMX swaps. + return; // children remain in place; no virtualization applied. + } var store = document.createElement('div'); store.style.display = 'none'; all.forEach(function(n){ store.appendChild(n); }); diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 943b7cc..3cca2d8 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -219,6 +219,8 @@ small, .muted{ color: var(--muted); } gap: .5rem; margin-top:.5rem; justify-content: start; /* pack as many as possible per row */ + /* Prevent scroll chaining bounce that can cause flicker near bottom */ + overscroll-behavior: contain; } @media (max-width: 420px){ .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/docker-compose.yml b/docker-compose.yml index 79b5ca5..c1707d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: ENABLE_PRESETS: "0" # 1=show presets section WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5 ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable + SHOW_MISC_POOL: "0" # Theming THEME: "dark" # system|light|dark @@ -30,9 +31,19 @@ services: # Compliance/exports WEB_AUTO_ENFORCE: "0" # 1=auto-apply bracket enforcement and re-export - APP_VERSION: "v2.2.8" # Optional label shown in footer + APP_VERSION: "v2.2.9" # Optional label shown in footer # WEB_CUSTOM_EXPORT_BASE: "" # Optional custom export basename + # Misc land tuning (utility land selection – Step 7) + # MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off by default unless SHOW_DIAGNOSTICS=1 + # MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (0–1). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled each build + # MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # Upper bound (0–1) for dynamic EDHREC keep range + # MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Legacy single fixed keep % (used only if MIN/MAX not both provided) + # (Optional theme weighting overrides) + # MISC_LAND_THEME_MATCH_BASE: "1.4" # Multiplier if at least one theme tag matches + # MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" # Increment per extra matching tag beyond first + # MISC_LAND_THEME_MATCH_CAP: "2.0" # Cap for total theme multiplier + # Paths (optional overrides) # DECK_EXPORTS: "/app/deck_files" # Where the deck browser looks for exports # DECK_CONFIG: "/app/config" # Where the config browser looks for *.json diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml index 9d898fd..2cba0ab 100644 --- a/dockerhub-docker-compose.yml +++ b/dockerhub-docker-compose.yml @@ -32,9 +32,18 @@ services: # Compliance/exports WEB_AUTO_ENFORCE: "0" - APP_VERSION: "v2.2.8" + APP_VERSION: "v2.2.9" # WEB_CUSTOM_EXPORT_BASE: "" + # Misc land tuning (utility land selection – Step 7) + # MISC_LAND_DEBUG: "1" # 1=write misc land debug CSVs (post-filter, candidates); off unless SHOW_DIAGNOSTICS=1 + # MISC_LAND_EDHREC_KEEP_PERCENT_MIN: "0.75" # Lower bound (0–1). When both MIN & MAX set, a random keep % in [MIN,MAX] is rolled per build + # MISC_LAND_EDHREC_KEEP_PERCENT_MAX: "1.0" # Upper bound (0–1) + # MISC_LAND_EDHREC_KEEP_PERCENT: "0.80" # Legacy single fixed keep % (only used if MIN/MAX not both set) + # MISC_LAND_THEME_MATCH_BASE: "1.4" # Multiplier if at least one theme tag matches + # MISC_LAND_THEME_MATCH_PER_EXTRA: "0.15" # Increment per extra matching tag + # MISC_LAND_THEME_MATCH_CAP: "2.0" # Cap for theme multiplier + # Paths (optional overrides) # DECK_EXPORTS: "/app/deck_files" # DECK_CONFIG: "/app/config" diff --git a/pyproject.toml b/pyproject.toml index 1ce90d3..cc9eba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "mtg-deckbuilder" -version = "2.2.8" +version = "2.2.9" description = "A command-line tool for building and analyzing Magic: The Gathering decks" readme = "README.md" license = {file = "LICENSE"}