mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
chore(release): v2.2.9 misc land variety, land alternatives randomization, scroll flicker fix
This commit is contained in:
parent
52457f6a25
commit
07a92eb47f
22 changed files with 889 additions and 248 deletions
124
.env.example
124
.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|<blank>. 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.
|
||||
######################################################################
|
||||
|
||||
|
|
77
.github/workflows/dockerhub-publish.yml
vendored
77
.github/workflows/dockerhub-publish.yml
vendored
|
@ -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<<EOF" >> $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<<EOF" >> $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
|
||||
|
||||
|
|
25
CHANGELOG.md
25
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
|
||||
|
||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -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).
|
||||
|
|
|
@ -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<min<=max<=1), Step 7 will roll a random % in [min,max]
|
||||
# using the builder RNG to keep that share of top EDHREC-ranked candidates, injecting variety.
|
||||
MISC_LAND_EDHREC_KEEP_PERCENT_MIN: Final[float] = 0.75
|
||||
MISC_LAND_EDHREC_KEEP_PERCENT_MAX: Final[float] = 1.00
|
||||
|
||||
# Theme-based misc land weighting (applied after all reductions)
|
||||
MISC_LAND_THEME_MATCH_ENABLED: Final[bool] = True
|
||||
MISC_LAND_THEME_MATCH_BASE: Final[float] = 1.4 # Multiplier if at least one theme tag matches
|
||||
MISC_LAND_THEME_MATCH_PER_EXTRA: Final[float] = 0.15 # Additional multiplier increment per extra matching tag beyond first
|
||||
MISC_LAND_THEME_MATCH_CAP: Final[float] = 2.0 # Maximum total multiplier cap for theme boosting
|
||||
|
||||
# Mono-color extra rainbow filtering (text-based)
|
||||
MONO_COLOR_EXCLUDE_RAINBOW_TEXT: Final[bool] = True # If True, exclude lands whose rules text implies any-color mana in mono decks (beyond explicit list)
|
||||
MONO_COLOR_RAINBOW_TEXT_EXTRA: Final[List[str]] = [ # Additional substrings (lowercased) checked besides ANY_COLOR_MANA_PHRASES
|
||||
'add one mana of any type',
|
||||
'choose a color',
|
||||
'add one mana of any color',
|
||||
'add one mana of any color that a gate',
|
||||
'add one mana of any color among', # e.g., Plaza of Harmony style variants (kept list overrides)
|
||||
]
|
||||
|
||||
# Mono-color misc land exclusion (utility/rainbow) logic
|
||||
# Lands in this list will be excluded from the Step 7 misc/utility selection pool
|
||||
# when the deck is mono-colored UNLESS they appear in MONO_COLOR_MISC_LAND_KEEP_ALWAYS
|
||||
# or are detected as kindred lands (see KINDRED_* constants below).
|
||||
MONO_COLOR_MISC_LAND_EXCLUDE: Final[List[str]] = [
|
||||
'Command Tower',
|
||||
'Mana Confluence',
|
||||
'City of Brass',
|
||||
'Grand Coliseum',
|
||||
'Tarnished Citadel',
|
||||
'Gemstone Mine',
|
||||
'Aether Hub',
|
||||
'Spire of Industry',
|
||||
'Exotic Orchard',
|
||||
'Reflecting Pool',
|
||||
'Plaza of Harmony',
|
||||
'Pillar of the Paruns',
|
||||
'Cascading Cataracts',
|
||||
'Crystal Quarry',
|
||||
'The World Tree',
|
||||
# Thriving cycle – functionally useless / invalid in strict mono-color builds
|
||||
'Thriving Bluff',
|
||||
'Thriving Grove',
|
||||
'Thriving Isle',
|
||||
'Thriving Heath',
|
||||
'Thriving Moor'
|
||||
]
|
||||
|
||||
# Mono-color always-keep exceptions (never excluded by the above rule)
|
||||
MONO_COLOR_MISC_LAND_KEEP_ALWAYS: Final[List[str]] = [
|
||||
'Forbidden Orchard',
|
||||
'Plaza of Heroes',
|
||||
'Path of Ancestry',
|
||||
'Lotus Field',
|
||||
'Lotus Vale'
|
||||
]
|
||||
|
||||
## Kindred / creature-type / legend-supporting lands (single unified list)
|
||||
# Consolidates former KINDRED_STAPLE_LANDS + KINDRED_MISC_LAND_NAMES + Plaza of Heroes
|
||||
# Order is not semantically important; kept readable.
|
||||
KINDRED_LAND_NAMES: Final[List[str]] = [
|
||||
'Path of Ancestry',
|
||||
'Three Tree City',
|
||||
'Cavern of Souls',
|
||||
'Unclaimed Territory',
|
||||
'Secluded Courtyard',
|
||||
'Plaza of Heroes'
|
||||
]
|
||||
|
||||
# Default fetch land count & cap
|
||||
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
|
||||
|
@ -285,18 +356,11 @@ GENERIC_FETCH_LANDS: Final[List[str]] = [
|
|||
'Prismatic Vista'
|
||||
]
|
||||
|
||||
# Kindred land constants
|
||||
## Backwards compatibility: expose prior names as derived values
|
||||
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
|
||||
{
|
||||
'name': 'Path of Ancestry',
|
||||
'type': 'Land'
|
||||
},
|
||||
{
|
||||
'name': 'Three Tree City',
|
||||
'type': 'Legendary Land'
|
||||
},
|
||||
{'name': 'Cavern of Souls', 'type': 'Land'}
|
||||
{'name': n, 'type': 'Land'} for n in KINDRED_LAND_NAMES
|
||||
]
|
||||
KINDRED_ALL_LAND_NAMES: Final[List[str]] = list(KINDRED_LAND_NAMES)
|
||||
|
||||
# Color-specific fetch land mappings
|
||||
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
|
||||
|
@ -361,7 +425,7 @@ STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bo
|
|||
LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
|
||||
|
||||
# Protected lands that cannot be removed during land removal process
|
||||
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
|
||||
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + KINDRED_LAND_NAMES
|
||||
|
||||
# Other defaults
|
||||
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
|
||||
|
|
|
@ -364,7 +364,6 @@ def is_color_fixing_land(tline: str, text_lower: str) -> 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
@ -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__ = [
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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__ = [
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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__ = [
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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); });
|
||||
|
|
|
@ -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)); }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue