diff --git a/.env.example b/.env.example index 5921ede..129e102 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ # HOST=0.0.0.0 # Uvicorn bind host (only when APP_MODE=web). # PORT=8080 # Uvicorn port. # WORKERS=1 # Uvicorn worker count. -APP_VERSION=v3.0.1 # Matches dockerhub compose. +APP_VERSION=v2.6.0 # Matches dockerhub compose. ############################ # Theming @@ -27,17 +27,9 @@ THEME=system # system|light|dark (initial default; user p # 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. -# CSV_FILES_DIR=/app/csv_files # Override CSV base dir (DEPRECATED v3.0.0+, use CARD_FILES_* instead) +# CSV_FILES_DIR=/app/csv_files # Override CSV base dir (use test snapshots or alternate datasets) # CARD_INDEX_EXTRA_CSV= # Inject an extra CSV into the card index for testing -# Parquet-based card files (v3.0.0+) -# CARD_FILES_DIR=card_files # Base directory for Parquet files (default: card_files) -# CARD_FILES_RAW_DIR=card_files/raw # Raw MTGJSON Parquet files (default: card_files/raw) -# CARD_FILES_PROCESSED_DIR=card_files/processed # Processed/tagged Parquet files (default: card_files/processed) - -# Legacy CSV compatibility (v3.0.0 only, removed in v3.1.0) -# LEGACY_CSV_COMPAT=0 # Set to 1 to enable CSV fallback when Parquet loading fails - ############################ # Web UI Feature Flags ############################ @@ -52,16 +44,11 @@ ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1" SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons) -WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1" -ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1" -SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1" -SIMILARITY_CACHE_PATH="card_files/similarity_cache.parquet" # Path to Parquet cache file -ENABLE_BATCH_BUILD=1 # dockerhub: ENABLE_BATCH_BUILD="1" (enable Build X and Compare feature) +WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only) ############################ # Partner / Background Mechanics ############################ -# HEADLESS_EXPORT_JSON=1 # 1=export resolved run config JSON ENABLE_PARTNER_MECHANICS=1 # 1=unlock partner/background commander inputs for headless (web wiring in progress) ENABLE_PARTNER_SUGGESTIONS=1 # 1=enable partner suggestion API and UI chips (dataset auto-refreshes when missing) # PARTNER_SUGGESTIONS_DATASET=config/analytics/partner_synergy.json # Optional override path for the suggestion dataset @@ -106,15 +93,6 @@ 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" -# Card Image Caching (optional, uses Scryfall bulk data API) -CACHE_CARD_IMAGES=1 # dockerhub: CACHE_CARD_IMAGES="1" (1=download images to card_files/images/, 0=fetch from Scryfall API on demand) - -# Build Stage Ordering -WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → spells → lands → fill. 'legacy': lands → creatures → spells → fill - -# Ideals UI Mode -WEB_IDEALS_UI=slider # input|slider. 'slider' (default): range sliders with live value display. 'input': text input boxes - # Tagging Refinement Feature Flags TAG_NORMALIZE_KEYWORDS=1 # dockerhub: TAG_NORMALIZE_KEYWORDS="1" # Normalize keywords & filter specialty mechanics TAG_PROTECTION_GRANTS=1 # dockerhub: TAG_PROTECTION_GRANTS="1" # Protection tag only for cards granting shields diff --git a/.github/workflows/build-similarity-cache.yml b/.github/workflows/build-similarity-cache.yml deleted file mode 100644 index 1d83171..0000000 --- a/.github/workflows/build-similarity-cache.yml +++ /dev/null @@ -1,293 +0,0 @@ -name: Build Similarity Cache - -# Manual trigger + weekly schedule + callable from other workflows -on: - workflow_dispatch: - inputs: - force_rebuild: - description: 'Force rebuild even if cache exists' - required: false - type: boolean - default: true - workflow_call: # Allow this workflow to be called by other workflows - schedule: - # Run every Sunday at 2 AM UTC - - cron: '0 2 * * 0' - -jobs: - build-cache: - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Check if cache needs rebuild - id: check_cache - run: | - FORCE="${{ github.event.inputs.force_rebuild }}" - if [ "$FORCE" = "true" ] || [ ! -f "card_files/similarity_cache.parquet" ]; then - echo "needs_build=true" >> $GITHUB_OUTPUT - echo "Cache doesn't exist or force rebuild requested" - else - # Check cache age via metadata JSON - CACHE_AGE_DAYS=$(python -c " - import json - from datetime import datetime - from pathlib import Path - - metadata_path = Path('card_files/similarity_cache_metadata.json') - if metadata_path.exists(): - with open(metadata_path) as f: - data = json.load(f) - build_date = data.get('build_date') - if build_date: - age = (datetime.now() - datetime.fromisoformat(build_date)).days - print(age) - else: - print(999) - else: - print(999) - " || echo "999") - - if [ "$CACHE_AGE_DAYS" -gt 7 ]; then - echo "needs_build=true" >> $GITHUB_OUTPUT - echo "Cache is $CACHE_AGE_DAYS days old, rebuilding" - else - echo "needs_build=false" >> $GITHUB_OUTPUT - echo "Cache is only $CACHE_AGE_DAYS days old, skipping" - fi - fi - - - name: Run initial setup - if: steps.check_cache.outputs.needs_build == 'true' - run: | - python -c "from code.file_setup.setup import initial_setup; initial_setup()" - - - name: Run tagging (serial for CI reliability) - if: steps.check_cache.outputs.needs_build == 'true' - run: | - python -c "from code.tagging.tagger import run_tagging; run_tagging(parallel=False)" - - # Verify tagging completed - if [ ! -f "card_files/processed/.tagging_complete.json" ]; then - echo "ERROR: Tagging completion flag not found" - exit 1 - fi - - - name: Debug - Inspect Parquet file after tagging - if: steps.check_cache.outputs.needs_build == 'true' - run: | - python -c " - import pandas as pd - from pathlib import Path - from code.path_util import get_processed_cards_path - - parquet_path = Path(get_processed_cards_path()) - print(f'Reading Parquet file: {parquet_path}') - print(f'File exists: {parquet_path.exists()}') - - if not parquet_path.exists(): - raise FileNotFoundError(f'Parquet file not found: {parquet_path}') - - df = pd.read_parquet(parquet_path) - print(f'Loaded {len(df)} rows from Parquet file') - print(f'Columns: {list(df.columns)}') - print('') - - # Show first 5 rows completely - print('First 5 complete rows:') - print('=' * 100) - for idx, row in df.head(5).iterrows(): - print(f'Row {idx}:') - for col in df.columns: - value = row[col] - if isinstance(value, (list, tuple)) or hasattr(value, '__array__'): - # For array-like, show type and length - try: - length = len(value) - print(f' {col}: {type(value).__name__}[{length}] = {value}') - except: - print(f' {col}: {type(value).__name__} = {value}') - else: - print(f' {col}: {value}') - print('-' * 100) - " - - - name: Generate theme catalog - if: steps.check_cache.outputs.needs_build == 'true' - run: | - if [ ! -f "config/themes/theme_catalog.csv" ]; then - echo "Theme catalog not found, generating..." - python -m code.scripts.generate_theme_catalog - else - echo "Theme catalog already exists, skipping generation" - fi - - - name: Verify theme catalog and tag statistics - if: steps.check_cache.outputs.needs_build == 'true' - run: | - # Detailed check of what tags were actually written - python -c " - import pandas as pd - from code.path_util import get_processed_cards_path - df = pd.read_parquet(get_processed_cards_path()) - - # Helper to count tags (handles both list and numpy array) - def count_tags(x): - if x is None: - return 0 - if hasattr(x, '__len__'): - try: - return len(x) - except: - return 0 - return 0 - - # Count total tags - total_tags = 0 - cards_with_tags = 0 - sample_cards = [] - - for idx, row in df.head(10).iterrows(): - name = row['name'] - tags = row['themeTags'] - tag_count = count_tags(tags) - total_tags += tag_count - if tag_count > 0: - cards_with_tags += 1 - sample_cards.append(f'{name}: {tag_count} tags') - - print(f'Sample of first 10 cards:') - for card in sample_cards: - print(f' {card}') - - # Full count - all_tags = df['themeTags'].apply(count_tags).sum() - all_with_tags = (df['themeTags'].apply(count_tags) > 0).sum() - - print(f'') - print(f'Total cards: {len(df):,}') - print(f'Cards with tags: {all_with_tags:,}') - print(f'Total theme tags: {all_tags:,}') - - if all_tags < 10000: - raise ValueError(f'Only {all_tags} tags found, expected >10k') - " - - - name: Build similarity cache (Parquet) from card_files/processed/all_cards.parquet - if: steps.check_cache.outputs.needs_build == 'true' - run: | - python -m code.scripts.build_similarity_cache_parquet --parallel --checkpoint-interval 1000 --force - - - name: Verify cache was created - if: steps.check_cache.outputs.needs_build == 'true' - run: | - if [ ! -f "card_files/similarity_cache.parquet" ]; then - echo "ERROR: Similarity cache not created" - exit 1 - fi - if [ ! -f "card_files/similarity_cache_metadata.json" ]; then - echo "ERROR: Similarity cache metadata not created" - exit 1 - fi - if [ ! -f "card_files/processed/commander_cards.parquet" ]; then - echo "ERROR: Commander cache not created" - exit 1 - fi - - echo "✓ All cache files created successfully" - - - name: Get cache metadata for commit message - if: steps.check_cache.outputs.needs_build == 'true' - id: cache_meta - run: | - METADATA=$(python -c " - import json - from pathlib import Path - from code.web.services.similarity_cache import get_cache - - cache = get_cache() - stats = cache.get_stats() - metadata = cache._metadata or {} - - build_date = metadata.get('build_date', 'unknown') - print(f\"{stats['total_cards']} cards, {stats['total_entries']} entries, {stats['file_size_mb']:.1f}MB, built {build_date}\") - ") - echo "metadata=$METADATA" >> $GITHUB_OUTPUT - - - name: Commit and push cache - if: steps.check_cache.outputs.needs_build == 'true' - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - - # Fetch all branches - git fetch origin - - # Try to checkout existing branch, or create new orphan branch - if git ls-remote --heads origin similarity-cache-data | grep similarity-cache-data; then - echo "Checking out existing similarity-cache-data branch..." - git checkout similarity-cache-data - else - echo "Creating new orphan branch similarity-cache-data..." - git checkout --orphan similarity-cache-data - git rm -rf . || true - # Create minimal README for the branch - echo "# Similarity Cache Data" > README.md - echo "This branch contains pre-built similarity cache files for the MTG Deckbuilder." >> README.md - echo "Updated automatically by GitHub Actions." >> README.md - echo "" >> README.md - echo "## Files" >> README.md - echo "- \`card_files/similarity_cache.parquet\` - Pre-computed card similarity cache" >> README.md - echo "- \`card_files/similarity_cache_metadata.json\` - Cache metadata" >> README.md - echo "- \`card_files/processed/all_cards.parquet\` - Tagged card database" >> README.md - echo "- \`card_files/processed/commander_cards.parquet\` - Commander-only cache (fast lookups)" >> README.md - echo "- \`card_files/processed/.tagging_complete.json\` - Tagging status" >> README.md - fi - - # Ensure directories exist - mkdir -p card_files/processed - - # Add similarity cache files (use -f to override .gitignore) - git add -f card_files/similarity_cache.parquet - git add -f card_files/similarity_cache_metadata.json - - # Add processed Parquet and status file - git add -f card_files/processed/all_cards.parquet - git add -f card_files/processed/commander_cards.parquet - git add -f card_files/processed/.tagging_complete.json - - git add README.md 2>/dev/null || true - - # Check if there are changes to commit - if git diff --staged --quiet; then - echo "No changes to commit" - else - git commit -m "chore: update similarity cache [${{ steps.cache_meta.outputs.metadata }}]" - git push origin similarity-cache-data --force - fi - - - name: Summary - if: always() - run: | - if [ "${{ steps.check_cache.outputs.needs_build }}" = "true" ]; then - echo "✓ Similarity cache built and committed" - echo " Metadata: ${{ steps.cache_meta.outputs.metadata }}" - else - echo "⊘ Cache is recent, no rebuild needed" - fi diff --git a/.github/workflows/dockerhub-publish.yml b/.github/workflows/dockerhub-publish.yml index 1e26bc2..ec5eff6 100644 --- a/.github/workflows/dockerhub-publish.yml +++ b/.github/workflows/dockerhub-publish.yml @@ -63,18 +63,6 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 - - name: Download similarity cache from branch - run: | - # Download cache files from similarity-cache-data branch - mkdir -p card_files - wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache.parquet -O card_files/similarity_cache.parquet || echo "Cache not found, will build without it" - wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache_metadata.json -O card_files/similarity_cache_metadata.json || echo "Metadata not found" - - if [ -f card_files/similarity_cache.parquet ]; then - echo "✓ Downloaded similarity cache" - ls -lh card_files/similarity_cache.parquet - fi - - name: Compute amd64 tag id: arch_tag shell: bash @@ -132,18 +120,6 @@ jobs: - name: Checkout uses: actions/checkout@v5.0.0 - - name: Download similarity cache from branch - run: | - # Download cache files from similarity-cache-data branch - mkdir -p card_files - wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache.parquet -O card_files/similarity_cache.parquet || echo "Cache not found, will build without it" - wget -q https://raw.githubusercontent.com/${{ github.repository }}/similarity-cache-data/card_files/similarity_cache_metadata.json -O card_files/similarity_cache_metadata.json || echo "Metadata not found" - - if [ -f card_files/similarity_cache.parquet ]; then - echo "✓ Downloaded similarity cache" - ls -lh card_files/similarity_cache.parquet - fi - - name: Compute arm64 tag id: arch_tag shell: bash diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index e1a9fe4..c7ee2a1 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -62,13 +62,8 @@ jobs: run: | VERSION_REF="${GITHUB_REF##*/}" # e.g. v1.2.3 VERSION_NO_V="${VERSION_REF#v}" - VERSIONED_NOTES="docs/releases/${VERSION_REF}.md" TEMPLATE="RELEASE_NOTES_TEMPLATE.md" - - # Prefer versioned release notes file if it exists - if [ -f "$VERSIONED_NOTES" ]; then - cp "$VERSIONED_NOTES" RELEASE_NOTES.md - elif [ -f "$TEMPLATE" ]; then + if [ -f "$TEMPLATE" ]; then sed "s/\${VERSION}/${VERSION_REF}/g" "$TEMPLATE" > RELEASE_NOTES.md else echo "# MTG Python Deckbuilder ${VERSION_REF}" > RELEASE_NOTES.md diff --git a/.gitignore b/.gitignore index 6de24ec..fd0113e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ RELEASE_NOTES.md test.py -test_*.py !test_exclude_cards.txt !test_include_exclude_config.json @@ -31,7 +30,6 @@ config/themes/catalog/ csv_files/* !csv_files/testdata/ !csv_files/testdata/**/* -card_files/* deck_files/ dist/ @@ -41,14 +39,4 @@ logs/ logs/* !logs/perf/ logs/perf/* -!logs/perf/theme_preview_warm_baseline.json - -# Node.js and build artifacts -node_modules/ -code/web/static/js/ -code/web/static/styles.css -*.js.map - -# Keep TypeScript sources and Tailwind CSS input -!code/web/static/ts/ -!code/web/static/tailwind.css \ No newline at end of file +!logs/perf/theme_preview_warm_baseline.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2351a17..e526fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,335 +8,17 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability. ## [Unreleased] -### Added -- **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates - - Validates Jinja2 syntax across all templates - - Checks HTML structure (balanced tags, unique IDs, proper attributes) - - Basic accessibility validation (alt text, form labels, button types) - - Regression prevention thresholds to maintain code quality -- **Code Quality Tools**: Enhanced development tooling for maintainability - - Automated utilities for code cleanup - - Improved type checking configuration -- **Card Image Caching**: Optional local image cache for faster card display - - Downloads card images from Scryfall bulk data (respects API guidelines) - - Graceful fallback to Scryfall API for uncached images - - Enabled via `CACHE_CARD_IMAGES=1` environment variable - - Integrated with setup/tagging process - - Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness) -- **Component Library**: Living documentation of reusable UI components at `/docs/components` - - Interactive examples of all buttons, modals, forms, cards, and panels - - Jinja2 macros for consistent component usage - - Component partial templates for reuse across pages -- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety - - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript - - Created shared type definitions for state management, telemetry, HTMX, and UI components - - Integrated TypeScript compilation into build process (`npm run build:ts`) - - Compiled JavaScript output in `code/web/static/js/` directory - - Docker build automatically compiles TypeScript during image creation - -### Changed -- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code) -- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules - - Created `cardHover.ts` for unified hover panel functionality - - Created `cardImages.ts` for card image loading with automatic retry fallbacks - - Reduced inline script size in base template for better maintainability -- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture - - Tailwind CSS v3 with custom MTG color palette - - PostCSS build pipeline with autoprefixer - - Reduced inline styles in templates (moved to shared CSS classes) - - Organized CSS into functional sections with clear documentation -- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes - - Light mode: Darker text for improved readability, warm earth tone color palette - - Dark mode: Refined contrast for better visual hierarchy - - High-contrast mode: Optimized for maximum accessibility - - Consistent hover states across all interactive elements - - Improved visibility of form inputs and controls -- **JavaScript Modernization**: Updated to modern JavaScript patterns - - Converted `var` declarations to `const`/`let` - - Added TypeScript type annotations for better IDE support and error catching - - Consolidated event handlers and utility functions -- **Docker Build Optimization**: Improved developer experience - - Hot reload enabled for templates and static files - - Volume mounts for rapid iteration without rebuilds -- **Template Modernization**: Migrated templates to use component system -- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck - - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) - - 10% bonus for cards appearing in 80%+ of builds - - Color-coded synergy scores in preview (green=high, red=low) - - Partner commander support with combined color identity - - Multi-copy card tracking (e.g., 8 Mountains, 7 Islands) - - Export synergy deck with full metadata (CSV, TXT, JSON files) -- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) -- Detailed progress logging for multi-build orchestration -- User guide: `docs/user_guides/batch_build_compare.md` -- **Web UI Component Library**: Standardized UI components for consistent design across all pages - - 5 component partial template files (buttons, modals, forms, cards, panels) - - ~900 lines of component CSS styles - - Interactive JavaScript utilities (components.js) - - Living component library page at `/docs/components` - - 1600+ lines developer documentation (component_catalog.md) -- **Custom UI Enhancements**: - - Darker gray styling for home page buttons - - Visual highlighting for selected theme chips in deck builder - -### Changed -- Migrated 5 templates to new component system (home, 404, 500, setup, commanders) -- **Type Checking Configuration**: Improved Python code quality tooling - - Configured type checker for better error detection - - Optimized linting rules for development workflow - -### Fixed -- **Template Quality**: Resolved HTML structure issues found by validation tests - - Fixed duplicate ID attributes in build wizard and theme picker templates - - Removed erroneous block tags from component documentation - - Corrected template structure for HTMX fragments -- **Code Quality**: Resolved type checking warnings and improved code maintainability - - Fixed type annotation inconsistencies - - Cleaned up redundant code quality suppressions - - Corrected configuration conflicts - -### Removed -_None_ - -### Performance -- Hot reload for CSS/template changes (no Docker rebuild needed) -- Optional image caching reduces Scryfall API calls -- Faster page loads with optimized CSS -- TypeScript compilation produces optimized JavaScript - -### For Users -- Faster card image loading with optional caching -- Cleaner, more consistent web UI design -- Improved page load performance -- More reliable JavaScript behavior - -### Deprecated -_None_ - -### Security -_None_ - -## [3.0.1] - 2025-10-19 -### Added -_None_ - -### Changed -_None_ - -### Removed -_None_ - -### Fixed -- **Color Identity Display**: Fixed commander color identity showing incorrectly as "Colorless (C)" for non-partner commanders in the summary panel - -### Performance -- **Commander Selection Speed**: Dramatically improved response time from 4+ seconds to under 1 second - - Implemented intelligent caching for card data to eliminate redundant file loading - - Both commander data and full card database now cached with automatic refresh when data updates - -### Deprecated -_None_ - -### Security -_None_ - -## [3.0.0] - 2025-10-19 ### Summary -Major infrastructure upgrade to Parquet format with comprehensive performance improvements, simplified data management, and instant setup via GitHub downloads. +- _No unreleased changes yet._ ### Added -- **Parquet Migration (M4)**: Unified `card_files/processed/all_cards.parquet` replaces multiple CSV files - - Single source of truth for all card data (29,857 cards, 2,751 commanders, 31 backgrounds) - - Native support for lists and complex data types - - Faster loading (binary columnar format vs text parsing) - - Automatic deduplication and data validation -- **Performance**: Parallel tagging option provides 4.2x speedup (22s → 5.2s) -- **Combo Tags**: 226 cards tagged with combo-enabling abilities for better deck building -- **Data Quality**: Built-in commander/background detection using boolean flags instead of separate files -- **GitHub Downloads**: Pre-tagged card database and similarity cache available for instant setup - - Auto-download on first run (seconds instead of 15-20 minutes) - - Manual download button in web UI - - Updated weekly via automated workflow +- _No unreleased changes yet._ ### Changed -- **CLI & Web**: Both interfaces now load from unified Parquet data source -- **Deck Builder**: Simplified data loading, removed CSV file juggling -- **Web Services**: Updated card browser, commander catalog, and owned cards to use Parquet -- **Setup Process**: Streamlined initial setup with fewer file operations -- **Module Execution**: Use `python -m code.main` / `python -m code.headless_runner` for proper imports - -### Removed -- Dependency on separate `commander_cards.csv` and `background_cards.csv` files -- Multiple color-specific CSV file loading logic -- CSV parsing overhead from hot paths - -### Technical Details -- DataLoader class provides consistent Parquet I/O across codebase -- Boolean filters (`isCommander`, `isBackground`) replace file-based separation -- Numpy array conversion ensures compatibility with existing list-checking code -- GitHub Actions updated to use processed Parquet path -- Docker containers benefit from smaller, faster data files - -## [2.9.1] - 2025-10-17 -### Summary -Improved similar cards section with refresh button and reduced sidebar animation distractions. - -### Added -- Similar cards now have a refresh button to see different recommendations without reloading the page -- Explanation text clarifying that similarities are based on shared themes and tags - -### Changed -- Sidebar generally no longer animates during page loads and partial updates, reducing visual distractions - -### Removed -_None_ +- _No unreleased changes yet._ ### Fixed -_None_ - -## [2.9.0] - 2025-10-17 -### Summary -New card browser for exploring 29,839 Magic cards with advanced filters, similar card recommendations, and performance optimizations. - -### Added -- **Card Browser**: Browse and search all Magic cards at `/browse/cards` - - Smart autocomplete for card names and themes with typo tolerance - - Multi-theme filtering (up to 5 themes) - - Color, type, rarity, CMC, power/toughness filters - - Multiple sorting options including EDHREC popularity - - Infinite scroll with shareable filter URLs -- **Card Detail Pages**: Individual card pages with similar card suggestions - - Full card stats, oracle text, and theme tags - - Similar cards based on theme overlap - - Color-coded similarity scores - - Card preview on hover - - Enable with `ENABLE_CARD_DETAILS=1` environment variable -- **Similarity Cache**: Pre-computed card similarities for fast page loads - - Build cache with parallel processing script - - Automatically used when available - - Control with `SIMILARITY_CACHE_ENABLED` environment variable -- **Keyboard Shortcuts**: Quick navigation in card browser - - `Enter` to add autocomplete matches - - `Shift+Enter` to apply filters - - Double `Esc` to clear all filters - -### Changed -- **Card Database**: Expanded to 29,839 cards (updated from 26,427) -- **Theme Catalog**: Improved coverage with better filtering - -### Removed -- **Unused Scripts**: Removed `regenerate_parquet.py` (functionality now in web UI setup) - -### Fixed -- **Card Browser UI**: Improved styling consistency and card image loading -- **Infinite Scroll**: Fixed cards appearing multiple times when loading more results -- **Sorting**: Sort order now persists correctly when scrolling through all pages - -## [2.8.1] - 2025-10-16 -### Summary -Improved colorless commander support with automatic card filtering and display fixes. - -### Added -- **Colorless Commander Filtering**: 25 cards that don't work in colorless decks are now automatically excluded - - Filters out cards like Arcane Signet, Commander's Sphere, and medallions that reference "commander's color identity" or colored spells - - Only applies to colorless identity commanders (Karn, Kozilek, Liberator, etc.) - -### Fixed -- **Colorless Commander Display**: Fixed three bugs affecting colorless commander decks - - Color identity now displays correctly (grey "C" button with "Colorless" label) - - Wastes now correctly added as basic lands in colorless decks - - Colored basics (Plains, Island, etc.) no longer incorrectly added to colorless decks - -## [2.8.0] - 2025-10-15 -### Summary -Theme catalog improvements with faster processing, new tag search features, regeneration fixes, and browser performance optimizations. - -### Added -- **Theme Catalog Optimization**: - - Consolidated theme enrichment pipeline (single pass instead of 7 separate scripts) - - Tag index for fast theme-based card queries - - Tag search API with new endpoints for card search, autocomplete, and popular tags - - Commander browser theme autocomplete with keyboard navigation - - Tag loading infrastructure for batch operations -- **Theme Browser Keyboard Navigation**: Arrow keys now navigate search results (ArrowUp/Down, Enter to select, Escape to close) - -### Changed -- **Theme Browser Performance**: Theme detail pages now load much faster - - Disabled YAML file scanning in production (use `THEME_CATALOG_CHECK_YAML_CHANGES=1` during theme authoring) - - Cache invalidation now checks theme_list.json instead of scanning all files -- **Theme Browser UI**: Removed color filter from theme catalog - -### Fixed -- **Theme Regeneration**: Theme catalog can now be fully rebuilt from scratch without placeholder data - - Fixed "Anchor" placeholder issue when regenerating catalog - - Examples now generated from actual card data - - Theme export preserves all metadata fields - -## [2.7.1] - 2025-10-14 -### Summary -Quick Build UI refinements for improved desktop display. - -### Fixed -- Quick Build progress display now uses full desktop width instead of narrow mobile-like layout -- Quick Build completion screen properly transitions to full-width Step 5 layout matching manual build experience - -## [2.7.0] - 2025-10-14 -### Summary -- Enhanced deck building workflow with improved stage ordering, granular skip controls, and one-click Quick Build automation. -- New Ideal Counts section with interactive sliders or text inputs for customizing deck composition targets. -- Stage execution order now prioritizes creatures and spells before lands for better mana curve analysis. -- New wizard-only skip controls allow auto-advancing through specific stages (lands, creatures, spells) without approval prompts. -- Quick Build button provides one-click full automation with clean 5-phase progress indicator. - -### Added -- **Ideal Counts UI**: Dedicated section in New Deck wizard for setting ideal card counts (ramp, lands, creatures, removal, wipes, card advantage, protection). - - **Slider Mode** (default): Interactive range sliders with live value display and expanded ranges (e.g., creatures: 0-70, lands: 25-45). - - **Input Mode**: Text input boxes with placeholder defaults (e.g., "10 (Default)"). - - Smart validation warns when estimated total exceeds 99 cards (accounts for overlap: `Lands + Creatures + Spells/2`). - - Sliders start at recommended defaults and remember user preferences across builds. - - Configurable via `WEB_IDEALS_UI` environment variable (`slider` or `input`). -- **Quick Build**: One-click automation button in New Deck wizard with live progress tracking (5 phases: Creatures, Spells, Lands, Final Touches, Summary). -- **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: all land steps, creature stages, spell categories). - - Individual land step controls: basics, staples, fetches, duals, triomes, kindred, misc lands. - - Spell category controls: ramp, removal, wipes, card advantage, protection, theme fill. - - Creature stage controls: all creatures, primary, secondary, fill. - - Mutual exclusivity enforcement: "Skip All Lands" disables individual land toggles; "Skip to Misc Lands" skips early land steps. -- **Stage Reordering**: New default build order executes creatures → spells → lands for improved pip analysis (configurable via `WEB_STAGE_ORDER` environment variable). -- Background task execution for Quick Build with HTMX polling progress updates. -- Mobile-friendly Quick Build with touch device confirmation dialog. -- Commander session cleanup: Commander selection automatically cleared after build completes. - -### Changed -- **Default Stage Order**: Creatures and ideal spells now execute before land stages (lands can analyze actual pip requirements instead of estimates). -- **Ideal Counts Display**: Removed collapsible "Advanced options (ideals)" section; replaced with prominent fieldset with slider/input modes. -- Slider ranges expanded to support edge-case strategies (e.g., creature-heavy tribal, spell-heavy control). -- Skip controls only available in New Deck wizard (disabled during build execution for consistency). -- Skip behavior auto-advances through stages without approval prompts (cards still added, just not gated). -- Post-spell land adjustment automatically skipped when any skip flag enabled. - -### Fixed -- Session context properly injected into Quick Build so skip configuration works correctly. -- HTMX polling uses continuous trigger (`every 500ms`) instead of one-time (`load delay`) for reliable progress updates. -- Progress indicator stops cleanly when build completes (out-of-band swap removes poller div). -- Ideal counts now properly populate from session state, allowing sliders to start at defaults and remember user preferences. -- Commander and commander_name cleared from session after build completes to prevent carryover to next build. - -## [2.6.1] - 2025-10-13 -### Summary -- Fixed issues with custom themes in the web UI. -- Added non-basic land type tagging (i.e. Caves, Deserts, Gates, etc...) in the tagging module. -- Improved alternatives panel UX with dismissible header and cleaner owned card indicators. - -### Added -- Non-basic land type tagging (i.e. Caves, Deserts, Gates, etc...) in the tagging module. -- Close button to alternatives panel header so it can be dismissed. - -### Changed -- Removed the owned badge from each alternative and moved owned metadata to a data attribute on the button. - -### Fixed -- Custom theme fuzzy matching now accepts selection. -- Custom themes may now be removed from the list. +- _No unreleased changes yet._ ## [2.6.0] - 2025-10-13 ### Summary diff --git a/DOCKER.md b/DOCKER.md index 99c9907..9ac2c7b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -254,11 +254,6 @@ See `.env.example` for the full catalog. Common knobs: | `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. | | `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | -| `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | -| `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | -| `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/`. | -| `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. | -| `ENABLE_BATCH_BUILD` | `1` | Enable Build X and Compare feature (build multiple decks in parallel and compare results). | ### Random build controls @@ -283,7 +278,6 @@ See `.env.example` for the full catalog. Common knobs: | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_TAG_PARALLEL` | `1` | Use parallel workers during tagging. | | `WEB_TAG_WORKERS` | `4` | Worker count for parallel tagging. | -| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). See [Image Caching](docs/IMAGE_CACHING.md). | | `WEB_AUTO_ENFORCE` | `0` | Re-export decks after auto-applying compliance fixes. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | diff --git a/Dockerfile b/Dockerfile index 1f76105..7dbfb62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,42 +10,21 @@ ENV PYTHONUNBUFFERED=1 ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} -# Install system dependencies including Node.js +# Install system dependencies if needed RUN apt-get update && apt-get install -y \ gcc \ - curl \ - && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ - && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Copy package files for Node.js dependencies -COPY package.json package-lock.json* ./ - -# Install Node.js dependencies -RUN npm install - -# Copy Tailwind/TypeScript config files -COPY tailwind.config.js postcss.config.js tsconfig.json ./ - -# Copy requirements for Python dependencies (for better caching) +# Copy requirements first for better caching COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Copy Python application code (includes templates needed for Tailwind) +# Copy application code COPY code/ ./code/ COPY mypy.ini . -# Tailwind source is already in code/web/static/tailwind.css from COPY code/ -# TypeScript sources are in code/web/static/ts/ from COPY code/ - -# Force fresh CSS build by removing any copied styles.css -RUN rm -f ./code/web/static/styles.css - -# Build CSS and TypeScript -RUN npm run build - # Copy default configs in two locations: # 1) /app/config is the live path (may be overlaid by a volume) # 2) /app/.defaults/config is preserved in the image for first-run seeding when a volume is mounted @@ -53,19 +32,11 @@ COPY config/ ./config/ COPY config/ /.defaults/config/ RUN mkdir -p owned_cards -# Copy similarity cache if available (pre-built during CI) -# Store in /.defaults/card_files so it persists after volume mount -RUN mkdir -p /.defaults/card_files -# Copy entire card_files directory (will include cache if present, empty if not) -# COMMENTED OUT FOR LOCAL DEV: card_files is mounted as volume anyway -# Uncomment for production builds or CI/CD -# COPY card_files/ /.defaults/card_files/ - # Create necessary directories as mount points -RUN mkdir -p deck_files logs csv_files card_files config /.defaults +RUN mkdir -p deck_files logs csv_files config /.defaults # Create volumes for persistent data -VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files", "/app/card_files", "/app/config", "/app/owned_cards"] +VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files", "/app/config", "/app/owned_cards"] # Create symbolic links BEFORE changing working directory # These will point to the mounted volumes @@ -73,12 +44,11 @@ RUN cd /app/code && \ ln -sf /app/deck_files ./deck_files && \ ln -sf /app/logs ./logs && \ ln -sf /app/csv_files ./csv_files && \ - ln -sf /app/card_files ./card_files && \ ln -sf /app/config ./config && \ ln -sf /app/owned_cards ./owned_cards # Verify symbolic links were created -RUN cd /app/code && ls -la deck_files logs csv_files card_files config owned_cards +RUN cd /app/code && ls -la deck_files logs csv_files config owned_cards # Set the working directory to code for proper imports WORKDIR /app/code diff --git a/README.md b/README.md index 5d46b02..6089672 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ A web-first Commander/EDH deckbuilder with a shared core for CLI, headless, and - [Initial Setup](#initial-setup) - [Owned Library](#owned-library) - [Browse Commanders](#browse-commanders) - - [Browse Cards](#browse-cards) - [Browse Themes](#browse-themes) - [Finished Decks](#finished-decks) - [Random Build](#random-build) @@ -79,14 +78,6 @@ Every tile on the homepage connects to a workflow. Use these sections as your to ### Build a Deck Start here for interactive deck creation. - Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal. -- **Build X and Compare** (`ENABLE_BATCH_BUILD=1`, default): Build 1-10 decks with the same configuration to see variance - - Parallel execution (max 5 concurrent) with real-time progress and dynamic time estimates - - Comparison view shows card overlap statistics and individual build summaries - - **Synergy Builder**: Analyze builds and create optimized "best-of" deck scored by frequency, EDHREC rank, and theme tags - - Rebuild button for quick iterations, ZIP export for all builds - - See `docs/user_guides/batch_build_compare.md` for full guide -- **Quick Build**: One-click automation runs the full workflow with live progress (Creatures → Spells → Lands → Final Touches → Summary). Available in New Deck wizard. -- **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: land steps, creature stages, spell categories). Auto-advance without approval prompts. - Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`. - Partner mechanics (ENABLE_PARTNER_MECHANICS): Step 2 and the quick-start modal auto-enable partner controls for eligible commanders, show only legal partner/background/Doctor options, and keep previews, warnings, and theme chips in sync. - Partner suggestions (ENABLE_PARTNER_SUGGESTIONS): ranked chips appear beside the partner selector, recommending popular partner/background/Doctor pairings based on the analytics dataset; selections respect existing partner mode and lock states. @@ -98,8 +89,6 @@ Start here for interactive deck creation. - Exports (CSV, TXT, compliance JSON, summary JSON) land in `deck_files/` and reuse your chosen deck name when set. CSV/TXT headers now include commander metadata (names, partner mode, colors) so downstream tools can pick up dual-commander context without extra parsing. - `ALLOW_MUST_HAVES=1` (default) enables include/exclude enforcement. - `WEB_AUTO_ENFORCE=1` re-runs bracket enforcement automatically after each build. -- `WEB_STAGE_ORDER=new` (default) runs creatures/spells before lands for better pip analysis. Use `legacy` for original lands-first order. -- `WEB_IDEALS_UI=slider` (default) shows interactive range sliders for ideal counts with live validation. Use `input` for traditional text boxes. ### Run a JSON Config Execute saved configs without manual input. @@ -110,10 +99,8 @@ Execute saved configs without manual input. ### Initial Setup Refresh data and caches when formats shift. -- **First run**: Auto-downloads pre-tagged card database from GitHub (instant setup) -- **Manual refresh**: Download button in web UI or run setup locally -- Runs card downloads, data generation, smart tagging (keywords + protection grants), and commander catalog rebuilds -- Controlled by `SHOW_SETUP=1` (on by default in compose) +- Runs card downloads, CSV regeneration, smart tagging (keywords + protection grants), and commander catalog rebuilds. +- Controlled by `SHOW_SETUP=1` (on by default in compose). - **Force a full rebuild (setup + tagging)**: ```powershell # Docker: @@ -128,7 +115,7 @@ Refresh data and caches when formats shift. # With parallel processing and custom worker count: python -c "from code.file_setup.setup import initial_setup; from code.tagging.tagger import run_tagging; initial_setup(); run_tagging(parallel=True, max_workers=4)" ``` -- **Rebuild only data without tagging**: +- **Rebuild only CSVs without tagging**: ```powershell # Docker: docker compose run --rm web python -c "from code.file_setup.setup import initial_setup; initial_setup()" @@ -173,15 +160,6 @@ Explore the curated commander catalog. - Refresh via Initial Setup or the commander catalog script above. - MDFC merges and compatibility snapshots are handled automatically; use `--compat-snapshot` on the refresh script to emit an unmerged snapshot. -### Browse Cards -Search and explore all 29,839 Magic cards. -- **Search & Filters**: Smart autocomplete for card names and themes, multi-theme filtering (up to 5), color identity, type, rarity, CMC range, power/toughness -- **Sorting**: Name A-Z/Z-A, CMC Low/High, Power High, EDHREC Popular -- **Card Details** (optional): Enable with `ENABLE_CARD_DETAILS=1` for individual card pages with similar card recommendations -- **Keyboard Shortcuts**: `Enter` to add matches, `Shift+Enter` to apply filters, double `Esc` to clear all -- **Shareable URLs**: Filter state persists in URL for easy sharing -- Fast lookups powered by pre-built card index and optional similarity cache (`SIMILARITY_CACHE_ENABLED=1`) - ### Browse Themes Investigate theme synergies and diagnostics. - `ENABLE_THEMES=1` keeps the tile visible (default). @@ -309,7 +287,6 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `WEB_AUTO_REFRESH_DAYS` | `7` | Refresh `cards.csv` if older than N days. | | `WEB_TAG_PARALLEL` | `1` | Enable parallel tagging workers. | | `WEB_TAG_WORKERS` | `4` | Worker count for tagging (compose default). | -| `CACHE_CARD_IMAGES` | `0` | Download card images to `card_files/images/` (1=enable, 0=fetch from API on demand). Requires ~3-6 GB. See [Image Caching](docs/IMAGE_CACHING.md). | | `WEB_AUTO_ENFORCE` | `0` | Auto-apply bracket enforcement after builds. | | `WEB_THEME_PICKER_DIAGNOSTICS` | `1` | Enable theme diagnostics endpoints. | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index f03d5c5..2bdb2d6 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,111 +1,14 @@ # MTG Python Deckbuilder ${VERSION} ## [Unreleased] - ### Summary -Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability. +- _No unreleased changes yet._ ### Added -- **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality - - Validates Jinja2 syntax and structure - - Checks for common HTML issues (duplicate IDs, balanced tags) - - Basic accessibility validation - - Prevents regression in template quality -- **Code Quality Tools**: Enhanced development tooling for maintainability - - Automated utilities for code cleanup - - Improved type checking configuration -- **Card Image Caching**: Optional local image cache for faster card display - - Downloads card images from Scryfall bulk data (respects API guidelines) - - Graceful fallback to Scryfall API for uncached images - - Enabled via `CACHE_CARD_IMAGES=1` environment variable - - Integrated with setup/tagging process - - Statistics endpoint with intelligent caching (weekly refresh, matching card data staleness) -- **Component Library**: Living documentation of reusable UI components at `/docs/components` - - Interactive examples of all buttons, modals, forms, cards, and panels - - Jinja2 macros for consistent component usage - - Component partial templates for reuse across pages -- **TypeScript Migration**: Migrated JavaScript codebase to TypeScript for better type safety - - Converted `components.js` (376 lines) and `app.js` (1390 lines) to TypeScript - - Created shared type definitions for state management, telemetry, HTMX, and UI components - - Integrated TypeScript compilation into build process (`npm run build:ts`) - - Compiled JavaScript output in `code/web/static/js/` directory - - Docker build automatically compiles TypeScript during image creation +- _No unreleased changes yet._ ### Changed -- **Inline JavaScript Cleanup**: Removed legacy card hover system (~230 lines of unused code) -- **JavaScript Consolidation**: Extracted inline scripts to TypeScript modules - - Created `cardHover.ts` for unified hover panel functionality - - Created `cardImages.ts` for card image loading with automatic retry fallbacks - - Reduced inline script size in base template for better maintainability -- **Migrated CSS to Tailwind**: Consolidated and unified CSS architecture - - Tailwind CSS v3 with custom MTG color palette - - PostCSS build pipeline with autoprefixer - - Reduced inline styles in templates (moved to shared CSS classes) - - Organized CSS into functional sections with clear documentation -- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes - - Light mode: Darker text for improved readability, warm earth tone color palette - - Dark mode: Refined contrast for better visual hierarchy - - High-contrast mode: Optimized for maximum accessibility - - Consistent hover states across all interactive elements - - Improved visibility of form inputs and controls -- **JavaScript Modernization**: Updated to modern JavaScript patterns - - Converted `var` declarations to `const`/`let` - - Added TypeScript type annotations for better IDE support and error catching - - Consolidated event handlers and utility functions -- **Docker Build Optimization**: Improved developer experience - - Hot reload enabled for templates and static files - - Volume mounts for rapid iteration without rebuilds -- **Template Modernization**: Migrated templates to use component system -- **Type Checking Configuration**: Improved Python code quality tooling - - Configured type checker for better error detection - - Optimized linting rules for development workflow -- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck - - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) - - 10% bonus for cards appearing in 80%+ of builds - - Color-coded synergy scores in preview (green=high, red=low) - - Partner commander support with combined color identity - - Multi-copy card tracking (e.g., 8 Mountains, 7 Islands) - - Export synergy deck with full metadata (CSV, TXT, JSON files) -- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) -- Detailed progress logging for multi-build orchestration -- User guide: `docs/user_guides/batch_build_compare.md` -- **Web UI Component Library**: Standardized UI components for consistent design across all pages - - 5 component partial template files (buttons, modals, forms, cards, panels) - - ~900 lines of component CSS styles - - Interactive JavaScript utilities (components.js) - - Living component library page at `/docs/components` - - 1600+ lines developer documentation (component_catalog.md) -- **Custom UI Enhancements**: - - Darker gray styling for home page buttons - - Visual highlighting for selected theme chips in deck builder - -### Removed -_None_ +- _No unreleased changes yet._ ### Fixed -- **Template Quality**: Resolved HTML structure issues - - Fixed duplicate ID attributes in templates - - Removed erroneous template block tags - - Corrected structure for HTMX fragments -- **Code Quality**: Resolved type checking warnings and improved code maintainability - - Fixed type annotation inconsistencies - - Cleaned up redundant code quality suppressions - - Corrected configuration conflicts - -### Performance -- Hot reload for CSS/template changes (no Docker rebuild needed) -- Optional image caching reduces Scryfall API calls -- Faster page loads with optimized CSS -- TypeScript compilation produces optimized JavaScript - -### For Users -- Faster card image loading with optional caching -- Cleaner, more consistent web UI design -- Improved page load performance -- More reliable JavaScript behavior - -### Deprecated -_None_ - -### Security -_None_ \ No newline at end of file +- _No unreleased changes yet._ diff --git a/code/deck_builder/__init__.py b/code/deck_builder/__init__.py index 9540709..c992bac 100644 --- a/code/deck_builder/__init__.py +++ b/code/deck_builder/__init__.py @@ -4,6 +4,6 @@ __all__ = ['DeckBuilder'] def __getattr__(name): # Lazy-load DeckBuilder to avoid side effects during import of submodules if name == 'DeckBuilder': - from .builder import DeckBuilder + from .builder import DeckBuilder # type: ignore return DeckBuilder raise AttributeError(name) diff --git a/code/deck_builder/background_loader.py b/code/deck_builder/background_loader.py index b941f30..87123d1 100644 --- a/code/deck_builder/background_loader.py +++ b/code/deck_builder/background_loader.py @@ -1,18 +1,22 @@ -"""Loader for background cards derived from all_cards.parquet.""" +"""Loader for background cards derived from `background_cards.csv`.""" from __future__ import annotations import ast -import re +import csv from dataclasses import dataclass from functools import lru_cache from pathlib import Path -from typing import Any, Mapping, Tuple +import re +from typing import Mapping, Tuple -from logging_util import get_logger +from code.logging_util import get_logger from deck_builder.partner_background_utils import analyze_partner_background +from path_util import csv_dir LOGGER = get_logger(__name__) +BACKGROUND_FILENAME = "background_cards.csv" + @dataclass(frozen=True, slots=True) class BackgroundCard: @@ -53,7 +57,7 @@ class BackgroundCatalog: def load_background_cards( source_path: str | Path | None = None, ) -> BackgroundCatalog: - """Load and cache background card data from all_cards.parquet.""" + """Load and cache background card data.""" resolved = _resolve_background_path(source_path) try: @@ -61,7 +65,7 @@ def load_background_cards( mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) size = stat.st_size except FileNotFoundError: - raise FileNotFoundError(f"Background data not found at {resolved}") from None + raise FileNotFoundError(f"Background CSV not found at {resolved}") from None entries, version = _load_background_cards_cached(str(resolved), mtime_ns) etag = f"{size}-{mtime_ns}-{len(entries)}" @@ -84,49 +88,46 @@ def _load_background_cards_cached(path_str: str, mtime_ns: int) -> Tuple[Tuple[B if not path.exists(): return tuple(), "unknown" - try: - import pandas as pd - df = pd.read_parquet(path, engine="pyarrow") - - # Filter for background cards - if 'isBackground' not in df.columns: - LOGGER.warning("isBackground column not found in %s", path) - return tuple(), "unknown" - - df_backgrounds = df[df['isBackground']].copy() - - if len(df_backgrounds) == 0: - LOGGER.warning("No background cards found in %s", path) - return tuple(), "unknown" - - entries = _rows_to_cards(df_backgrounds) - version = "parquet" - - except Exception as e: - LOGGER.error("Failed to load backgrounds from %s: %s", path, e) - return tuple(), "unknown" + with path.open("r", encoding="utf-8", newline="") as handle: + first_line = handle.readline() + version = "unknown" + if first_line.startswith("#"): + version = _parse_version(first_line) + else: + handle.seek(0) + reader = csv.DictReader(handle) + if reader.fieldnames is None: + return tuple(), version + entries = _rows_to_cards(reader) frozen = tuple(entries) return frozen, version def _resolve_background_path(override: str | Path | None) -> Path: - """Resolve path to all_cards.parquet.""" if override: return Path(override).resolve() - # Use card_files/processed/all_cards.parquet - return Path("card_files/processed/all_cards.parquet").resolve() + return (Path(csv_dir()) / BACKGROUND_FILENAME).resolve() -def _rows_to_cards(df) -> list[BackgroundCard]: - """Convert DataFrame rows to BackgroundCard objects.""" +def _parse_version(line: str) -> str: + tokens = line.lstrip("# ").strip().split() + for token in tokens: + if "=" not in token: + continue + key, value = token.split("=", 1) + if key == "version": + return value + return "unknown" + + +def _rows_to_cards(reader: csv.DictReader) -> list[BackgroundCard]: entries: list[BackgroundCard] = [] seen: set[str] = set() - - for _, row in df.iterrows(): - if row.empty: + for raw in reader: + if not raw: continue - card = _row_to_card(row) + card = _row_to_card(raw) if card is None: continue key = card.display_name.lower() @@ -134,35 +135,20 @@ def _rows_to_cards(df) -> list[BackgroundCard]: continue seen.add(key) entries.append(card) - entries.sort(key=lambda card: card.display_name) return entries -def _row_to_card(row) -> BackgroundCard | None: - """Convert a DataFrame row to a BackgroundCard.""" - # Helper to safely get values from DataFrame row - def get_val(key: str): - try: - if hasattr(row, key): - val = getattr(row, key) - # Handle pandas NA/None - if val is None or (hasattr(val, '__class__') and 'NA' in val.__class__.__name__): - return None - return val - return None - except Exception: - return None - - name = _clean_str(get_val("name")) - face_name = _clean_str(get_val("faceName")) or None +def _row_to_card(row: Mapping[str, str]) -> BackgroundCard | None: + name = _clean_str(row.get("name")) + face_name = _clean_str(row.get("faceName")) or None display = face_name or name if not display: return None - type_line = _clean_str(get_val("type")) - oracle_text = _clean_multiline(get_val("text")) - raw_theme_tags = tuple(_parse_literal_list(get_val("themeTags"))) + type_line = _clean_str(row.get("type")) + oracle_text = _clean_multiline(row.get("text")) + raw_theme_tags = tuple(_parse_literal_list(row.get("themeTags"))) detection = analyze_partner_background(type_line, oracle_text, raw_theme_tags) if not detection.is_background: return None @@ -172,18 +158,18 @@ def _row_to_card(row) -> BackgroundCard | None: face_name=face_name, display_name=display, slug=_slugify(display), - color_identity=_parse_color_list(get_val("colorIdentity")), - colors=_parse_color_list(get_val("colors")), - mana_cost=_clean_str(get_val("manaCost")), - mana_value=_parse_float(get_val("manaValue")), + color_identity=_parse_color_list(row.get("colorIdentity")), + colors=_parse_color_list(row.get("colors")), + mana_cost=_clean_str(row.get("manaCost")), + mana_value=_parse_float(row.get("manaValue")), type_line=type_line, oracle_text=oracle_text, - keywords=tuple(_split_list(get_val("keywords"))), + keywords=tuple(_split_list(row.get("keywords"))), theme_tags=tuple(tag for tag in raw_theme_tags if tag), raw_theme_tags=raw_theme_tags, - edhrec_rank=_parse_int(get_val("edhrecRank")), - layout=_clean_str(get_val("layout")) or "normal", - side=_clean_str(get_val("side")) or None, + edhrec_rank=_parse_int(row.get("edhrecRank")), + layout=_clean_str(row.get("layout")) or "normal", + side=_clean_str(row.get("side")) or None, ) @@ -203,19 +189,8 @@ def _clean_multiline(value: object) -> str: def _parse_literal_list(value: object) -> list[str]: if value is None: return [] - - # Check if it's a numpy array (from Parquet/pandas) - is_numpy = False - try: - import numpy as np - is_numpy = isinstance(value, np.ndarray) - except ImportError: - pass - - # Handle lists, tuples, sets, and numpy arrays - if isinstance(value, (list, tuple, set)) or is_numpy: + if isinstance(value, (list, tuple, set)): return [str(item).strip() for item in value if str(item).strip()] - text = str(value).strip() if not text: return [] @@ -230,17 +205,6 @@ def _parse_literal_list(value: object) -> list[str]: def _split_list(value: object) -> list[str]: - # Check if it's a numpy array (from Parquet/pandas) - is_numpy = False - try: - import numpy as np - is_numpy = isinstance(value, np.ndarray) - except ImportError: - pass - - if isinstance(value, (list, tuple, set)) or is_numpy: - return [str(item).strip() for item in value if str(item).strip()] - text = _clean_str(value) if not text: return [] @@ -249,18 +213,6 @@ def _split_list(value: object) -> list[str]: def _parse_color_list(value: object) -> Tuple[str, ...]: - # Check if it's a numpy array (from Parquet/pandas) - is_numpy = False - try: - import numpy as np - is_numpy = isinstance(value, np.ndarray) - except ImportError: - pass - - if isinstance(value, (list, tuple, set)) or is_numpy: - parts = [str(item).strip().upper() for item in value if str(item).strip()] - return tuple(parts) - text = _clean_str(value) if not text: return tuple() diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index a7eadd7..b08a718 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -95,7 +95,7 @@ class DeckBuilder( # If a seed was assigned pre-init, use it if self.seed is not None: # Import here to avoid any heavy import cycles at module import time - from random_util import set_seed as _set_seed + from random_util import set_seed as _set_seed # type: ignore self._rng = _set_seed(int(self.seed)) else: self._rng = random.Random() @@ -107,7 +107,7 @@ class DeckBuilder( def set_seed(self, seed: int | str) -> None: """Set deterministic seed for this builder and reset its RNG instance.""" try: - from random_util import derive_seed_from_string as _derive, set_seed as _set_seed + from random_util import derive_seed_from_string as _derive, set_seed as _set_seed # type: ignore s = _derive(seed) self.seed = int(s) self._rng = _set_seed(s) @@ -154,33 +154,28 @@ class DeckBuilder( start_ts = datetime.datetime.now() logger.info("=== Deck Build: BEGIN ===") try: - # M4: Ensure Parquet file exists and is tagged before starting any deck build logic + # Ensure CSVs exist and are tagged before starting any deck build logic try: import time as _time import json as _json from datetime import datetime as _dt - from code.path_util import get_processed_cards_path - - parquet_path = get_processed_cards_path() + cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv') flag_path = os.path.join(CSV_DIRECTORY, '.tagging_complete.json') refresh_needed = False - - if not os.path.exists(parquet_path): - logger.info("all_cards.parquet not found. Running initial setup and tagging before deck build...") + if not os.path.exists(cards_path): + logger.info("cards.csv not found. Running initial setup and tagging before deck build...") refresh_needed = True else: try: - age_seconds = _time.time() - os.path.getmtime(parquet_path) + age_seconds = _time.time() - os.path.getmtime(cards_path) if age_seconds > 7 * 24 * 60 * 60: - logger.info("all_cards.parquet is older than 7 days. Refreshing data before deck build...") + logger.info("cards.csv is older than 7 days. Refreshing data before deck build...") refresh_needed = True except Exception: pass - if not os.path.exists(flag_path): logger.info("Tagging completion flag not found. Performing full tagging before deck build...") refresh_needed = True - if refresh_needed: initial_setup() from tagging import tagger as _tagger @@ -192,7 +187,7 @@ class DeckBuilder( except Exception: logger.warning("Failed to write tagging completion flag (non-fatal).") except Exception as e: - logger.error(f"Failed ensuring Parquet file before deck build: {e}") + logger.error(f"Failed ensuring CSVs before deck build: {e}") self.run_initial_setup() self.run_deck_build_step1() self.run_deck_build_step2() @@ -215,7 +210,7 @@ class DeckBuilder( try: # Compute a quick compliance snapshot here to hint at upcoming enforcement if hasattr(self, 'compute_and_print_compliance') and not getattr(self, 'headless', False): - from deck_builder.brackets_compliance import evaluate_deck as _eval + from deck_builder.brackets_compliance import evaluate_deck as _eval # type: ignore bracket_key = str(getattr(self, 'bracket_name', '') or getattr(self, 'bracket_level', 'core')).lower() commander = getattr(self, 'commander_name', None) snap = _eval(self.card_library, commander_name=commander, bracket=bracket_key) @@ -240,15 +235,15 @@ class DeckBuilder( csv_path = self.export_decklist_csv() # Persist CSV path immediately (before any later potential exceptions) try: - self.last_csv_path = csv_path + self.last_csv_path = csv_path # type: ignore[attr-defined] except Exception: pass try: import os as _os base, _ext = _os.path.splitext(_os.path.basename(csv_path)) - txt_path = self.export_decklist_text(filename=base + '.txt') + txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] try: - self.last_txt_path = txt_path + self.last_txt_path = txt_path # type: ignore[attr-defined] except Exception: pass # Display the text file contents for easy copy/paste to online deck builders @@ -256,18 +251,18 @@ class DeckBuilder( # Compute bracket compliance and save a JSON report alongside exports try: if hasattr(self, 'compute_and_print_compliance'): - report0 = self.compute_and_print_compliance(base_stem=base) + report0 = self.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] # If non-compliant and interactive, offer enforcement now try: if isinstance(report0, dict) and report0.get('overall') == 'FAIL' and not getattr(self, 'headless', False): - from deck_builder.phases.phase6_reporting import ReportingMixin as _RM + from deck_builder.phases.phase6_reporting import ReportingMixin as _RM # type: ignore if isinstance(self, _RM) and hasattr(self, 'enforce_and_reexport'): self.output_func("One or more bracket limits exceeded. Enter to auto-resolve, or Ctrl+C to skip.") try: _ = self.input_func("") except Exception: pass - self.enforce_and_reexport(base_stem=base, mode='prompt') + self.enforce_and_reexport(base_stem=base, mode='prompt') # type: ignore[attr-defined] except Exception: pass except Exception: @@ -295,12 +290,12 @@ class DeckBuilder( cfg_dir = 'config' if cfg_dir: _os.makedirs(cfg_dir, exist_ok=True) - self.export_run_config_json(directory=cfg_dir, filename=base + '.json') + self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined] if cfg_path_env: cfg_dir2 = _os.path.dirname(cfg_path_env) or '.' cfg_name2 = _os.path.basename(cfg_path_env) _os.makedirs(cfg_dir2, exist_ok=True) - self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) + self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined] except Exception: pass except Exception: @@ -308,8 +303,8 @@ class DeckBuilder( else: # Mark suppression so random flow knows nothing was exported yet try: - self.last_csv_path = None - self.last_txt_path = None + self.last_csv_path = None # type: ignore[attr-defined] + self.last_txt_path = None # type: ignore[attr-defined] except Exception: pass # If owned-only and deck not complete, print a note @@ -624,8 +619,8 @@ class DeckBuilder( try: rec.card_library = rec_subset # Export CSV and TXT with suffix - rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True) - rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) + rec.export_decklist_csv(directory='deck_files', filename=base_stem + '_recommendations.csv', suppress_output=True) # type: ignore[attr-defined] + rec.export_decklist_text(directory='deck_files', filename=base_stem + '_recommendations.txt', suppress_output=True) # type: ignore[attr-defined] finally: rec.card_library = original_lib # Notify user succinctly @@ -837,47 +832,14 @@ class DeckBuilder( def load_commander_data(self) -> pd.DataFrame: if self._commander_df is not None: return self._commander_df - - # M7: Try loading from dedicated commander cache first (fast path) - from path_util import get_commander_cards_path - from file_setup.data_loader import DataLoader - - commander_path = get_commander_cards_path() - if os.path.exists(commander_path): - try: - loader = DataLoader() - df = loader.read_cards(commander_path, format="parquet") - - # Ensure required columns exist with proper defaults - if "themeTags" not in df.columns: - df["themeTags"] = [[] for _ in range(len(df))] - if "creatureTypes" not in df.columns: - df["creatureTypes"] = [[] for _ in range(len(df))] - - self._commander_df = df - return df - except Exception: - # Fall through to legacy path if cache read fails - pass - - # M4: Fallback - Load commanders from full Parquet file (slower) - from deck_builder import builder_utils as bu - from deck_builder import builder_constants as bc - - all_cards_df = bu._load_all_cards_parquet() - if all_cards_df.empty: - # Fallback to empty DataFrame with expected columns - return pd.DataFrame(columns=['name', 'themeTags', 'creatureTypes']) - - # Filter to only commander-eligible cards - df = bc.get_commanders(all_cards_df) - - # Ensure required columns exist with proper defaults + df = pd.read_csv( + bc.COMMANDER_CSV_PATH, + converters=getattr(bc, "COMMANDER_CONVERTERS", None) + ) if "themeTags" not in df.columns: df["themeTags"] = [[] for _ in range(len(df))] if "creatureTypes" not in df.columns: df["creatureTypes"] = [[] for _ in range(len(df))] - self._commander_df = df return df @@ -1101,11 +1063,8 @@ class DeckBuilder( if isinstance(raw_ci, list): colors_list = [str(c).strip().upper() for c in raw_ci] elif isinstance(raw_ci, str) and raw_ci.strip(): - # Handle the literal string "Colorless" specially (from commander_cards.csv) - if raw_ci.strip().lower() == 'colorless': - colors_list = [] # Could be formatted like "['B','G']" or 'BG'; attempt simple parsing - elif ',' in raw_ci: + if ',' in raw_ci: colors_list = [c.strip().strip("'[] ").upper() for c in raw_ci.split(',') if c.strip().strip("'[] ")] else: colors_list = [c.upper() for c in raw_ci if c.isalpha()] @@ -1163,9 +1122,9 @@ class DeckBuilder( return full, load_files def setup_dataframes(self) -> pd.DataFrame: - """Load cards from all_cards.parquet and filter by current color identity. + """Load all csv files for current color identity into one combined DataFrame. - M4: Migrated from CSV to Parquet. Filters by color identity using colorIdentity column. + Each file stem in files_to_load corresponds to csv_files/{stem}_cards.csv. The result is cached and returned. Minimal validation only (non-empty, required columns exist if known). """ if self._combined_cards_df is not None: @@ -1173,53 +1132,29 @@ class DeckBuilder( if not self.files_to_load: # Attempt to determine if not yet done self.determine_color_identity() - - # M4: Load from Parquet instead of CSV files - from deck_builder import builder_utils as bu - all_cards_df = bu._load_all_cards_parquet() - - if all_cards_df is None or all_cards_df.empty: - raise RuntimeError("Failed to load all_cards.parquet or file is empty.") - - # M4: Filter by color identity instead of loading multiple CSVs - # Get the colors from self.color_identity (e.g., {'W', 'U', 'B', 'G'}) - if hasattr(self, 'color_identity') and self.color_identity: - # Determine which cards can be played in this color identity - # A card can be played if its color identity is a subset of the commander's color identity - def card_matches_identity(card_colors): - """Check if card's color identity is legal in commander's identity.""" - if card_colors is None or (isinstance(card_colors, float) and pd.isna(card_colors)): - # Colorless cards can go in any deck - return True - if isinstance(card_colors, str): - # Handle string format like "B, G, R, U" (note the spaces after commas) - card_colors = {c.strip() for c in card_colors.split(',')} if card_colors else set() - elif isinstance(card_colors, list): - card_colors = set(card_colors) - else: - # Unknown format, be permissive - return True - # Card is legal if its colors are a subset of commander colors - return card_colors.issubset(self.color_identity) - - if 'colorIdentity' in all_cards_df.columns: - mask = all_cards_df['colorIdentity'].apply(card_matches_identity) - combined = all_cards_df[mask].copy() - logger.info(f"M4 COLOR_FILTER: Filtered {len(all_cards_df)} cards to {len(combined)} cards for identity {sorted(self.color_identity)}") - else: - logger.warning("M4 COLOR_FILTER: colorIdentity column missing, using all cards") - combined = all_cards_df.copy() - else: - # No color identity set, use all cards - logger.warning("M4 COLOR_FILTER: No color identity set, using all cards") - combined = all_cards_df.copy() - + dfs = [] + required = getattr(bc, 'CSV_REQUIRED_COLUMNS', []) + from path_util import csv_dir as _csv_dir + base = _csv_dir() + for stem in self.files_to_load: + path = f"{base}/{stem}_cards.csv" + try: + df = pd.read_csv(path) + if required: + missing = [c for c in required if c not in df.columns] + if missing: + # Skip or still keep with warning; choose to warn + self.output_func(f"Warning: {path} missing columns: {missing}") + dfs.append(df) + except FileNotFoundError: + self.output_func(f"Warning: CSV file not found: {path}") + continue + if not dfs: + raise RuntimeError("No CSV files loaded for color identity.") + combined = pd.concat(dfs, axis=0, ignore_index=True) # Drop duplicate rows by 'name' if column exists if 'name' in combined.columns: - before_dedup = len(combined) combined = combined.drop_duplicates(subset='name', keep='first') - if len(combined) < before_dedup: - logger.info(f"M4 DEDUP: Removed {before_dedup - len(combined)} duplicate names") # If owned-only mode, filter combined pool to owned names (case-insensitive) if self.use_owned_only: try: @@ -1240,54 +1175,6 @@ class DeckBuilder( self.output_func(f"Owned-only mode: failed to filter combined pool: {_e}") # Soft prefer-owned does not filter the pool; biasing is applied later at selection time - # M2: Filter out cards useless in colorless identity decks - if self.color_identity_key == 'COLORLESS': - logger.info(f"M2 COLORLESS FILTER: Activated for color_identity_key='{self.color_identity_key}'") - try: - if 'metadataTags' in combined.columns and 'name' in combined.columns: - # Find cards with "Useless in Colorless" metadata tag - def has_useless_tag(metadata_tags): - # Handle various types: NaN, empty list, list with values - if metadata_tags is None: - return False - # Check for pandas NaN or numpy NaN - try: - import numpy as np - if isinstance(metadata_tags, float) and np.isnan(metadata_tags): - return False - except (TypeError, ValueError): - pass - # Handle empty list or numpy array - if isinstance(metadata_tags, (list, np.ndarray)): - if len(metadata_tags) == 0: - return False - return 'Useless in Colorless' in metadata_tags - return False - - useless_mask = combined['metadataTags'].apply(has_useless_tag) - useless_count = useless_mask.sum() - - if useless_count > 0: - useless_names = combined.loc[useless_mask, 'name'].tolist() - combined = combined[~useless_mask].copy() - self.output_func(f"Colorless commander: filtered out {useless_count} cards useless in colorless identity") - logger.info(f"M2 COLORLESS FILTER: Filtered out {useless_count} cards") - # Log first few cards for transparency - for name in useless_names[:3]: - self.output_func(f" - Filtered: {name}") - logger.info(f"M2 COLORLESS FILTER: Removed '{name}'") - if useless_count > 3: - self.output_func(f" - ... and {useless_count - 3} more") - else: - logger.warning(f"M2 COLORLESS FILTER: No cards found with 'Useless in Colorless' tag!") - else: - logger.warning(f"M2 COLORLESS FILTER: Missing required columns (metadataTags or name)") - except Exception as e: - self.output_func(f"Warning: Failed to apply colorless filter: {e}") - logger.error(f"M2 COLORLESS FILTER: Exception: {e}", exc_info=True) - else: - logger.info(f"M2 COLORLESS FILTER: Not activated - color_identity_key='{self.color_identity_key}' (not 'Colorless')") - # Apply exclude card filtering (M0.5: Phase 1 - Exclude Only) if hasattr(self, 'exclude_cards') and self.exclude_cards: try: @@ -1843,7 +1730,7 @@ class DeckBuilder( from deck_builder import builder_constants as bc from settings import MULTIPLE_COPY_CARDS except Exception: - MULTIPLE_COPY_CARDS = [] + MULTIPLE_COPY_CARDS = [] # type: ignore is_land = 'land' in str(card_type or entry.get('Card Type','')).lower() is_basic = False try: @@ -2005,10 +1892,10 @@ class DeckBuilder( return block = self._format_commander_pretty(self.commander_row) self.output_func("\n" + block) - # M4: Show that we're loading from unified Parquet file - if hasattr(self, 'color_identity') and self.color_identity: - colors = ', '.join(sorted(self.color_identity)) - self.output_func(f"Card Pool: all_cards.parquet (filtered to {colors} identity)") + # New: show which CSV files (stems) were loaded for this color identity + if self.files_to_load: + file_list = ", ".join(f"{stem}_cards.csv" for stem in self.files_to_load) + self.output_func(f"Card Pool Files: {file_list}") # Owned-only status if getattr(self, 'use_owned_only', False): try: @@ -2353,7 +2240,7 @@ class DeckBuilder( rng = getattr(self, 'rng', None) try: if rng: - rng.shuffle(bucket_keys) + rng.shuffle(bucket_keys) # type: ignore else: random.shuffle(bucket_keys) except Exception: diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 02e2054..6193869 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -1,12 +1,9 @@ -from typing import Dict, List, Final, Tuple, Union, Callable, Any +from typing import Dict, List, Final, Tuple, Union, Callable, Any as _Any from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified from path_util import csv_dir -import pandas as pd __all__ = [ - 'CSV_REQUIRED_COLUMNS', - 'get_commanders', - 'get_backgrounds', + 'CSV_REQUIRED_COLUMNS' ] import ast @@ -17,11 +14,9 @@ MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices # Commander-related constants DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}' -# M4: Deprecated - use Parquet loading instead COMMANDER_CSV_PATH: Final[str] = f"{csv_dir()}/commander_cards.csv" DECK_DIRECTORY = '../deck_files' -# M4: Deprecated - Parquet handles types natively (no converters needed) -COMMANDER_CONVERTERS: Final[Dict[str, Any]] = { +COMMANDER_CONVERTERS: Final[Dict[str, str]] = { 'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval, 'roleTags': ast.literal_eval, @@ -140,18 +135,18 @@ OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = { } # Card category validation rules -CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { +CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { 'power': {'type': ('str', 'int', 'float'), 'required': True}, 'toughness': {'type': ('str', 'int', 'float'), 'required': True}, 'creatureTypes': {'type': 'list', 'required': True} } -SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { +SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { 'manaCost': {'type': 'str', 'required': True}, 'text': {'type': 'str', 'required': True} } -LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { +LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { 'type': {'type': ('str', 'object'), 'required': True}, 'text': {'type': ('str', 'object'), 'required': False} } @@ -291,7 +286,7 @@ COLORED_MANA_SYMBOLS: Final[List[str]] = ['{w}','{u}','{b}','{r}','{g}'] # Basic Lands -BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes'] +BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] # Basic land mappings COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { @@ -526,7 +521,7 @@ CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch # CSV validation configuration -CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Any]]] = { +CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = { 'name': {'type': ('str', 'object'), 'required': True, 'unique': True}, 'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000}, 'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20}, @@ -602,12 +597,12 @@ GAME_CHANGERS: Final[List[str]] = [ # - color_identity: list[str] of required color letters (subset must be in commander CI) # - printed_cap: int | None (None means no printed cap) # - exclusive_group: str | None (at most one from the same group) -# - triggers: { tagsAny: list[str], tags_all: list[str] } +# - triggers: { tags_any: list[str], tags_all: list[str] } # - default_count: int (default 25) # - rec_window: tuple[int,int] (recommendation window) # - thrumming_stone_synergy: bool # - type_hint: 'creature' | 'noncreature' -MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { +MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, _Any]]] = { 'cid_timeless_artificer': { 'id': 'cid_timeless_artificer', 'name': 'Cid, Timeless Artificer', @@ -615,7 +610,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['artificer kindred', 'hero kindred', 'artifacts matter'], + 'tags_any': ['artificer kindred', 'hero kindred', 'artifacts matter'], 'tags_all': [] }, 'default_count': 25, @@ -630,7 +625,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'], + 'tags_any': ['burn','spellslinger','prowess','storm','copy','cascade','impulse draw','treasure','ramp','graveyard','mill','discard','recursion'], 'tags_all': [] }, 'default_count': 25, @@ -645,7 +640,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['rabbit kindred','tokens matter','aggro'], + 'tags_any': ['rabbit kindred','tokens matter','aggro'], 'tags_all': [] }, 'default_count': 25, @@ -660,7 +655,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'], + 'tags_any': ['tokens','tokens matter','go-wide','exile matters','ooze kindred','spells matter','spellslinger','graveyard','mill','discard','recursion','domain','self-mill','delirium','descend'], 'tags_all': [] }, 'default_count': 25, @@ -675,7 +670,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': 'rats', 'triggers': { - 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], + 'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], 'tags_all': [] }, 'default_count': 25, @@ -690,7 +685,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': 'rats', 'triggers': { - 'tagsAny': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], + 'tags_any': ['rats','swarm','aristocrats','sacrifice','devotion-b','lifedrain','graveyard','recursion'], 'tags_all': [] }, 'default_count': 25, @@ -705,7 +700,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': 7, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'], + 'tags_any': ['dwarf kindred','treasure','equipment','tokens','go-wide','tribal'], 'tags_all': [] }, 'default_count': 7, @@ -720,7 +715,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['mill','advisor kindred','control','defenders','walls','draw-go'], + 'tags_any': ['mill','advisor kindred','control','defenders','walls','draw-go'], 'tags_all': [] }, 'default_count': 25, @@ -735,7 +730,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'], + 'tags_any': ['demon kindred','aristocrats','sacrifice','recursion','lifedrain'], 'tags_all': [] }, 'default_count': 25, @@ -750,7 +745,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': 9, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'], + 'tags_any': ['wraith kindred','ring','amass','orc','menace','aristocrats','sacrifice','devotion-b'], 'tags_all': [] }, 'default_count': 9, @@ -765,7 +760,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['bird kindred','aggro'], + 'tags_any': ['bird kindred','aggro'], 'tags_all': [] }, 'default_count': 25, @@ -780,7 +775,7 @@ MULTI_COPY_ARCHETYPES: Final[dict[str, dict[str, Any]]] = { 'printed_cap': None, 'exclusive_group': None, 'triggers': { - 'tagsAny': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], + 'tags_any': ['aggro','human kindred','knight kindred','historic matters','artifacts matter'], 'tags_all': [] }, 'default_count': 25, @@ -923,37 +918,3 @@ ICONIC_CARDS: Final[set[str]] = { 'Vampiric Tutor', 'Mystical Tutor', 'Enlightened Tutor', 'Worldly Tutor', 'Eternal Witness', 'Solemn Simulacrum', 'Consecrated Sphinx', 'Avenger of Zendikar', } - - -# M4: Parquet filtering helpers -def get_commanders(df: pd.DataFrame) -> pd.DataFrame: - """Filter DataFrame to only commander-legal cards using isCommander flag. - - M4: Replaces CSV-based commander filtering with Parquet boolean flag. - - Args: - df: DataFrame with 'isCommander' column - - Returns: - Filtered DataFrame containing only commanders - """ - if 'isCommander' not in df.columns: - return pd.DataFrame() - return df[df['isCommander'] == True].copy() # noqa: E712 - - -def get_backgrounds(df: pd.DataFrame) -> pd.DataFrame: - """Filter DataFrame to only background cards using isBackground flag. - - M4: Replaces CSV-based background filtering with Parquet boolean flag. - - Args: - df: DataFrame with 'isBackground' column - - Returns: - Filtered DataFrame containing only backgrounds - """ - if 'isBackground' not in df.columns: - return pd.DataFrame() - return df[df['isBackground'] == True].copy() # noqa: E712 - diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index a47101e..5defecb 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -62,32 +62,6 @@ def _detect_produces_mana(text: str) -> bool: return False -def _extract_colors_from_land_type(type_line: str) -> List[str]: - """Extract mana colors from basic land types in a type line. - - Args: - type_line: Card type line (e.g., "Land — Mountain", "Land — Forest Plains") - - Returns: - List of color letters (e.g., ['R'], ['G', 'W']) - """ - if not isinstance(type_line, str): - return [] - type_lower = type_line.lower() - colors = [] - basic_land_colors = { - 'plains': 'W', - 'island': 'U', - 'swamp': 'B', - 'mountain': 'R', - 'forest': 'G', - } - for land_type, color in basic_land_colors.items(): - if land_type in type_lower: - colors.append(color) - return colors - - def _resolved_csv_dir(base_dir: str | None = None) -> str: try: if base_dir: @@ -97,86 +71,16 @@ def _resolved_csv_dir(base_dir: str | None = None) -> str: return base_dir or csv_dir() -# M7: Cache for all cards Parquet DataFrame to avoid repeated loads -_ALL_CARDS_CACHE: Dict[str, Any] = {"df": None, "mtime": None} - - -def _load_all_cards_parquet() -> pd.DataFrame: - """Load all cards from the unified Parquet file with caching. - - M4: Centralized Parquet loading for deck builder. - M7: Added module-level caching to avoid repeated file loads. - Returns empty DataFrame on error (defensive). - Converts numpy arrays to Python lists for compatibility with existing code. - """ - global _ALL_CARDS_CACHE - - try: - from code.path_util import get_processed_cards_path - from code.file_setup.data_loader import DataLoader - import numpy as np - import os - - parquet_path = get_processed_cards_path() - if not Path(parquet_path).exists(): - return pd.DataFrame() - - # M7: Check cache and mtime - need_reload = _ALL_CARDS_CACHE["df"] is None - if not need_reload: - try: - current_mtime = os.path.getmtime(parquet_path) - cached_mtime = _ALL_CARDS_CACHE.get("mtime") - if cached_mtime is None or current_mtime > cached_mtime: - need_reload = True - except Exception: - # If mtime check fails, use cached version if available - pass - - if need_reload: - data_loader = DataLoader() - df = data_loader.read_cards(parquet_path, format="parquet") - - # M4: Convert numpy arrays to Python lists for compatibility - # Parquet stores lists as numpy arrays, but existing code expects Python lists - list_columns = ['themeTags', 'creatureTypes', 'metadataTags', 'keywords'] - for col in list_columns: - if col in df.columns: - df[col] = df[col].apply(lambda x: x.tolist() if isinstance(x, np.ndarray) else x) - - # M7: Cache the result - _ALL_CARDS_CACHE["df"] = df - try: - _ALL_CARDS_CACHE["mtime"] = os.path.getmtime(parquet_path) - except Exception: - _ALL_CARDS_CACHE["mtime"] = None - - return _ALL_CARDS_CACHE["df"] - except Exception: - return pd.DataFrame() - - @lru_cache(maxsize=None) def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: - """Load mapping of multi-faced cards that have at least one land face. - - M4: Migrated to use Parquet loading. base_dir parameter kept for - backward compatibility but now only used as cache key. - """ + """Load mapping of multi-faced cards that have at least one land face.""" try: - # M4: Load from Parquet instead of CSV - df = _load_all_cards_parquet() - if df.empty: + base_path = Path(base_dir) + csv_path = base_path / 'cards.csv' + if not csv_path.exists(): return {} - - # Select only needed columns - # M9: Added backType to detect MDFC lands where land is on back face - # M9: Added colorIdentity to extract mana colors for MDFC lands - usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName', 'backType', 'colorIdentity'] - available_cols = [col for col in usecols if col in df.columns] - if not available_cols: - return {} - df = df[available_cols].copy() + usecols = ['name', 'layout', 'side', 'type', 'text', 'manaCost', 'manaValue', 'faceName'] + df = pd.read_csv(csv_path, usecols=usecols, low_memory=False) except Exception: return {} if df.empty or 'layout' not in df.columns or 'type' not in df.columns: @@ -188,16 +92,7 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: multi_df['type'] = multi_df['type'].fillna('').astype(str) multi_df['side'] = multi_df['side'].fillna('').astype(str) multi_df['text'] = multi_df['text'].fillna('').astype(str) - # M9: Check both type and backType for land faces - if 'backType' in multi_df.columns: - multi_df['backType'] = multi_df['backType'].fillna('').astype(str) - land_mask = ( - multi_df['type'].str.contains('land', case=False, na=False) | - multi_df['backType'].str.contains('land', case=False, na=False) - ) - land_rows = multi_df[land_mask] - else: - land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] + land_rows = multi_df[multi_df['type'].str.contains('land', case=False, na=False)] if land_rows.empty: return {} mapping: Dict[str, Dict[str, Any]] = {} @@ -206,78 +101,6 @@ def _load_multi_face_land_map(base_dir: str) -> Dict[str, Dict[str, Any]]: seen: set[tuple[str, str, str]] = set() front_is_land = False layout_val = '' - - # M9: Handle merged rows with backType - if len(group) == 1 and 'backType' in group.columns: - row = group.iloc[0] - back_type_val = str(row.get('backType', '') or '') - if back_type_val and 'land' in back_type_val.lower(): - # Construct synthetic faces from merged row - front_type = str(row.get('type', '') or '') - front_text = str(row.get('text', '') or '') - mana_cost_val = str(row.get('manaCost', '') or '') - mana_value_raw = row.get('manaValue', '') - mana_value_val = None - try: - if mana_value_raw not in (None, ''): - mana_value_val = float(mana_value_raw) - if math.isnan(mana_value_val): - mana_value_val = None - except Exception: - mana_value_val = None - - # Front face - faces.append({ - 'face': str(row.get('faceName', '') or name), - 'side': 'a', - 'type': front_type, - 'text': front_text, - 'mana_cost': mana_cost_val, - 'mana_value': mana_value_val, - 'produces_mana': _detect_produces_mana(front_text), - 'is_land': 'land' in front_type.lower(), - 'layout': str(row.get('layout', '') or ''), - }) - - # Back face (synthesized) - # M9: Use colorIdentity column for MDFC land colors (more reliable than parsing type line) - color_identity_raw = row.get('colorIdentity', []) - if isinstance(color_identity_raw, str): - # Handle string format like "['G']" or "G" - try: - import ast - color_identity_raw = ast.literal_eval(color_identity_raw) - except Exception: - color_identity_raw = [c.strip() for c in color_identity_raw.split(',') if c.strip()] - back_face_colors = list(color_identity_raw) if color_identity_raw else [] - # Fallback to parsing land type if colorIdentity not available - if not back_face_colors: - back_face_colors = _extract_colors_from_land_type(back_type_val) - - faces.append({ - 'face': name.split(' // ')[1] if ' // ' in name else 'Back', - 'side': 'b', - 'type': back_type_val, - 'text': '', # Not available in merged row - 'mana_cost': '', - 'mana_value': None, - 'produces_mana': True, # Assume land produces mana - 'is_land': True, - 'layout': str(row.get('layout', '') or ''), - 'colors': back_face_colors, # M9: Color information for mana sources - }) - - front_is_land = 'land' in front_type.lower() - layout_val = str(row.get('layout', '') or '') - mapping[name] = { - 'faces': faces, - 'front_is_land': front_is_land, - 'layout': layout_val, - 'colors': back_face_colors, # M9: Store colors at top level for easy access - } - continue - - # Original logic for multi-row format for _, row in group.iterrows(): side_raw = str(row.get('side', '') or '').strip() side_key = side_raw.lower() @@ -347,13 +170,7 @@ def parse_theme_tags(val) -> list[str]: ['Tag1', 'Tag2'] "['Tag1', 'Tag2']" Tag1, Tag2 - numpy.ndarray (from Parquet) Returns list of stripped string tags (may be empty).""" - # M4: Handle numpy arrays from Parquet - import numpy as np - if isinstance(val, np.ndarray): - return [str(x).strip() for x in val.tolist() if x and str(x).strip()] - if isinstance(val, list): flat: list[str] = [] for v in val: @@ -386,18 +203,6 @@ def parse_theme_tags(val) -> list[str]: return [] -def ensure_theme_tags_list(val) -> list[str]: - """Safely convert themeTags value to list, handling None, lists, and numpy arrays. - - This is a simpler wrapper around parse_theme_tags for the common case where - you just need to ensure you have a list to work with. - """ - if val is None: - return [] - return parse_theme_tags(val) - - - def normalize_theme_list(raw) -> list[str]: """Parse then lowercase + strip each tag.""" tags = parse_theme_tags(raw) @@ -425,7 +230,7 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ matrix: Dict[str, Dict[str, int]] = {} lookup = {} if full_df is not None and not getattr(full_df, 'empty', True) and 'name' in full_df.columns: - for _, r in full_df.iterrows(): + for _, r in full_df.iterrows(): # type: ignore[attr-defined] nm = str(r.get('name', '')) if nm and nm not in lookup: lookup[nm] = r @@ -441,13 +246,8 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if hasattr(row, 'get'): row_type_raw = row.get('type', row.get('type_line', '')) or '' tline_full = str(row_type_raw).lower() - # M9: Check backType for MDFC land detection - back_type_raw = '' - if hasattr(row, 'get'): - back_type_raw = row.get('backType', '') or '' - back_type = str(back_type_raw).lower() # Land or permanent that could produce mana via text - is_land = ('land' in entry_type) or ('land' in tline_full) or ('land' in back_type) + is_land = ('land' in entry_type) or ('land' in tline_full) base_is_land = is_land text_field_raw = '' if hasattr(row, 'get'): @@ -477,8 +277,7 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if face_types or face_texts: is_land = True text_field = text_field_raw.lower().replace('\n', ' ') - # Skip obvious non-permanents (rituals etc.) - but NOT if any face is a land - # M9: If is_land is True (from backType check), we keep it regardless of front face type + # Skip obvious non-permanents (rituals etc.) if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full): continue # Keep only candidates that are lands OR whose text indicates mana production @@ -552,12 +351,6 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ colors['_dfc_land'] = True if not (base_is_land or dfc_entry.get('front_is_land')): colors['_dfc_counts_as_extra'] = True - # M9: Extract colors from DFC face metadata (back face land colors) - dfc_colors = dfc_entry.get('colors', []) - if dfc_colors: - for color in dfc_colors: - if color in colors: - colors[color] = 1 produces_any_color = any(colors[c] for c in ('W', 'U', 'B', 'R', 'G', 'C')) if produces_any_color or colors.get('_dfc_land'): matrix[name] = colors @@ -850,7 +643,7 @@ def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: i out: list[tuple[int,str,str,str]] = [] if df is None or getattr(df, 'empty', True): return out - for _, row in df.iterrows(): + for _, row in df.iterrows(): # type: ignore[attr-defined] try: name = str(row.get('name','')) if not name or name in already or name in basics: @@ -1114,7 +907,7 @@ def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'): # --------------------------------------------------------------------------- # Tag-driven land suggestion helpers # --------------------------------------------------------------------------- -def build_tag_driven_suggestions(builder) -> list[dict]: +def build_tag_driven_suggestions(builder) -> list[dict]: # type: ignore[override] """Return a list of suggestion dicts based on selected commander tags. Each dict fields: @@ -1202,7 +995,7 @@ def color_balance_addition_candidates(builder, target_color: str, combined_df) - return [] existing = set(builder.card_library.keys()) out: list[tuple[str, int]] = [] - for _, row in combined_df.iterrows(): + for _, row in combined_df.iterrows(): # type: ignore[attr-defined] name = str(row.get('name', '')) if not name or name in existing or any(name == o[0] for o in out): continue diff --git a/code/deck_builder/combined_commander.py b/code/deck_builder/combined_commander.py index 85ba6eb..a5694b6 100644 --- a/code/deck_builder/combined_commander.py +++ b/code/deck_builder/combined_commander.py @@ -7,8 +7,8 @@ from typing import Iterable, Sequence, Tuple from exceptions import CommanderPartnerError -from .partner_background_utils import analyze_partner_background -from .color_identity_utils import canon_color_code, color_label_from_code +from code.deck_builder.partner_background_utils import analyze_partner_background +from code.deck_builder.color_identity_utils import canon_color_code, color_label_from_code _WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G", "C") _COLOR_PRIORITY = {color: index for index, color in enumerate(_WUBRG_ORDER)} diff --git a/code/deck_builder/enforcement.py b/code/deck_builder/enforcement.py index ecc9395..0f0ef17 100644 --- a/code/deck_builder/enforcement.py +++ b/code/deck_builder/enforcement.py @@ -88,12 +88,12 @@ def _candidate_pool_for_role(builder, role: str) -> List[Tuple[str, dict]]: # Sort by edhrecRank then manaValue try: from . import builder_utils as bu - sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) + sorted_df = bu.sort_by_priority(pool, ["edhrecRank", "manaValue"]) # type: ignore[attr-defined] # Prefer-owned bias if getattr(builder, "prefer_owned", False): owned = getattr(builder, "owned_card_names", None) if owned: - sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) + sorted_df = bu.prefer_owned_first(sorted_df, {str(n).lower() for n in owned}) # type: ignore[attr-defined] except Exception: sorted_df = pool @@ -363,7 +363,7 @@ def enforce_bracket_compliance(builder, mode: str = "prompt") -> Dict: break # Rank candidates: break the most combos first; break ties by worst desirability cand_names = list(freq.keys()) - cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) + cand_names.sort(key=lambda nm: (-int(freq.get(nm, 0)), _score(nm)), reverse=False) # type: ignore[arg-type] removed_any = False for nm in cand_names: if nm in blocked: diff --git a/code/deck_builder/partner_selection.py b/code/deck_builder/partner_selection.py index 4ec59fc..f5808bc 100644 --- a/code/deck_builder/partner_selection.py +++ b/code/deck_builder/partner_selection.py @@ -17,7 +17,7 @@ from logging_util import get_logger logger = get_logger(__name__) try: # Optional pandas import for type checking without heavy dependency at runtime. - import pandas as _pd + import pandas as _pd # type: ignore except Exception: # pragma: no cover - tests provide DataFrame-like objects. _pd = None # type: ignore @@ -267,7 +267,7 @@ def _find_commander_row(df: Any, name: str | None): if not target: return None - if _pd is not None and isinstance(df, _pd.DataFrame): + if _pd is not None and isinstance(df, _pd.DataFrame): # type: ignore columns = [col for col in ("name", "faceName") if col in df.columns] for col in columns: series = df[col].astype(str).str.casefold() @@ -363,14 +363,7 @@ def _normalize_color_identity(value: Any) -> tuple[str, ...]: def _normalize_string_sequence(value: Any) -> tuple[str, ...]: if value is None: return tuple() - # Handle numpy arrays, lists, tuples, sets, and other sequences - try: - import numpy as np - is_numpy = isinstance(value, np.ndarray) - except ImportError: - is_numpy = False - - if isinstance(value, (list, tuple, set)) or is_numpy: + if isinstance(value, (list, tuple, set)): items = list(value) else: text = _safe_str(value) diff --git a/code/deck_builder/phases/phase0_core.py b/code/deck_builder/phases/phase0_core.py index a23f96c..d464204 100644 --- a/code/deck_builder/phases/phase0_core.py +++ b/code/deck_builder/phases/phase0_core.py @@ -25,11 +25,11 @@ No behavior change intended. # Attempt to use a fast fuzzy library; fall back gracefully try: - from rapidfuzz import process as rf_process, fuzz as rf_fuzz + from rapidfuzz import process as rf_process, fuzz as rf_fuzz # type: ignore _FUZZ_BACKEND = "rapidfuzz" except ImportError: # pragma: no cover - environment dependent try: - from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz + from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz # type: ignore _FUZZ_BACKEND = "fuzzywuzzy" except ImportError: # pragma: no cover _FUZZ_BACKEND = "difflib" diff --git a/code/deck_builder/phases/phase1_commander.py b/code/deck_builder/phases/phase1_commander.py index 6cdead5..2db8b9f 100644 --- a/code/deck_builder/phases/phase1_commander.py +++ b/code/deck_builder/phases/phase1_commander.py @@ -68,7 +68,7 @@ class CommanderSelectionMixin: out_words[0] = out_words[0][:1].upper() + out_words[0][1:] return ' '.join(out_words) - def choose_commander(self) -> str: + def choose_commander(self) -> str: # type: ignore[override] df = self.load_commander_data() names = df["name"].tolist() while True: @@ -113,7 +113,7 @@ class CommanderSelectionMixin: continue query = self._normalize_commander_query(choice) # treat as new (normalized) query - def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: + def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: # type: ignore[override] row = df[df["name"] == name].iloc[0] pretty = self._format_commander_pretty(row) self.output_func("\n" + pretty) @@ -126,17 +126,16 @@ class CommanderSelectionMixin: return False self.output_func("Please enter y or n.") - def _apply_commander_selection(self, row: pd.Series): + def _apply_commander_selection(self, row: pd.Series): # type: ignore[override] self.commander_name = row["name"] self.commander_row = row - tags_value = row.get("themeTags", []) - self.commander_tags = list(tags_value) if tags_value is not None else [] + self.commander_tags = list(row.get("themeTags", []) or []) self._initialize_commander_dict(row) # --------------------------- # Tag Prioritization # --------------------------- - def select_commander_tags(self) -> List[str]: + def select_commander_tags(self) -> List[str]: # type: ignore[override] if not self.commander_name: self.output_func("No commander chosen yet. Selecting commander first...") self.choose_commander() @@ -173,7 +172,7 @@ class CommanderSelectionMixin: self._update_commander_dict_with_selected_tags() return self.selected_tags - def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: + def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: # type: ignore[override] while True: self.output_func("\nCurrent options:") for i, t in enumerate(available, 1): @@ -192,7 +191,7 @@ class CommanderSelectionMixin: return matches[0] self.output_func("Invalid selection. Try again.") - def _update_commander_dict_with_selected_tags(self): + def _update_commander_dict_with_selected_tags(self): # type: ignore[override] if not self.commander_dict and self.commander_row is not None: self._initialize_commander_dict(self.commander_row) if not self.commander_dict: @@ -205,7 +204,7 @@ class CommanderSelectionMixin: # --------------------------- # Power Bracket Selection # --------------------------- - def select_power_bracket(self) -> BracketDefinition: + def select_power_bracket(self) -> BracketDefinition: # type: ignore[override] if self.bracket_definition: return self.bracket_definition self.output_func("\nChoose Deck Power Bracket:") @@ -229,14 +228,14 @@ class CommanderSelectionMixin: return match self.output_func("Invalid input. Type 1-5 or 'info'.") - def _print_bracket_details(self): + def _print_bracket_details(self): # type: ignore[override] self.output_func("\nBracket Details:") for bd in BRACKET_DEFINITIONS: self.output_func(f"\n[{bd.level}] {bd.name}") self.output_func(bd.long_desc) self.output_func(self._format_limits(bd.limits)) - def _print_selected_bracket_summary(self): + def _print_selected_bracket_summary(self): # type: ignore[override] self.output_func("\nBracket Constraints:") if self.bracket_limits: self.output_func(self._format_limits(self.bracket_limits)) diff --git a/code/deck_builder/phases/phase2_lands_basics.py b/code/deck_builder/phases/phase2_lands_basics.py index 36b1586..5f9788a 100644 --- a/code/deck_builder/phases/phase2_lands_basics.py +++ b/code/deck_builder/phases/phase2_lands_basics.py @@ -22,7 +22,7 @@ Expected attributes / methods on the host DeckBuilder: class LandBasicsMixin: - def add_basic_lands(self): + def add_basic_lands(self): # type: ignore[override] """Add basic (or snow basic) lands based on color identity. Logic: @@ -71,23 +71,20 @@ class LandBasicsMixin: basic_min: Optional[int] = None land_total: Optional[int] = None if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - basic_min = self.ideal_counts.get('basic_lands') - land_total = self.ideal_counts.get('lands') + basic_min = self.ideal_counts.get('basic_lands') # type: ignore[attr-defined] + land_total = self.ideal_counts.get('lands') # type: ignore[attr-defined] if basic_min is None: basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if land_total is None: land_total = getattr(bc, 'DEFAULT_LAND_COUNT', 35) # Target basics = 1.3 * minimum (rounded) but not exceeding total lands - # target_basics = int(round(1.3 * basic_min)) - # if target_basics > land_total: - # target_basics = land_total - # if target_basics <= 0: - # self.output_func("Target basic land count is zero; skipping basics.") - # return - - # Changing code to use minimum basics as target for simplicity - target_basics = basic_min + target_basics = int(round(1.3 * basic_min)) + if target_basics > land_total: + target_basics = land_total + if target_basics <= 0: + self.output_func("Target basic land count is zero; skipping basics.") + return colors = [c for c in getattr(self, 'color_identity', []) if c in ['W', 'U', 'B', 'R', 'G']] if not colors: # colorless special case -> Wastes only @@ -136,7 +133,7 @@ class LandBasicsMixin: self.output_func(f" {name.ljust(width)} : {cnt}") self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})") - def run_land_step1(self): + def run_land_step1(self): # type: ignore[override] """Public wrapper to execute land building step 1 (basics).""" self.add_basic_lands() try: diff --git a/code/deck_builder/phases/phase2_lands_duals.py b/code/deck_builder/phases/phase2_lands_duals.py index 713c1f4..7db15f2 100644 --- a/code/deck_builder/phases/phase2_lands_duals.py +++ b/code/deck_builder/phases/phase2_lands_duals.py @@ -21,7 +21,7 @@ Host DeckBuilder must provide: """ class LandDualsMixin: - def add_dual_lands(self, requested_count: int | None = None): + def add_dual_lands(self, requested_count: int | None = None): # type: ignore[override] """Add two-color 'typed' dual lands based on color identity.""" if not getattr(self, 'files_to_load', []): try: @@ -117,10 +117,10 @@ class LandDualsMixin: pair_buckets[key] = names min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if getattr(self, 'ideal_counts', None): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) - basic_floor = self._basic_floor(min_basic_cfg) + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] + basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6) - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] effective_default = min(default_dual_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool)) desired = effective_default if requested_count is None else max(0, int(requested_count)) if desired == 0: @@ -129,14 +129,14 @@ class LandDualsMixin: if remaining_capacity == 0 and desired > 0: slots_needed = desired freed_slots = 0 - while freed_slots < slots_needed and self._count_basic_lands() > basic_floor: - target_basic = self._choose_basic_to_trim() - if not target_basic or not self._decrement_card(target_basic): + while freed_slots < slots_needed and self._count_basic_lands() > basic_floor: # type: ignore[attr-defined] + target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] + if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined] break freed_slots += 1 if freed_slots == 0: desired = 0 - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] desired = min(desired, remaining_capacity, len(pool)) if desired <= 0: self.output_func("Dual Lands: No capacity after trimming; skipping.") @@ -146,7 +146,7 @@ class LandDualsMixin: rng = getattr(self, 'rng', None) try: if rng: - rng.shuffle(bucket_keys) + rng.shuffle(bucket_keys) # type: ignore else: random.shuffle(bucket_keys) except Exception: @@ -171,7 +171,7 @@ class LandDualsMixin: break added: List[str] = [] for name in chosen: - if self._current_land_count() >= land_target: + if self._current_land_count() >= land_target: # type: ignore[attr-defined] break # Determine sub_role as concatenated color pair for traceability try: @@ -198,7 +198,7 @@ class LandDualsMixin: role='dual', sub_role=sub_role, added_by='lands_step5' - ) + ) # type: ignore[attr-defined] added.append(name) self.output_func("\nDual Lands Added (Step 5):") if not added: @@ -207,11 +207,11 @@ class LandDualsMixin: width = max(len(n) for n in added) for n in added: self.output_func(f" {n.ljust(width)} : 1") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step5(self, requested_count: int | None = None): + 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)") + 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') diff --git a/code/deck_builder/phases/phase2_lands_fetch.py b/code/deck_builder/phases/phase2_lands_fetch.py index 4dcf54b..57de480 100644 --- a/code/deck_builder/phases/phase2_lands_fetch.py +++ b/code/deck_builder/phases/phase2_lands_fetch.py @@ -19,7 +19,7 @@ Host DeckBuilder must supply: """ class LandFetchMixin: - def add_fetch_lands(self, requested_count: int | None = None): + def add_fetch_lands(self, requested_count: int | None = None): # type: ignore[override] """Add fetch lands (color-specific + generic) respecting land target.""" if not getattr(self, 'files_to_load', []): try: @@ -28,8 +28,8 @@ class LandFetchMixin: except Exception as e: # pragma: no cover - defensive self.output_func(f"Cannot add fetch lands until color identity resolved: {e}") return - land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) - current = self._current_land_count() + land_target = (getattr(self, 'ideal_counts', {}).get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) # type: ignore[attr-defined] + current = self._current_land_count() # type: ignore[attr-defined] color_order = [c for c in getattr(self, 'color_identity', []) if c in ['W','U','B','R','G']] color_map = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) candidates: List[str] = [] @@ -56,7 +56,7 @@ class LandFetchMixin: self.output_func("\nAdd Fetch Lands (Step 4):") self.output_func("Fetch lands help fix colors & enable landfall / graveyard synergies.") prompt = f"Enter desired number of fetch lands (default: {effective_default}):" - desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) + desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) # type: ignore[attr-defined] else: desired = max(0, int(requested_count)) if desired > remaining_fetch_slots: @@ -70,20 +70,20 @@ class LandFetchMixin: if remaining_capacity == 0 and desired > 0: min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if getattr(self, 'ideal_counts', None): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) - floor_basics = self._basic_floor(min_basic_cfg) + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] + floor_basics = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] slots_needed = desired - while slots_needed > 0 and self._count_basic_lands() > floor_basics: - target_basic = self._choose_basic_to_trim() - if not target_basic or not self._decrement_card(target_basic): + while slots_needed > 0 and self._count_basic_lands() > floor_basics: # type: ignore[attr-defined] + target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] + if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined] break slots_needed -= 1 - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] if remaining_capacity > 0 and slots_needed == 0: break if slots_needed > 0 and remaining_capacity == 0: desired -= slots_needed - remaining_capacity = max(0, land_target - self._current_land_count()) + remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined] desired = min(desired, remaining_capacity, len(candidates), remaining_fetch_slots) if desired <= 0: self.output_func("Fetch Lands: No capacity (after trimming) or desired reduced to 0; skipping.") @@ -101,7 +101,7 @@ class LandFetchMixin: if k >= len(pool): return pool.copy() try: - return (rng.sample if rng else random.sample)(pool, k) + return (rng.sample if rng else random.sample)(pool, k) # type: ignore except Exception: return pool[:k] need = desired @@ -117,7 +117,7 @@ class LandFetchMixin: added: List[str] = [] for nm in chosen: - if self._current_land_count() >= land_target: + if self._current_land_count() >= land_target: # type: ignore[attr-defined] break note = 'generic' if nm in generic_list else 'color-specific' self.add_card( @@ -126,11 +126,11 @@ class LandFetchMixin: role='fetch', sub_role=note, added_by='lands_step4' - ) + ) # type: ignore[attr-defined] added.append(nm) # Record actual number of fetch lands added for export/replay context try: - setattr(self, 'fetch_count', len(added)) + setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined] except Exception: pass self.output_func("\nFetch Lands Added (Step 4):") @@ -141,9 +141,9 @@ class LandFetchMixin: for n in added: note = 'generic' if n in generic_list else 'color-specific' self.output_func(f" {n.ljust(width)} : 1 ({note})") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step4(self, requested_count: int | None = None): + def run_land_step4(self, requested_count: int | None = None): # type: ignore[override] """Public wrapper to add fetch lands. If ideal_counts['fetch_lands'] is set, it will be used to bypass the prompt in both CLI and web builds. @@ -155,7 +155,7 @@ class LandFetchMixin: except Exception: desired = requested_count self.add_fetch_lands(requested_count=desired) - self._enforce_land_cap(step_label="Fetch (Step 4)") + 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') diff --git a/code/deck_builder/phases/phase2_lands_kindred.py b/code/deck_builder/phases/phase2_lands_kindred.py index 2b361c7..bca1827 100644 --- a/code/deck_builder/phases/phase2_lands_kindred.py +++ b/code/deck_builder/phases/phase2_lands_kindred.py @@ -20,7 +20,7 @@ Host DeckBuilder must provide: """ class LandKindredMixin: - def add_kindred_lands(self): + def add_kindred_lands(self): # type: ignore[override] """Add kindred-oriented lands ONLY if a selected tag includes 'Kindred' or 'Tribal'. Baseline inclusions on kindred focus: @@ -41,32 +41,32 @@ class LandKindredMixin: self.output_func("Kindred Lands: No selected kindred/tribal tag; skipping.") return if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) + land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) # type: ignore[attr-defined] else: land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) - basic_floor = self._basic_floor(min_basic_cfg) + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] + basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] def ensure_capacity() -> bool: - if self._current_land_count() < land_target: + if self._current_land_count() < land_target: # type: ignore[attr-defined] return True - if self._count_basic_lands() <= basic_floor: + if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] return False - target_basic = self._choose_basic_to_trim() + target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] if not target_basic: return False - if not self._decrement_card(target_basic): + if not self._decrement_card(target_basic): # type: ignore[attr-defined] return False - return self._current_land_count() < land_target + return self._current_land_count() < land_target # type: ignore[attr-defined] colors = getattr(self, 'color_identity', []) or [] added: List[str] = [] reasons: Dict[str, str] = {} def try_add(name: str, reason: str): - if name in self.card_library: + if name in self.card_library: # type: ignore[attr-defined] return if not ensure_capacity(): return @@ -77,7 +77,7 @@ class LandKindredMixin: sub_role='baseline' if reason.startswith('kindred focus') else 'tribe-specific', added_by='lands_step3', trigger_tag='Kindred/Tribal' - ) + ) # type: ignore[attr-defined] added.append(name) reasons[name] = reason @@ -105,14 +105,14 @@ class LandKindredMixin: if snapshot is not None and not snapshot.empty and tribe_terms: dynamic_limit = 5 for tribe in sorted(tribe_terms): - if self._current_land_count() >= land_target or dynamic_limit <= 0: + if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] break tribe_lower = tribe.lower() matches: List[str] = [] for _, row in snapshot.iterrows(): try: nm = str(row.get('name', '')) - if not nm or nm in self.card_library: + if not nm or nm in self.card_library: # type: ignore[attr-defined] continue tline = str(row.get('type', row.get('type_line', ''))).lower() if 'land' not in tline: @@ -125,7 +125,7 @@ class LandKindredMixin: except Exception: continue for nm in matches[:2]: - if self._current_land_count() >= land_target or dynamic_limit <= 0: + if self._current_land_count() >= land_target or dynamic_limit <= 0: # type: ignore[attr-defined] break if nm in added or nm in getattr(bc, 'BASIC_LANDS', []): continue @@ -139,12 +139,12 @@ class LandKindredMixin: width = max(len(n) for n in added) for n in added: self.output_func(f" {n.ljust(width)} : 1 ({reasons.get(n,'')})") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step3(self): + def run_land_step3(self): # type: ignore[override] """Public wrapper to add kindred-focused lands.""" self.add_kindred_lands() - self._enforce_land_cap(step_label="Kindred (Step 3)") + 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') diff --git a/code/deck_builder/phases/phase2_lands_misc.py b/code/deck_builder/phases/phase2_lands_misc.py index 4d0cbef..f4cf589 100644 --- a/code/deck_builder/phases/phase2_lands_misc.py +++ b/code/deck_builder/phases/phase2_lands_misc.py @@ -19,7 +19,7 @@ class LandMiscUtilityMixin: - Diagnostics & CSV exports """ - def add_misc_utility_lands(self, requested_count: Optional[int] = None): + 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: @@ -44,14 +44,8 @@ class LandMiscUtilityMixin: return basics = self._basic_land_names() already = set(self.card_library.keys()) - top_n = getattr(bc, 'MISC_LAND_TOP_POOL_SIZE', 60) + top_n = getattr(bc, 'MISC_LAND_TOP_POOL_SIZE', 30) use_full = getattr(bc, 'MISC_LAND_USE_FULL_POOL', False) - if not use_full: - rng = getattr(self, 'rng', None) - pool_multiplier = rng.uniform(1.2, 2.0) if rng else 1.5 - top_n = int(top_n * pool_multiplier) - if getattr(self, 'show_diagnostics', False): - self.output_func(f"[Diagnostics] Misc Step pool size multiplier: {pool_multiplier:.2f}x (base={getattr(bc, 'MISC_LAND_TOP_POOL_SIZE', 60)} → effective={top_n})") 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 @@ -293,7 +287,7 @@ class LandMiscUtilityMixin: 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): + 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() @@ -305,12 +299,12 @@ class LandMiscUtilityMixin: pass # ---- Tag-driven suggestion helpers (used after Step 7) ---- - def _build_tag_driven_land_suggestions(self): + def _build_tag_driven_land_suggestions(self): # type: ignore[override] suggestions = bu.build_tag_driven_suggestions(self) if suggestions: self.suggested_lands_queue.extend(suggestions) - def _apply_land_suggestions_if_room(self): + def _apply_land_suggestions_if_room(self): # type: ignore[override] if not self.suggested_lands_queue: 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) diff --git a/code/deck_builder/phases/phase2_lands_optimize.py b/code/deck_builder/phases/phase2_lands_optimize.py index 9c32129..c74d411 100644 --- a/code/deck_builder/phases/phase2_lands_optimize.py +++ b/code/deck_builder/phases/phase2_lands_optimize.py @@ -12,7 +12,7 @@ class LandOptimizationMixin: Provides optimize_tapped_lands and run_land_step8 (moved from monolithic builder). """ - def optimize_tapped_lands(self): + def optimize_tapped_lands(self): # type: ignore[override] df = getattr(self, '_combined_cards_df', None) if df is None or df.empty: return @@ -146,7 +146,7 @@ class LandOptimizationMixin: new_tapped += 1 self.output_func(f" Tapped Lands After : {new_tapped} (threshold {threshold})") - def run_land_step8(self): + def run_land_step8(self): # type: ignore[override] self.optimize_tapped_lands() self._enforce_land_cap(step_label="Tapped Opt (Step 8)") if self.color_source_matrix_baseline is None: diff --git a/code/deck_builder/phases/phase2_lands_staples.py b/code/deck_builder/phases/phase2_lands_staples.py index 159319c..8d2e21c 100644 --- a/code/deck_builder/phases/phase2_lands_staples.py +++ b/code/deck_builder/phases/phase2_lands_staples.py @@ -27,10 +27,10 @@ class LandStaplesMixin: # --------------------------- # Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet) # --------------------------- - def _current_land_count(self) -> int: + def _current_land_count(self) -> int: # type: ignore[override] """Return total number of land cards currently in the library (counts duplicates).""" total = 0 - for name, entry in self.card_library.items(): + for name, entry in self.card_library.items(): # type: ignore[attr-defined] ctype = entry.get('Card Type', '') if ctype and 'land' in ctype.lower(): total += entry.get('Count', 1) @@ -47,7 +47,7 @@ class LandStaplesMixin: continue return total - def add_staple_lands(self): + def add_staple_lands(self): # type: ignore[override] """Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands). Respects total land target (ideal_counts['lands']). Skips additions once target reached. @@ -62,25 +62,25 @@ class LandStaplesMixin: return land_target = None if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - land_target = self.ideal_counts.get('lands') + land_target = self.ideal_counts.get('lands') # type: ignore[attr-defined] if land_target is None: land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) if hasattr(self, 'ideal_counts') and getattr(self, 'ideal_counts'): - min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) - basic_floor = self._basic_floor(min_basic_cfg) + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) # type: ignore[attr-defined] + basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined] def ensure_capacity() -> bool: - if self._current_land_count() < land_target: + if self._current_land_count() < land_target: # type: ignore[attr-defined] return True - if self._count_basic_lands() <= basic_floor: + if self._count_basic_lands() <= basic_floor: # type: ignore[attr-defined] return False - target_basic = self._choose_basic_to_trim() + target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined] if not target_basic: return False - if not self._decrement_card(target_basic): + if not self._decrement_card(target_basic): # type: ignore[attr-defined] return False - return self._current_land_count() < land_target + return self._current_land_count() < land_target # type: ignore[attr-defined] commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or []) colors = getattr(self, 'color_identity', []) or [] @@ -102,7 +102,7 @@ class LandStaplesMixin: if not ensure_capacity(): self.output_func("Staple Lands: Cannot free capacity without violating basic floor; stopping additions.") break - if land_name in self.card_library: + if land_name in self.card_library: # type: ignore[attr-defined] continue try: include = cond(list(commander_tags_all), colors, commander_power) @@ -115,7 +115,7 @@ class LandStaplesMixin: role='staple', sub_role='generic-staple', added_by='lands_step2' - ) + ) # type: ignore[attr-defined] added.append(land_name) if land_name == 'Command Tower': reasons[land_name] = f"multi-color ({len(colors)} colors)" @@ -137,12 +137,12 @@ class LandStaplesMixin: for n in added: reason = reasons.get(n, '') self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}") - self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") # type: ignore[attr-defined] - def run_land_step2(self): + def run_land_step2(self): # type: ignore[override] """Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" self.add_staple_lands() - self._enforce_land_cap(step_label="Staples (Step 2)") + 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') diff --git a/code/deck_builder/phases/phase2_lands_triples.py b/code/deck_builder/phases/phase2_lands_triples.py index 8c86bbc..97fbcd5 100644 --- a/code/deck_builder/phases/phase2_lands_triples.py +++ b/code/deck_builder/phases/phase2_lands_triples.py @@ -59,7 +59,7 @@ class LandTripleMixin: 'forest': 'G', } - for _, row in df.iterrows(): + for _, row in df.iterrows(): # type: ignore try: name = str(row.get('name','')) if not name or name in self.card_library: diff --git a/code/deck_builder/phases/phase3_creatures.py b/code/deck_builder/phases/phase3_creatures.py index e10b02c..bbf5f60 100644 --- a/code/deck_builder/phases/phase3_creatures.py +++ b/code/deck_builder/phases/phase3_creatures.py @@ -33,7 +33,7 @@ class CreatureAdditionMixin: self.output_func("Card pool missing 'type' column; cannot add creatures.") return try: - context = self.get_theme_context() + context = self.get_theme_context() # type: ignore[attr-defined] except Exception: context = None if context is None or not getattr(context, 'ordered_targets', []): @@ -120,7 +120,7 @@ class CreatureAdditionMixin: mana_cost=row.get('manaCost',''), mana_value=row.get('manaValue', row.get('cmc','')), creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [], - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='creature', sub_role='all_theme', added_by='creature_all_theme', @@ -231,7 +231,7 @@ class CreatureAdditionMixin: mana_cost=row.get('manaCost',''), mana_value=row.get('manaValue', row.get('cmc','')), creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [], - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='creature', sub_role=role, added_by='creature_add', @@ -288,7 +288,7 @@ class CreatureAdditionMixin: mana_cost=row.get('manaCost',''), mana_value=row.get('manaValue', row.get('cmc','')), creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [], - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='creature', sub_role='fill', added_by='creature_fill', @@ -480,7 +480,7 @@ class CreatureAdditionMixin: drop_idx = tags_series.apply(lambda lst, nd=needles: any(any(n in t for n in nd) for t in lst)) mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] try: - import pandas as _pd + import pandas as _pd # type: ignore mask_keep = _pd.Series(mask_keep, index=df.index) except Exception: pass @@ -551,7 +551,7 @@ class CreatureAdditionMixin: mana_cost=row.get('manaCost',''), mana_value=row.get('manaValue', row.get('cmc','')), creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [], - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='creature', sub_role=role, added_by='creature_add', @@ -590,7 +590,7 @@ class CreatureAdditionMixin: mana_cost=row.get('manaCost',''), mana_value=row.get('manaValue', row.get('cmc','')), creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [], - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='creature', sub_role='fill', added_by='creature_fill', @@ -672,7 +672,7 @@ class CreatureAdditionMixin: mana_cost=row.get('manaCost',''), mana_value=row.get('manaValue', row.get('cmc','')), creature_types=row.get('creatureTypes', []) if isinstance(row.get('creatureTypes', []), list) else [], - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='creature', sub_role='all_theme', added_by='creature_all_theme', diff --git a/code/deck_builder/phases/phase4_spells.py b/code/deck_builder/phases/phase4_spells.py index a0a0f90..3ec39fb 100644 --- a/code/deck_builder/phases/phase4_spells.py +++ b/code/deck_builder/phases/phase4_spells.py @@ -78,7 +78,7 @@ class SpellAdditionMixin: # Combine into keep mask mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] try: - import pandas as _pd + import pandas as _pd # type: ignore mask_keep = _pd.Series(mask_keep, index=df.index) except Exception: pass @@ -193,7 +193,7 @@ class SpellAdditionMixin: card_type=r.get('type',''), mana_cost=r.get('manaCost',''), mana_value=r.get('manaValue', r.get('cmc','')), - tags=bu.ensure_theme_tags_list(r.get('themeTags')), + tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [], role='ramp', sub_role=phase_name.lower(), added_by='spell_ramp' @@ -322,7 +322,7 @@ class SpellAdditionMixin: card_type=r.get('type',''), mana_cost=r.get('manaCost',''), mana_value=r.get('manaValue', r.get('cmc','')), - tags=bu.ensure_theme_tags_list(r.get('themeTags')), + tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [], role='removal', sub_role='spot', added_by='spell_removal' @@ -399,7 +399,7 @@ class SpellAdditionMixin: card_type=r.get('type',''), mana_cost=r.get('manaCost',''), mana_value=r.get('manaValue', r.get('cmc','')), - tags=bu.ensure_theme_tags_list(r.get('themeTags')), + tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [], role='wipe', sub_role='board', added_by='spell_wipe' @@ -493,7 +493,7 @@ class SpellAdditionMixin: card_type=r.get('type',''), mana_cost=r.get('manaCost',''), mana_value=r.get('manaValue', r.get('cmc','')), - tags=bu.ensure_theme_tags_list(r.get('themeTags')), + tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [], role='card_advantage', sub_role='conditional', added_by='spell_draw' @@ -516,7 +516,7 @@ class SpellAdditionMixin: card_type=r.get('type',''), mana_cost=r.get('manaCost',''), mana_value=r.get('manaValue', r.get('cmc','')), - tags=bu.ensure_theme_tags_list(r.get('themeTags')), + tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [], role='card_advantage', sub_role='unconditional', added_by='spell_draw' @@ -713,7 +713,7 @@ class SpellAdditionMixin: card_type=r.get('type',''), mana_cost=r.get('manaCost',''), mana_value=r.get('manaValue', r.get('cmc','')), - tags=bu.ensure_theme_tags_list(r.get('themeTags')), + tags=r.get('themeTags', []) if isinstance(r.get('themeTags', []), list) else [], role='protection', added_by='spell_protection' ) @@ -742,7 +742,7 @@ class SpellAdditionMixin: if df is None or df.empty or 'type' not in df.columns: return try: - context = self.get_theme_context() + context = self.get_theme_context() # type: ignore[attr-defined] except Exception: context = None if context is None or not getattr(context, 'ordered_targets', []): @@ -879,7 +879,7 @@ class SpellAdditionMixin: card_type=row.get('type', ''), mana_cost=row.get('manaCost', ''), mana_value=row.get('manaValue', row.get('cmc', '')), - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='theme_spell', sub_role=role, added_by='spell_theme_fill', @@ -942,7 +942,7 @@ class SpellAdditionMixin: card_type=row.get('type', ''), mana_cost=row.get('manaCost', ''), mana_value=row.get('manaValue', row.get('cmc', '')), - tags=bu.ensure_theme_tags_list(row.get('themeTags')), + tags=row.get('themeTags', []) if isinstance(row.get('themeTags', []), list) else [], role='theme_spell', sub_role='fill_multi', added_by='spell_theme_fill', @@ -1006,7 +1006,7 @@ class SpellAdditionMixin: card_type=r0.get('type',''), mana_cost=r0.get('manaCost',''), mana_value=r0.get('manaValue', r0.get('cmc','')), - tags=bu.ensure_theme_tags_list(r0.get('themeTags')), + tags=r0.get('themeTags', []) if isinstance(r0.get('themeTags', []), list) else [], role='filler', sub_role=r0.get('_fillerCat',''), added_by='spell_general_filler' @@ -1058,4 +1058,4 @@ class SpellAdditionMixin: """ """Public method for orchestration: delegates to add_non_creature_spells.""" return self.add_non_creature_spells() - + \ No newline at end of file diff --git a/code/deck_builder/phases/phase5_color_balance.py b/code/deck_builder/phases/phase5_color_balance.py index d8c7db6..bbb2085 100644 --- a/code/deck_builder/phases/phase5_color_balance.py +++ b/code/deck_builder/phases/phase5_color_balance.py @@ -159,8 +159,7 @@ class ColorBalanceMixin: self.output_func(" (No viable swaps executed.)") # Always consider basic-land rebalance when requested - # M5: Skip rebalance for colorless commanders (they should have only Wastes) - if rebalance_basics and self.color_identity: # Only rebalance if commander has colors + if rebalance_basics: try: basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {}) basics_present = {nm: entry for nm, entry in self.card_library.items() if nm in basic_map.values()} diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 3044736..b71fcc0 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -7,14 +7,14 @@ import datetime as _dt import re as _re import logging_util -from ..summary_telemetry import record_land_summary, record_theme_summary, record_partner_summary -from ..color_identity_utils import normalize_colors, canon_color_code, color_label_from_code -from ..shared_copy import build_land_headline, dfc_card_note +from code.deck_builder.summary_telemetry import record_land_summary, record_theme_summary, record_partner_summary +from code.deck_builder.color_identity_utils import normalize_colors, canon_color_code, color_label_from_code +from code.deck_builder.shared_copy import build_land_headline, dfc_card_note logger = logging_util.logging.getLogger(__name__) try: - from prettytable import PrettyTable + from prettytable import PrettyTable # type: ignore except Exception: # pragma: no cover PrettyTable = None # type: ignore @@ -176,7 +176,7 @@ class ReportingMixin: """ try: # Lazy import to avoid cycles - from deck_builder.enforcement import enforce_bracket_compliance + from deck_builder.enforcement import enforce_bracket_compliance # type: ignore except Exception: self.output_func("Enforcement module unavailable.") return {} @@ -194,7 +194,7 @@ class ReportingMixin: if int(total_cards) < 100 and hasattr(self, 'fill_remaining_theme_spells'): before = int(total_cards) try: - self.fill_remaining_theme_spells() + self.fill_remaining_theme_spells() # type: ignore[attr-defined] except Exception: pass # Recompute after filler @@ -239,13 +239,13 @@ class ReportingMixin: csv_name = base_stem + ".csv" txt_name = base_stem + ".txt" # Overwrite exports with updated library - self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) - self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) + self.export_decklist_csv(directory='deck_files', filename=csv_name, suppress_output=True) # type: ignore[attr-defined] + self.export_decklist_text(directory='deck_files', filename=txt_name, suppress_output=True) # type: ignore[attr-defined] # Re-export the JSON config to reflect any changes from enforcement json_name = base_stem + ".json" - self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) + self.export_run_config_json(directory='config', filename=json_name, suppress_output=True) # type: ignore[attr-defined] # Recompute and write compliance next to them - self.compute_and_print_compliance(base_stem=base_stem) + self.compute_and_print_compliance(base_stem=base_stem) # type: ignore[attr-defined] # Inject enforcement details into the saved compliance JSON for UI transparency comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") try: @@ -259,18 +259,18 @@ class ReportingMixin: pass else: # Fall back to default export flow - csv_path = self.export_decklist_csv() + csv_path = self.export_decklist_csv() # type: ignore[attr-defined] try: base, _ = _os.path.splitext(csv_path) base_only = _os.path.basename(base) except Exception: base_only = None - self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) + self.export_decklist_text(filename=(base_only + '.txt') if base_only else None) # type: ignore[attr-defined] # Re-export JSON config after enforcement changes if base_only: - self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) + self.export_run_config_json(directory='config', filename=base_only + '.json', suppress_output=True) # type: ignore[attr-defined] if base_only: - self.compute_and_print_compliance(base_stem=base_only) + self.compute_and_print_compliance(base_stem=base_only) # type: ignore[attr-defined] # Inject enforcement into written JSON as above try: comp_path = _os.path.join('deck_files', f"{base_only}_compliance.json") @@ -294,7 +294,7 @@ class ReportingMixin: """ try: # Late import to avoid circulars in some environments - from deck_builder.brackets_compliance import evaluate_deck + from deck_builder.brackets_compliance import evaluate_deck # type: ignore except Exception: self.output_func("Bracket compliance module unavailable.") return {} @@ -373,7 +373,7 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, Any] = {} + row_lookup: Dict[str, any] = {} if snapshot is not None and hasattr(snapshot, 'empty') and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) @@ -429,7 +429,7 @@ class ReportingMixin: # Surface land vs. MDFC counts for CLI users to mirror web summary copy try: - summary = self.build_deck_summary() + summary = self.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None if isinstance(summary, dict): @@ -483,9 +483,9 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, Any] = {} + row_lookup: Dict[str, any] = {} if snapshot is not None and not getattr(snapshot, 'empty', True) and 'name' in snapshot.columns: - for _, r in snapshot.iterrows(): + for _, r in snapshot.iterrows(): # type: ignore[attr-defined] nm = str(r.get('name')) if nm and nm not in row_lookup: row_lookup[nm] = r @@ -521,7 +521,7 @@ class ReportingMixin: builder_utils_module = None try: - from deck_builder import builder_utils as _builder_utils + from deck_builder import builder_utils as _builder_utils # type: ignore builder_utils_module = _builder_utils color_matrix = builder_utils_module.compute_color_source_matrix(self.card_library, full_df) except Exception: @@ -543,9 +543,6 @@ class ReportingMixin: mf_info = {} faces_meta = list(mf_info.get('faces', [])) if isinstance(mf_info, dict) else [] layout_val = mf_info.get('layout') if isinstance(mf_info, dict) else None - # M9: If no colors found from mana production, try extracting from face metadata - if not card_colors and isinstance(mf_info, dict): - card_colors = list(mf_info.get('colors', [])) dfc_land_lookup[name] = { 'adds_extra_land': counts_as_extra, 'counts_as_land': not counts_as_extra, @@ -684,14 +681,13 @@ class ReportingMixin: 'faces': faces_meta, 'layout': layout_val, }) - # M9: Count ALL MDFC lands for land summary - dfc_extra_total += copies + if adds_extra: + dfc_extra_total += copies total_sources = sum(source_counts.values()) traditional_lands = type_counts.get('Land', 0) - # M9: dfc_extra_total now contains ALL MDFC lands, not just extras land_summary = { 'traditional': traditional_lands, - 'dfc_lands': dfc_extra_total, # M9: Count of all MDFC lands + 'dfc_lands': dfc_extra_total, 'with_dfc': traditional_lands + dfc_extra_total, 'dfc_cards': dfc_details, 'headline': build_land_headline(traditional_lands, dfc_extra_total, traditional_lands + dfc_extra_total), @@ -856,7 +852,7 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, Any] = {} + row_lookup: Dict[str, any] = {} if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) @@ -1128,7 +1124,7 @@ class ReportingMixin: full_df = getattr(self, '_full_cards_df', None) combined_df = getattr(self, '_combined_cards_df', None) snapshot = full_df if full_df is not None else combined_df - row_lookup: Dict[str, Any] = {} + row_lookup: Dict[str, any] = {} if snapshot is not None and not snapshot.empty and 'name' in snapshot.columns: for _, r in snapshot.iterrows(): nm = str(r.get('name')) @@ -1136,7 +1132,7 @@ class ReportingMixin: row_lookup[nm] = r try: - from deck_builder import builder_utils as _builder_utils + from deck_builder import builder_utils as _builder_utils # type: ignore color_matrix = _builder_utils.compute_color_source_matrix(self.card_library, full_df) except Exception: color_matrix = {} @@ -1387,4 +1383,3 @@ class ReportingMixin: """ # Card library printout suppressed; use CSV and text export for card list. pass - diff --git a/code/deck_builder/random_entrypoint.py b/code/deck_builder/random_entrypoint.py index 8b00d40..7030488 100644 --- a/code/deck_builder/random_entrypoint.py +++ b/code/deck_builder/random_entrypoint.py @@ -425,20 +425,12 @@ class RandomBuildResult: def _load_commanders_df() -> pd.DataFrame: - """Load commanders from Parquet using isCommander boolean flag. + """Load commander CSV using the same path/converters as the builder. - M4: Migrated from CSV to Parquet loading with boolean filtering. + Uses bc.COMMANDER_CSV_PATH and bc.COMMANDER_CONVERTERS for consistency. """ - from . import builder_utils as bu - - # Load all cards from Parquet - df = bu._load_all_cards_parquet() - if df.empty: - return pd.DataFrame() - - # Filter to commanders using boolean flag - commanders_df = bc.get_commanders(df) - return _ensure_theme_tag_cache(commanders_df) + df = pd.read_csv(bc.COMMANDER_CSV_PATH, converters=getattr(bc, "COMMANDER_CONVERTERS", None)) + return _ensure_theme_tag_cache(df) def _ensure_theme_tag_cache(df: pd.DataFrame) -> pd.DataFrame: @@ -885,7 +877,7 @@ def _filter_multi(df: pd.DataFrame, primary: Optional[str], secondary: Optional[ if index_map is None: _ensure_theme_tag_index(current_df) index_map = current_df.attrs.get("_ltag_index") or {} - return index_map + return index_map # type: ignore[return-value] index_map_all = _get_index_map(df) @@ -1047,7 +1039,7 @@ def _check_constraints(candidate_count: int, constraints: Optional[Dict[str, Any if not constraints: return try: - req_min = constraints.get("require_min_candidates") + req_min = constraints.get("require_min_candidates") # type: ignore[attr-defined] except Exception: req_min = None if req_min is None: @@ -1436,7 +1428,7 @@ def build_random_full_deck( primary_choice_idx, secondary_choice_idx, tertiary_choice_idx = _resolve_theme_choices_for_headless(base.commander, base) try: - from headless_runner import run as _run + from headless_runner import run as _run # type: ignore except Exception as e: return RandomFullBuildResult( seed=base.seed, @@ -1482,7 +1474,7 @@ def build_random_full_deck( summary: Dict[str, Any] | None = None try: if hasattr(builder, 'build_deck_summary'): - summary = builder.build_deck_summary() + summary = builder.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None @@ -1559,7 +1551,7 @@ def build_random_full_deck( if isinstance(custom_base, str) and custom_base.strip(): meta_payload["name"] = custom_base.strip() try: - commander_meta = builder.get_commander_export_metadata() + commander_meta = builder.get_commander_export_metadata() # type: ignore[attr-defined] except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -1589,8 +1581,8 @@ def build_random_full_deck( try: import os as _os import json as _json - csv_path = getattr(builder, 'last_csv_path', None) - txt_path = getattr(builder, 'last_txt_path', None) + csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] + txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] if csv_path and isinstance(csv_path, str): base_path, _ = _os.path.splitext(csv_path) # If txt missing but expected, look for sibling @@ -1608,7 +1600,7 @@ def build_random_full_deck( # Compute compliance if not already saved try: if hasattr(builder, 'compute_and_print_compliance'): - compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] except Exception: compliance = None # Write summary sidecar if missing @@ -1646,7 +1638,7 @@ def build_random_full_deck( csv_path = existing_base base_path, _ = _os.path.splitext(csv_path) else: - tmp_csv = builder.export_decklist_csv() + tmp_csv = builder.export_decklist_csv() # type: ignore[attr-defined] stem_base, ext = _os.path.splitext(tmp_csv) if stem_base.endswith('_1'): original = stem_base[:-2] + ext @@ -1662,13 +1654,13 @@ def build_random_full_deck( if _os.path.isfile(target_txt): txt_path = target_txt else: - tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') + tmp_txt = builder.export_decklist_text(filename=_os.path.basename(base_path) + '.txt') # type: ignore[attr-defined] if tmp_txt.endswith('_1.txt') and _os.path.isfile(target_txt): txt_path = target_txt else: txt_path = tmp_txt if hasattr(builder, 'compute_and_print_compliance'): - compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] if summary: sidecar = base_path + '.summary.json' if not _os.path.isfile(sidecar): diff --git a/code/deck_builder/summary_telemetry.py b/code/deck_builder/summary_telemetry.py index 3bd38a3..6afa02c 100644 --- a/code/deck_builder/summary_telemetry.py +++ b/code/deck_builder/summary_telemetry.py @@ -167,7 +167,7 @@ def _reset_metrics_for_test() -> None: def _sanitize_theme_list(values: Iterable[Any]) -> list[str]: sanitized: list[str] = [] seen: set[str] = set() - for raw in values or []: + for raw in values or []: # type: ignore[arg-type] text = str(raw or "").strip() if not text: continue diff --git a/code/deck_builder/theme_catalog_loader.py b/code/deck_builder/theme_catalog_loader.py index 7d1214b..cddf9b3 100644 --- a/code/deck_builder/theme_catalog_loader.py +++ b/code/deck_builder/theme_catalog_loader.py @@ -9,9 +9,9 @@ from functools import lru_cache from pathlib import Path from typing import Iterable, Tuple -import logging_util +from code.logging_util import get_logger -LOGGER = logging_util.get_logger(__name__) +LOGGER = get_logger(__name__) ROOT = Path(__file__).resolve().parents[2] DEFAULT_CATALOG_PATH = ROOT / "config" / "themes" / "theme_catalog.csv" @@ -183,7 +183,7 @@ def _iter_json_themes(payload: object) -> Iterable[ThemeCatalogEntry]: try: from type_definitions_theme_catalog import ThemeCatalog # pragma: no cover - primary import path except ImportError: # pragma: no cover - fallback when running as package - from code.type_definitions_theme_catalog import ThemeCatalog + from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore try: catalog = ThemeCatalog.model_validate(payload) diff --git a/code/deck_builder/theme_matcher.py b/code/deck_builder/theme_matcher.py index f45b656..fa92d86 100644 --- a/code/deck_builder/theme_matcher.py +++ b/code/deck_builder/theme_matcher.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from functools import lru_cache from typing import Iterable, List, Sequence -from .theme_catalog_loader import ThemeCatalogEntry +from code.deck_builder.theme_catalog_loader import ThemeCatalogEntry __all__ = [ "normalize_theme", diff --git a/code/file_setup/__init__.py b/code/file_setup/__init__.py index 77a5bc5..a624832 100644 --- a/code/file_setup/__init__.py +++ b/code/file_setup/__init__.py @@ -1,8 +1,8 @@ """Initialize the file_setup package.""" -from .setup import initial_setup, regenerate_processed_parquet +from .setup import setup, regenerate_csv_by_color __all__ = [ - 'initial_setup', - 'regenerate_processed_parquet' + 'setup', + 'regenerate_csv_by_color' ] \ No newline at end of file diff --git a/code/file_setup/card_aggregator.py b/code/file_setup/card_aggregator.py deleted file mode 100644 index 7ced420..0000000 --- a/code/file_setup/card_aggregator.py +++ /dev/null @@ -1,367 +0,0 @@ -""" -Card Data Aggregator - -Consolidates individual card CSV files into a single Parquet file for improved -performance in card browsing, theme cataloging, and searches. - -Key Features: -- Merges all card CSVs into all_cards.parquet (50-70% size reduction, 2-5x faster) -- Excludes master files (cards.csv, commander_cards.csv) from aggregation -- Deduplication logic (keeps most recent when card appears in multiple files) -- Incremental updates (only re-process changed files) -- Version rotation (maintains 2-3 historical versions for rollback) -- Validation (ensures no data loss) - -Usage: - aggregator = CardAggregator() - stats = aggregator.aggregate_all('csv_files', 'card_files/all_cards.parquet') -""" - -from __future__ import annotations - -import glob -import json -import os -from datetime import datetime -from typing import Optional - -import pandas as pd - -from code.logging_util import get_logger - -# Initialize logger -logger = get_logger(__name__) - - -class CardAggregator: - """Aggregates individual card CSV files into a consolidated Parquet file.""" - - # Files to exclude from aggregation (master files used for other purposes) - EXCLUDED_FILES = {"cards.csv", "commander_cards.csv", "background_cards.csv"} - - def __init__(self, output_dir: Optional[str] = None) -> None: - """ - Initialize CardAggregator. - - Args: - output_dir: Directory for output files (defaults to CARD_FILES_DIR env var or 'card_files/') - """ - self.output_dir = output_dir or os.getenv("CARD_FILES_DIR", "card_files") - self.ensure_output_dir() - - def ensure_output_dir(self) -> None: - """Create output directory if it doesn't exist.""" - os.makedirs(self.output_dir, exist_ok=True) - logger.info(f"Card aggregator output directory: {self.output_dir}") - - def get_card_csvs(self, source_dir: str) -> list[str]: - """ - Get all card CSV files to aggregate, excluding master files. - - Args: - source_dir: Directory containing card CSV files - - Returns: - List of file paths to aggregate - """ - all_csvs = glob.glob(os.path.join(source_dir, "*.csv")) - - # Filter out excluded files and temporary files - filtered = [ - f - for f in all_csvs - if os.path.basename(f) not in self.EXCLUDED_FILES - and not os.path.basename(f).startswith(".") - and not os.path.basename(f).startswith("_temp") - ] - - logger.info( - f"Found {len(all_csvs)} CSV files, {len(filtered)} to aggregate " - f"(excluded {len(all_csvs) - len(filtered)})" - ) - - return filtered - - def deduplicate_cards(self, df: pd.DataFrame) -> pd.DataFrame: - """ - Remove duplicate card entries, keeping the most recent version. - - Uses the 'name' column as the unique identifier. When duplicates exist, - keeps the last occurrence (assumes files are processed in order of modification time). - - Args: - df: DataFrame with potential duplicates - - Returns: - DataFrame with duplicates removed - """ - if "name" not in df.columns: - logger.warning("Cannot deduplicate: 'name' column not found") - return df - - original_count = len(df) - df_deduped = df.drop_duplicates(subset=["name"], keep="last") - removed_count = original_count - len(df_deduped) - - if removed_count > 0: - logger.info(f"Removed {removed_count} duplicate cards (kept most recent)") - - return df_deduped - - def aggregate_all(self, source_dir: str, output_path: str) -> dict: - """ - Perform full aggregation of all card CSV files into a single Parquet file. - - Args: - source_dir: Directory containing individual card CSV files - output_path: Path for output Parquet file - - Returns: - Dictionary with aggregation statistics: - - files_processed: Number of CSV files aggregated - - total_cards: Total cards in output (after deduplication) - - duplicates_removed: Number of duplicate cards removed - - file_size_mb: Size of output Parquet file in MB - - elapsed_seconds: Time taken for aggregation - - Raises: - FileNotFoundError: If source_dir doesn't exist - ValueError: If no CSV files found to aggregate - """ - start_time = datetime.now() - - if not os.path.exists(source_dir): - raise FileNotFoundError(f"Source directory not found: {source_dir}") - - # Get CSV files to aggregate - csv_files = self.get_card_csvs(source_dir) - if not csv_files: - raise ValueError(f"No CSV files found to aggregate in {source_dir}") - - logger.info(f"Starting aggregation of {len(csv_files)} files...") - - # Sort by modification time (oldest first, so newest are kept in deduplication) - csv_files_sorted = sorted(csv_files, key=lambda f: os.path.getmtime(f)) - - # Read and concatenate all CSV files - dfs = [] - for csv_file in csv_files_sorted: - try: - # Skip comment lines (lines starting with #) in CSV files - df = pd.read_csv(csv_file, low_memory=False, comment='#') - if not df.empty: - dfs.append(df) - except Exception as e: - logger.warning(f"Failed to read {os.path.basename(csv_file)}: {e}") - continue - - if not dfs: - raise ValueError("No valid CSV files could be read") - - # Concatenate all DataFrames - logger.info(f"Concatenating {len(dfs)} DataFrames...") - combined_df = pd.concat(dfs, ignore_index=True) - original_count = len(combined_df) - - # Deduplicate cards - combined_df = self.deduplicate_cards(combined_df) - duplicates_removed = original_count - len(combined_df) - - # Convert object columns with mixed types to strings for Parquet compatibility - # Common columns that may have mixed types: power, toughness, keywords - for col in ["power", "toughness", "keywords"]: - if col in combined_df.columns: - combined_df[col] = combined_df[col].astype(str) - - # Rotate existing versions before writing new file - self.rotate_versions(output_path, keep_versions=3) - - # Write to Parquet - logger.info(f"Writing {len(combined_df)} cards to {output_path}...") - combined_df.to_parquet(output_path, engine="pyarrow", compression="snappy", index=False) - - # Calculate stats - elapsed = (datetime.now() - start_time).total_seconds() - file_size_mb = os.path.getsize(output_path) / (1024 * 1024) - - stats = { - "files_processed": len(csv_files), - "total_cards": len(combined_df), - "duplicates_removed": duplicates_removed, - "file_size_mb": round(file_size_mb, 2), - "elapsed_seconds": round(elapsed, 2), - "timestamp": datetime.now().isoformat(), - } - - logger.info( - f"Aggregation complete: {stats['total_cards']} cards " - f"({stats['file_size_mb']} MB) in {stats['elapsed_seconds']}s" - ) - - # Save metadata - self._save_metadata(source_dir, output_path, stats) - - return stats - - def detect_changes(self, source_dir: str, metadata_path: str) -> list[str]: - """ - Detect which CSV files have changed since last aggregation. - - Args: - source_dir: Directory containing card CSV files - metadata_path: Path to metadata JSON file from previous run - - Returns: - List of file paths that have been added or modified - """ - if not os.path.exists(metadata_path): - logger.info("No previous metadata found, all files considered changed") - return self.get_card_csvs(source_dir) - - try: - with open(metadata_path, "r", encoding="utf-8") as f: - metadata = json.load(f) - last_run = datetime.fromisoformat(metadata.get("timestamp", "")) - except (json.JSONDecodeError, ValueError, KeyError) as e: - logger.warning(f"Invalid metadata file: {e}, treating all files as changed") - return self.get_card_csvs(source_dir) - - # Find files modified after last aggregation - csv_files = self.get_card_csvs(source_dir) - changed_files = [ - f for f in csv_files if datetime.fromtimestamp(os.path.getmtime(f)) > last_run - ] - - logger.info(f"Detected {len(changed_files)} changed files since last aggregation") - return changed_files - - def incremental_update(self, changed_files: list[str], output_path: str) -> dict: - """ - Perform incremental update by replacing only changed cards. - - Note: This is a simplified implementation. For production use, consider: - - Loading existing Parquet, removing old versions of changed cards, adding new - - Currently performs full re-aggregation (simpler, safer for MVP) - - Args: - changed_files: List of CSV files that have changed - output_path: Path to existing Parquet file to update - - Returns: - Dictionary with update statistics - """ - # For MVP, we'll perform a full aggregation instead of true incremental update - # True incremental update would require: - # 1. Load existing Parquet - # 2. Identify cards from changed files - # 3. Remove old versions of those cards - # 4. Add new versions - # This is more complex and error-prone, so we'll defer to a future iteration - - logger.info("Incremental update not yet implemented, performing full aggregation") - source_dir = os.path.dirname(changed_files[0]) if changed_files else "csv_files" - return self.aggregate_all(source_dir, output_path) - - def validate_output(self, output_path: str, source_dir: str) -> tuple[bool, list[str]]: - """ - Validate the aggregated output file. - - Checks: - - File exists and is readable - - Contains expected columns - - Has reasonable number of cards (>0) - - Random sampling matches source data - - Args: - output_path: Path to Parquet file to validate - source_dir: Original source directory for comparison - - Returns: - Tuple of (is_valid, list_of_errors) - """ - errors = [] - - # Check file exists - if not os.path.exists(output_path): - errors.append(f"Output file not found: {output_path}") - return False, errors - - try: - # Load Parquet file - df = pd.read_parquet(output_path, engine="pyarrow") - - # Check not empty - if df.empty: - errors.append("Output file is empty") - - # Check has 'name' column at minimum - if "name" not in df.columns: - errors.append("Output file missing 'name' column") - - # Check for reasonable card count (at least 100 cards expected in any real dataset) - if len(df) < 100: - logger.warning(f"Output has only {len(df)} cards (expected more)") - - logger.info(f"Validation passed: {len(df)} cards with {len(df.columns)} columns") - - except Exception as e: - errors.append(f"Failed to read/validate output file: {e}") - - return len(errors) == 0, errors - - def rotate_versions(self, output_path: str, keep_versions: int = 3) -> None: - """ - Rotate historical versions of the output file. - - Keeps the last N versions as backups (e.g., all_cards_v1.parquet, all_cards_v2.parquet). - - Args: - output_path: Path to current output file - keep_versions: Number of historical versions to keep (default: 3) - """ - if not os.path.exists(output_path): - return # Nothing to rotate - - # Parse output path - base_dir = os.path.dirname(output_path) - filename = os.path.basename(output_path) - name, ext = os.path.splitext(filename) - - # Rotate existing versions (v2 -> v3, v1 -> v2, current -> v1) - for version in range(keep_versions - 1, 0, -1): - old_path = os.path.join(base_dir, f"{name}_v{version}{ext}") - new_path = os.path.join(base_dir, f"{name}_v{version + 1}{ext}") - - if os.path.exists(old_path): - if version + 1 > keep_versions: - # Delete oldest version - os.remove(old_path) - logger.info(f"Deleted old version: {os.path.basename(old_path)}") - else: - # Rename to next version - os.rename(old_path, new_path) - logger.info( - f"Rotated {os.path.basename(old_path)} -> {os.path.basename(new_path)}" - ) - - # Move current file to v1 - v1_path = os.path.join(base_dir, f"{name}_v1{ext}") - if os.path.exists(output_path): - os.rename(output_path, v1_path) - logger.info(f"Rotated current file to {os.path.basename(v1_path)}") - - def _save_metadata(self, source_dir: str, output_path: str, stats: dict) -> None: - """Save aggregation metadata for incremental updates.""" - metadata_path = os.path.join(self.output_dir, ".aggregate_metadata.json") - - metadata = { - "source_dir": source_dir, - "output_path": output_path, - "last_aggregation": stats["timestamp"], - "stats": stats, - } - - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(metadata, f, indent=2) - - logger.info(f"Saved aggregation metadata to {metadata_path}") diff --git a/code/file_setup/data_loader.py b/code/file_setup/data_loader.py deleted file mode 100644 index 7102b88..0000000 --- a/code/file_setup/data_loader.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Data loader abstraction for CSV and Parquet formats. - -This module provides a unified interface for reading and writing card data -in both CSV and Parquet formats. It handles format detection, conversion, -and schema validation. - -Introduced in v3.0.0 as part of the Parquet migration. -""" - -from __future__ import annotations - -import os -from pathlib import Path -from typing import List, Optional - -import pandas as pd - -from logging_util import get_logger -from path_util import card_files_processed_dir - -logger = get_logger(__name__) - - -# Required columns for deck building -REQUIRED_COLUMNS = [ - "name", - "colorIdentity", - "type", # MTGJSON uses 'type' not 'types' - "keywords", - "manaValue", - "text", - "power", - "toughness", -] - - -def validate_schema(df: pd.DataFrame, required: Optional[List[str]] = None) -> None: - """Validate that DataFrame contains required columns. - - Args: - df: DataFrame to validate - required: List of required columns (uses REQUIRED_COLUMNS if None) - - Raises: - ValueError: If required columns are missing - """ - required = required or REQUIRED_COLUMNS - missing = [col for col in required if col not in df.columns] - - if missing: - raise ValueError( - f"Schema validation failed: missing required columns {missing}. " - f"Available columns: {list(df.columns)}" - ) - - logger.debug(f"✓ Schema validation passed ({len(required)} required columns present)") - - -class DataLoader: - """Unified data loading interface supporting CSV and Parquet formats. - - This class provides transparent access to card data regardless of the - underlying storage format. It automatically detects the format based on - file extensions and provides conversion utilities. - - Examples: - >>> loader = DataLoader() - >>> df = loader.read_cards("card_files/processed/all_cards.parquet") - >>> loader.write_cards(df, "output.parquet") - >>> loader.convert("input.csv", "output.parquet") - """ - - def __init__(self, format: str = "auto"): - """Initialize the data loader. - - Args: - format: Format preference - "csv", "parquet", or "auto" (default: auto) - "auto" detects format from file extension - """ - self.format = format.lower() - if self.format not in ("csv", "parquet", "auto"): - raise ValueError(f"Unsupported format: {format}. Use 'csv', 'parquet', or 'auto'.") - - def read_cards( - self, - path: str, - columns: Optional[List[str]] = None, - format: Optional[str] = None - ) -> pd.DataFrame: - """Load card data from a file. - - Args: - path: File path (e.g., "card_files/processed/all_cards.parquet") - columns: Optional list of columns to load (Parquet optimization) - format: Override format detection (uses self.format if None) - - Returns: - DataFrame with card data - - Raises: - FileNotFoundError: If the file doesn't exist - ValueError: If format is unsupported - """ - if not os.path.exists(path): - raise FileNotFoundError(f"Card data file not found: {path}") - - detected_format = format or self._detect_format(path) - - logger.debug(f"Loading card data from {path} (format: {detected_format})") - - if detected_format == "csv": - return self._read_csv(path, columns) - elif detected_format == "parquet": - return self._read_parquet(path, columns) - else: - raise ValueError(f"Unsupported format: {detected_format}") - - def write_cards( - self, - df: pd.DataFrame, - path: str, - format: Optional[str] = None, - index: bool = False - ) -> None: - """Save card data to a file. - - Args: - df: DataFrame to save - path: Output file path - format: Force format (overrides auto-detection) - index: Whether to write DataFrame index (default: False) - - Raises: - ValueError: If format is unsupported - """ - detected_format = format or self._detect_format(path) - - # Ensure output directory exists - os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True) - - logger.debug(f"Writing card data to {path} (format: {detected_format}, rows: {len(df)})") - - if detected_format == "csv": - self._write_csv(df, path, index) - elif detected_format == "parquet": - self._write_parquet(df, path, index) - else: - raise ValueError(f"Unsupported format: {detected_format}") - - def convert( - self, - src_path: str, - dst_path: str, - columns: Optional[List[str]] = None - ) -> None: - """Convert between CSV and Parquet formats. - - Args: - src_path: Source file path - dst_path: Destination file path - columns: Optional list of columns to include (all if None) - - Examples: - >>> loader.convert("cards.csv", "cards.parquet") - >>> loader.convert("cards.parquet", "cards.csv", columns=["name", "type"]) - """ - logger.info(f"Converting {src_path} → {dst_path}") - df = self.read_cards(src_path, columns=columns) - self.write_cards(df, dst_path) - logger.info(f"✓ Converted {len(df)} cards") - - def _read_csv(self, path: str, columns: Optional[List[str]] = None) -> pd.DataFrame: - """Read CSV file.""" - try: - return pd.read_csv(path, usecols=columns, low_memory=False) - except Exception as e: - logger.error(f"Failed to read CSV from {path}: {e}") - raise - - def _read_parquet(self, path: str, columns: Optional[List[str]] = None) -> pd.DataFrame: - """Read Parquet file.""" - try: - return pd.read_parquet(path, columns=columns) - except Exception as e: - logger.error(f"Failed to read Parquet from {path}: {e}") - raise - - def _write_csv(self, df: pd.DataFrame, path: str, index: bool) -> None: - """Write CSV file.""" - try: - df.to_csv(path, index=index) - except Exception as e: - logger.error(f"Failed to write CSV to {path}: {e}") - raise - - def _write_parquet(self, df: pd.DataFrame, path: str, index: bool) -> None: - """Write Parquet file with Snappy compression.""" - try: - df.to_parquet(path, index=index, compression="snappy", engine="pyarrow") - except Exception as e: - logger.error(f"Failed to write Parquet to {path}: {e}") - raise - - def _detect_format(self, path: str) -> str: - """Detect file format from extension. - - Args: - path: File path to analyze - - Returns: - Format string: "csv" or "parquet" - - Raises: - ValueError: If format cannot be determined - """ - if self.format != "auto": - return self.format - - # Check file extension - if path.endswith(".csv"): - return "csv" - elif path.endswith(".parquet"): - return "parquet" - - # Try to infer from existing files (no extension provided) - if os.path.exists(f"{path}.parquet"): - return "parquet" - elif os.path.exists(f"{path}.csv"): - return "csv" - - raise ValueError( - f"Cannot determine format for '{path}'. " - "Use .csv or .parquet extension, or specify format explicitly." - ) - - def write_batch_parquet( - self, - df: pd.DataFrame, - batch_id: int, - tag: str = "", - batches_dir: Optional[str] = None - ) -> str: - """Write a batch Parquet file (used during tagging). - - Args: - df: DataFrame to save as a batch - batch_id: Unique batch identifier (e.g., 0, 1, 2...) - tag: Optional tag to include in filename (e.g., "white", "commander") - batches_dir: Directory for batch files (defaults to card_files/processed/batches) - - Returns: - Path to the written batch file - - Example: - >>> loader.write_batch_parquet(white_df, batch_id=0, tag="white") - 'card_files/processed/batches/batch_0_white.parquet' - """ - if batches_dir is None: - batches_dir = os.path.join(card_files_processed_dir(), "batches") - - os.makedirs(batches_dir, exist_ok=True) - - # Build filename: batch_{id}_{tag}.parquet or batch_{id}.parquet - filename = f"batch_{batch_id}_{tag}.parquet" if tag else f"batch_{batch_id}.parquet" - path = os.path.join(batches_dir, filename) - - logger.debug(f"Writing batch {batch_id} ({tag or 'no tag'}): {len(df)} cards → {path}") - self.write_cards(df, path, format="parquet") - - return path - - def merge_batches( - self, - output_path: Optional[str] = None, - batches_dir: Optional[str] = None, - cleanup: bool = True - ) -> pd.DataFrame: - """Merge all batch Parquet files into a single output file. - - Args: - output_path: Path for merged output (defaults to card_files/processed/all_cards.parquet) - batches_dir: Directory containing batch files (defaults to card_files/processed/batches) - cleanup: Whether to delete batch files after merging (default: True) - - Returns: - Merged DataFrame - - Raises: - FileNotFoundError: If no batch files found - - Example: - >>> loader.merge_batches() # Merges all batches → all_cards.parquet - """ - if batches_dir is None: - batches_dir = os.path.join(card_files_processed_dir(), "batches") - - if output_path is None: - from code.path_util import get_processed_cards_path - output_path = get_processed_cards_path() - - # Find all batch files - batch_files = sorted(Path(batches_dir).glob("batch_*.parquet")) - - if not batch_files: - raise FileNotFoundError(f"No batch files found in {batches_dir}") - - logger.info(f"Merging {len(batch_files)} batch files from {batches_dir}") - - # Read and concatenate all batches - dfs = [] - for batch_file in batch_files: - logger.debug(f"Reading batch: {batch_file.name}") - df = self.read_cards(str(batch_file), format="parquet") - dfs.append(df) - - # Merge all batches - merged_df = pd.concat(dfs, ignore_index=True) - logger.info(f"Merged {len(merged_df)} total cards from {len(dfs)} batches") - - # Write merged output - self.write_cards(merged_df, output_path, format="parquet") - logger.info(f"✓ Wrote merged data to {output_path}") - - # Cleanup batch files if requested - if cleanup: - logger.debug(f"Cleaning up {len(batch_files)} batch files") - for batch_file in batch_files: - batch_file.unlink() - - # Remove batches directory if empty - try: - Path(batches_dir).rmdir() - logger.debug(f"Removed empty batches directory: {batches_dir}") - except OSError: - pass # Directory not empty, keep it - - return merged_df - diff --git a/code/file_setup/image_cache.py b/code/file_setup/image_cache.py deleted file mode 100644 index 08a7c22..0000000 --- a/code/file_setup/image_cache.py +++ /dev/null @@ -1,567 +0,0 @@ -""" -Card image caching system. - -Downloads and manages local cache of Magic: The Gathering card images -from Scryfall, with graceful fallback to API when images are missing. - -Features: -- Optional caching (disabled by default for open source users) -- Uses Scryfall bulk data API (respects rate limits and guidelines) -- Downloads from Scryfall CDN (no rate limits on image files) -- Progress tracking for long downloads -- Resume capability if interrupted -- Graceful fallback to API if images missing - -Environment Variables: - CACHE_CARD_IMAGES: 1=enable caching, 0=disable (default: 0) - -Image Sizes: - - small: 160px width (for list views) - - normal: 488px width (for prominent displays, hover previews) - -Directory Structure: - card_files/images/small/ - Small thumbnails (~900 MB - 1.5 GB) - card_files/images/normal/ - Normal images (~2.4 GB - 4.5 GB) - -See: https://scryfall.com/docs/api -""" - -import json -import logging -import os -import re -import time -from pathlib import Path -from typing import Any, Optional -from urllib.request import Request, urlopen - -from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient - -logger = logging.getLogger(__name__) - -# Scryfall CDN has no rate limits, but we'll be conservative -DOWNLOAD_DELAY = 0.05 # 50ms between image downloads (20 req/sec) - -# Image sizes to cache -IMAGE_SIZES = ["small", "normal"] - -# Card name sanitization (filesystem-safe) -INVALID_CHARS = r'[<>:"/\\|?*]' - - -def sanitize_filename(card_name: str) -> str: - """ - Sanitize card name for use as filename. - - Args: - card_name: Original card name - - Returns: - Filesystem-safe filename - """ - # Replace invalid characters with underscore - safe_name = re.sub(INVALID_CHARS, "_", card_name) - # Remove multiple consecutive underscores - safe_name = re.sub(r"_+", "_", safe_name) - # Trim leading/trailing underscores - safe_name = safe_name.strip("_") - return safe_name - - -class ImageCache: - """Manages local card image cache.""" - - def __init__( - self, - base_dir: str = "card_files/images", - bulk_data_path: str = "card_files/raw/scryfall_bulk_data.json", - ): - """ - Initialize image cache. - - Args: - base_dir: Base directory for cached images - bulk_data_path: Path to Scryfall bulk data JSON - """ - self.base_dir = Path(base_dir) - self.bulk_data_path = Path(bulk_data_path) - self.client = ScryfallBulkDataClient() - self._last_download_time: float = 0.0 - - def is_enabled(self) -> bool: - """Check if image caching is enabled via environment variable.""" - return os.getenv("CACHE_CARD_IMAGES", "0") == "1" - - def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]: - """ - Get local path to cached image if it exists. - - Args: - card_name: Card name - size: Image size ('small' or 'normal') - - Returns: - Path to cached image, or None if not cached - """ - if not self.is_enabled(): - return None - - safe_name = sanitize_filename(card_name) - image_path = self.base_dir / size / f"{safe_name}.jpg" - - if image_path.exists(): - return image_path - return None - - def get_image_url(self, card_name: str, size: str = "normal") -> str: - """ - Get image URL (local path if cached, Scryfall API otherwise). - - Args: - card_name: Card name - size: Image size ('small' or 'normal') - - Returns: - URL or local path to image - """ - # Check local cache first - local_path = self.get_image_path(card_name, size) - if local_path: - # Return as static file path for web serving - return f"/static/card_images/{size}/{sanitize_filename(card_name)}.jpg" - - # Fallback to Scryfall API - from urllib.parse import quote - card_query = quote(card_name) - return f"https://api.scryfall.com/cards/named?fuzzy={card_query}&format=image&version={size}" - - def _rate_limit_wait(self) -> None: - """Wait to respect rate limits between downloads.""" - elapsed = time.time() - self._last_download_time - if elapsed < DOWNLOAD_DELAY: - time.sleep(DOWNLOAD_DELAY - elapsed) - self._last_download_time = time.time() - - def _download_image(self, image_url: str, output_path: Path) -> bool: - """ - Download single image from Scryfall CDN. - - Args: - image_url: Image URL from bulk data - output_path: Local path to save image - - Returns: - True if successful, False otherwise - """ - self._rate_limit_wait() - - try: - # Ensure output directory exists - output_path.parent.mkdir(parents=True, exist_ok=True) - - req = Request(image_url) - req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") - - with urlopen(req, timeout=30) as response: - image_data = response.read() - with open(output_path, "wb") as f: - f.write(image_data) - - return True - - except Exception as e: - logger.debug(f"Failed to download {image_url}: {e}") - # Clean up partial download - if output_path.exists(): - output_path.unlink() - return False - - def _load_bulk_data(self) -> list[dict[str, Any]]: - """ - Load card data from bulk data JSON. - - Returns: - List of card objects with image URLs - - Raises: - FileNotFoundError: If bulk data file doesn't exist - json.JSONDecodeError: If file is invalid JSON - """ - if not self.bulk_data_path.exists(): - raise FileNotFoundError( - f"Bulk data file not found: {self.bulk_data_path}. " - "Run download_bulk_data() first." - ) - - logger.info(f"Loading bulk data from {self.bulk_data_path}") - with open(self.bulk_data_path, "r", encoding="utf-8") as f: - return json.load(f) - - def _filter_to_our_cards(self, bulk_cards: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Filter bulk data to only cards in our all_cards.parquet file. - Deduplicates by card name (takes first printing only). - - Args: - bulk_cards: Full Scryfall bulk data - - Returns: - Filtered list of cards matching our dataset (one per unique name) - """ - try: - import pandas as pd - from code.path_util import get_processed_cards_path - - # Load our card names - parquet_path = get_processed_cards_path() - df = pd.read_parquet(parquet_path, columns=["name"]) - our_card_names = set(df["name"].str.lower()) - - logger.info(f"Filtering {len(bulk_cards)} Scryfall cards to {len(our_card_names)} cards in our dataset") - - # Filter and deduplicate - keep only first printing of each card - seen_names = set() - filtered = [] - - for card in bulk_cards: - card_name_lower = card.get("name", "").lower() - if card_name_lower in our_card_names and card_name_lower not in seen_names: - filtered.append(card) - seen_names.add(card_name_lower) - - logger.info(f"Filtered to {len(filtered)} unique cards with image data") - return filtered - - except Exception as e: - logger.warning(f"Could not filter to our cards: {e}. Using all Scryfall cards.") - return bulk_cards - - def download_bulk_data(self, progress_callback=None) -> None: - """ - Download latest Scryfall bulk data JSON. - - Args: - progress_callback: Optional callback(bytes_downloaded, total_bytes) - - Raises: - Exception: If download fails - """ - logger.info("Downloading Scryfall bulk data...") - self.bulk_data_path.parent.mkdir(parents=True, exist_ok=True) - self.client.get_bulk_data( - output_path=str(self.bulk_data_path), - progress_callback=progress_callback, - ) - logger.info("Bulk data download complete") - - def download_images( - self, - sizes: Optional[list[str]] = None, - progress_callback=None, - max_cards: Optional[int] = None, - ) -> dict[str, int]: - """ - Download card images from Scryfall CDN. - - Args: - sizes: Image sizes to download (default: ['small', 'normal']) - progress_callback: Optional callback(current, total, card_name) - max_cards: Maximum cards to download (for testing) - - Returns: - Dictionary with download statistics - - Raises: - FileNotFoundError: If bulk data not available - """ - if not self.is_enabled(): - logger.info("Image caching disabled (CACHE_CARD_IMAGES=0)") - return {"skipped": 0} - - if sizes is None: - sizes = IMAGE_SIZES - - logger.info(f"Starting image download for sizes: {sizes}") - - # Load bulk data and filter to our cards - bulk_cards = self._load_bulk_data() - cards = self._filter_to_our_cards(bulk_cards) - total_cards = len(cards) if max_cards is None else min(max_cards, len(cards)) - - stats = { - "total": total_cards, - "downloaded": 0, - "skipped": 0, - "failed": 0, - } - - for i, card in enumerate(cards[:total_cards]): - card_name = card.get("name") - if not card_name: - stats["skipped"] += 1 - continue - - # Collect all faces to download (single-faced or multi-faced) - faces_to_download = [] - - # Check if card has direct image_uris (single-faced card) - if card.get("image_uris"): - faces_to_download.append({ - "name": card_name, - "image_uris": card["image_uris"], - }) - # Handle double-faced cards (get all faces) - elif card.get("card_faces"): - for face_idx, face in enumerate(card["card_faces"]): - if face.get("image_uris"): - # For multi-faced cards, append face name or index - face_name = face.get("name", f"{card_name}_face{face_idx}") - faces_to_download.append({ - "name": face_name, - "image_uris": face["image_uris"], - }) - - # Skip if no faces found - if not faces_to_download: - logger.debug(f"No image URIs for {card_name}") - stats["skipped"] += 1 - continue - - # Download each face in each requested size - for face in faces_to_download: - face_name = face["name"] - image_uris = face["image_uris"] - - for size in sizes: - image_url = image_uris.get(size) - if not image_url: - continue - - # Check if already cached - safe_name = sanitize_filename(face_name) - output_path = self.base_dir / size / f"{safe_name}.jpg" - - if output_path.exists(): - stats["skipped"] += 1 - continue - - # Download image - if self._download_image(image_url, output_path): - stats["downloaded"] += 1 - else: - stats["failed"] += 1 - - # Progress callback - if progress_callback: - progress_callback(i + 1, total_cards, card_name) - - # Invalidate cached summary since we just downloaded new images - self.invalidate_summary_cache() - - logger.info(f"Image download complete: {stats}") - return stats - - def cache_statistics(self) -> dict[str, Any]: - """ - Get statistics about cached images. - - Uses a cached summary.json file to avoid scanning thousands of files. - Regenerates summary if it doesn't exist or is stale (based on WEB_AUTO_REFRESH_DAYS, - default 7 days, matching the main card data staleness check). - - Returns: - Dictionary with cache stats (count, size, etc.) - """ - stats = {"enabled": self.is_enabled()} - - if not self.is_enabled(): - return stats - - summary_file = self.base_dir / "summary.json" - - # Get staleness threshold from environment (same as card data check) - try: - refresh_days = int(os.getenv('WEB_AUTO_REFRESH_DAYS', '7')) - except Exception: - refresh_days = 7 - - if refresh_days <= 0: - # Never consider stale - refresh_seconds = float('inf') - else: - refresh_seconds = refresh_days * 24 * 60 * 60 # Convert days to seconds - - # Check if summary exists and is recent (less than refresh_seconds old) - use_cached = False - if summary_file.exists(): - try: - import time - file_age = time.time() - summary_file.stat().st_mtime - if file_age < refresh_seconds: - use_cached = True - except Exception: - pass - - # Try to use cached summary - if use_cached: - try: - import json - with summary_file.open('r', encoding='utf-8') as f: - cached_stats = json.load(f) - stats.update(cached_stats) - return stats - except Exception as e: - logger.warning(f"Could not read cache summary: {e}") - - # Regenerate summary (fast - just count files and estimate size) - for size in IMAGE_SIZES: - size_dir = self.base_dir / size - if size_dir.exists(): - # Fast count: count .jpg files without statting each one - count = sum(1 for _ in size_dir.glob("*.jpg")) - - # Estimate total size based on typical averages to avoid stat() calls - # Small images: ~40 KB avg, Normal images: ~100 KB avg - avg_size_kb = 40 if size == "small" else 100 - estimated_size_mb = (count * avg_size_kb) / 1024 - - stats[size] = { - "count": count, - "size_mb": round(estimated_size_mb, 1), - } - else: - stats[size] = {"count": 0, "size_mb": 0.0} - - # Save summary for next time - try: - import json - with summary_file.open('w', encoding='utf-8') as f: - json.dump({k: v for k, v in stats.items() if k != "enabled"}, f) - except Exception as e: - logger.warning(f"Could not write cache summary: {e}") - - return stats - - def invalidate_summary_cache(self) -> None: - """Delete the cached summary file to force regeneration on next call.""" - if not self.is_enabled(): - return - - summary_file = self.base_dir / "summary.json" - if summary_file.exists(): - try: - summary_file.unlink() - logger.debug("Invalidated cache summary file") - except Exception as e: - logger.warning(f"Could not delete cache summary: {e}") - - -def main(): - """CLI entry point for image caching.""" - import argparse - - parser = argparse.ArgumentParser(description="Card image cache management") - parser.add_argument( - "--download", - action="store_true", - help="Download images from Scryfall", - ) - parser.add_argument( - "--stats", - action="store_true", - help="Show cache statistics", - ) - parser.add_argument( - "--max-cards", - type=int, - help="Maximum cards to download (for testing)", - ) - parser.add_argument( - "--sizes", - nargs="+", - default=IMAGE_SIZES, - choices=IMAGE_SIZES, - help="Image sizes to download", - ) - parser.add_argument( - "--force", - action="store_true", - help="Force re-download of bulk data even if recent", - ) - - args = parser.parse_args() - - # Setup logging - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - cache = ImageCache() - - if args.stats: - stats = cache.cache_statistics() - print("\nCache Statistics:") - print(f" Enabled: {stats['enabled']}") - if stats["enabled"]: - for size in IMAGE_SIZES: - if size in stats: - print( - f" {size.capitalize()}: {stats[size]['count']} images " - f"({stats[size]['size_mb']:.1f} MB)" - ) - - elif args.download: - if not cache.is_enabled(): - print("Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable.") - return - - # Check if bulk data already exists and is recent (within 24 hours) - bulk_data_exists = cache.bulk_data_path.exists() - bulk_data_age_hours = None - - if bulk_data_exists: - import time - age_seconds = time.time() - cache.bulk_data_path.stat().st_mtime - bulk_data_age_hours = age_seconds / 3600 - print(f"Bulk data file exists (age: {bulk_data_age_hours:.1f} hours)") - - # Download bulk data if missing, old, or forced - if not bulk_data_exists or bulk_data_age_hours > 24 or args.force: - print("Downloading Scryfall bulk data...") - - def bulk_progress(downloaded, total): - if total > 0: - pct = (downloaded / total) * 100 - print(f" Progress: {downloaded / 1024 / 1024:.1f} MB / " - f"{total / 1024 / 1024:.1f} MB ({pct:.1f}%)", end="\r") - - cache.download_bulk_data(progress_callback=bulk_progress) - print("\nBulk data downloaded successfully") - else: - print("Bulk data is recent, skipping download (use --force to re-download)") - - # Download images - print(f"\nDownloading card images (sizes: {', '.join(args.sizes)})...") - - def image_progress(current, total, card_name): - pct = (current / total) * 100 - print(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}", end="\r") - - stats = cache.download_images( - sizes=args.sizes, - progress_callback=image_progress, - max_cards=args.max_cards, - ) - print("\n\nDownload complete:") - print(f" Total: {stats['total']}") - print(f" Downloaded: {stats['downloaded']}") - print(f" Skipped: {stats['skipped']}") - print(f" Failed: {stats['failed']}") - - else: - parser.print_help() - - -if __name__ == "__main__": - main() diff --git a/code/file_setup/old/setup.py b/code/file_setup/old/setup.py deleted file mode 100644 index 104aa06..0000000 --- a/code/file_setup/old/setup.py +++ /dev/null @@ -1,362 +0,0 @@ -"""MTG Python Deckbuilder setup module. - -This module provides the main setup functionality for the MTG Python Deckbuilder -application. It handles initial setup tasks such as downloading card data, -creating color-filtered card lists, and gener logger.info(f'Downloading latest card data for {color} cards') - download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - - logger.info('Loading and processing card data') - try: - df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) - except pd.errors.ParserError as e: - logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...') - df = pd.read_csv( - f'{CSV_DIRECTORY}/cards.csv', - low_memory=False, - on_bad_lines='warn', # Warn about malformed rows but continue - encoding_errors='replace' # Replace bad encoding chars - ) - logger.info('Successfully loaded card data with error handling (some rows may have been skipped)') - - logger.info(f'Regenerating {color} cards CSV')der-eligible card lists. - -Key Features: - - Initial setup and configuration - - Card data download and processing - - Color-based card filtering - - Commander card list generation - - CSV file management and validation - -The module works in conjunction with setup_utils.py for utility functions and -exceptions.py for error handling. -""" - -from __future__ import annotations - -# Standard library imports -from enum import Enum -import os -from typing import List, Dict, Any - -# Third-party imports (optional) -try: - import inquirer -except Exception: - inquirer = None # Fallback to simple input-based menu when unavailable -import pandas as pd - -# Local imports -import logging_util -from settings import CSV_DIRECTORY -from .setup_constants import BANNED_CARDS, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL -from .setup_utils import ( - download_cards_csv, - filter_dataframe, - process_legendary_cards, - check_csv_exists, - save_color_filtered_csvs, - enrich_commander_rows_with_tags, -) -from exceptions import ( - CSVFileNotFoundError, - CommanderValidationError, - MTGJSONDownloadError -) -from scripts import generate_background_cards as background_cards_script -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _generate_background_catalog(cards_path: str, output_path: str) -> None: - """Regenerate ``background_cards.csv`` from the latest cards dataset.""" - - logger.info('Generating background cards catalog') - args = [ - '--source', cards_path, - '--output', output_path, - ] - try: - background_cards_script.main(args) - except Exception: # pragma: no cover - surfaced to caller/test - logger.exception('Failed to generate background catalog') - raise - else: - logger.info('Background cards catalog generated successfully') - -# Create logger for this module -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - -# Create CSV directory if it doesn't exist -if not os.path.exists(CSV_DIRECTORY): - os.makedirs(CSV_DIRECTORY) - -## Note: using shared check_csv_exists from setup_utils to avoid duplication - -def initial_setup() -> None: - """Perform initial setup by downloading card data and creating filtered CSV files. - - Downloads the latest card data from MTGJSON if needed, creates color-filtered CSV files, - and generates commander-eligible cards list. Uses utility functions from setup_utils.py - for file operations and data processing. - - Raises: - CSVFileNotFoundError: If required CSV files cannot be found - MTGJSONDownloadError: If card data download fails - DataFrameProcessingError: If data processing fails - ColorFilterError: If color filtering fails - """ - logger.info('Checking for cards.csv file') - - try: - cards_file = f'{CSV_DIRECTORY}/cards.csv' - try: - with open(cards_file, 'r', encoding='utf-8'): - logger.info('cards.csv exists') - except FileNotFoundError: - logger.info('cards.csv not found, downloading from mtgjson') - download_cards_csv(MTGJSON_API_URL, cards_file) - - df = pd.read_csv(cards_file, low_memory=False) - - logger.info('Checking for color identity sorted files') - # Generate color-identity filtered CSVs in one pass - save_color_filtered_csvs(df, CSV_DIRECTORY) - - # Generate commander list - determine_commanders() - - except Exception as e: - logger.error(f'Error during initial setup: {str(e)}') - raise - -## Removed local filter_by_color in favor of setup_utils.save_color_filtered_csvs - -def determine_commanders() -> None: - """Generate commander_cards.csv containing all cards eligible to be commanders. - - This function processes the card database to identify and validate commander-eligible cards, - applying comprehensive validation steps and filtering criteria. - - Raises: - CSVFileNotFoundError: If cards.csv is missing and cannot be downloaded - MTGJSONDownloadError: If downloading cards data fails - CommanderValidationError: If commander validation fails - DataFrameProcessingError: If data processing operations fail - """ - logger.info('Starting commander card generation process') - - try: - # Check for cards.csv with progress tracking - cards_file = f'{CSV_DIRECTORY}/cards.csv' - if not check_csv_exists(cards_file): - logger.info('cards.csv not found, initiating download') - download_cards_csv(MTGJSON_API_URL, cards_file) - else: - logger.info('cards.csv found, proceeding with processing') - - # Load and process cards data - logger.info('Loading card data from CSV') - df = pd.read_csv(cards_file, low_memory=False) - - # Process legendary cards with validation - logger.info('Processing and validating legendary cards') - try: - filtered_df = process_legendary_cards(df) - except CommanderValidationError as e: - logger.error(f'Commander validation failed: {str(e)}') - raise - - # Apply standard filters - logger.info('Applying standard card filters') - filtered_df = filter_dataframe(filtered_df, BANNED_CARDS) - - logger.info('Enriching commander metadata with theme and creature tags') - filtered_df = enrich_commander_rows_with_tags(filtered_df, CSV_DIRECTORY) - - # Save commander cards - logger.info('Saving validated commander cards') - commander_path = f'{CSV_DIRECTORY}/commander_cards.csv' - filtered_df.to_csv(commander_path, index=False) - - background_output = f'{CSV_DIRECTORY}/background_cards.csv' - _generate_background_catalog(cards_file, background_output) - - logger.info('Commander card generation completed successfully') - - except (CSVFileNotFoundError, MTGJSONDownloadError) as e: - logger.error(f'File operation error: {str(e)}') - raise - except CommanderValidationError as e: - logger.error(f'Commander validation error: {str(e)}') - raise - except Exception as e: - logger.error(f'Unexpected error during commander generation: {str(e)}') - raise - -def regenerate_csvs_all() -> None: - """Regenerate all color-filtered CSV files from latest card data. - - Downloads fresh card data and recreates all color-filtered CSV files. - Useful for updating the card database when new sets are released. - - Raises: - MTGJSONDownloadError: If card data download fails - DataFrameProcessingError: If data processing fails - ColorFilterError: If color filtering fails - """ - try: - logger.info('Downloading latest card data from MTGJSON') - download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - - logger.info('Loading and processing card data') - try: - df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) - except pd.errors.ParserError as e: - logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...') - df = pd.read_csv( - f'{CSV_DIRECTORY}/cards.csv', - low_memory=False, - on_bad_lines='warn', # Warn about malformed rows but continue - encoding_errors='replace' # Replace bad encoding chars - ) - logger.info(f'Successfully loaded card data with error handling (some rows may have been skipped)') - - logger.info('Regenerating color identity sorted files') - save_color_filtered_csvs(df, CSV_DIRECTORY) - - logger.info('Regenerating commander cards') - determine_commanders() - - logger.info('Card database regeneration complete') - - except Exception as e: - logger.error(f'Failed to regenerate card database: {str(e)}') - raise - # Once files are regenerated, create a new legendary list (already executed in try) - -def regenerate_csv_by_color(color: str) -> None: - """Regenerate CSV file for a specific color identity. - - Args: - color: Color name to regenerate CSV for (e.g. 'white', 'blue') - - Raises: - ValueError: If color is not valid - MTGJSONDownloadError: If card data download fails - DataFrameProcessingError: If data processing fails - ColorFilterError: If color filtering fails - """ - try: - if color not in SETUP_COLORS: - raise ValueError(f'Invalid color: {color}') - - color_abv = COLOR_ABRV[SETUP_COLORS.index(color)] - - logger.info(f'Downloading latest card data for {color} cards') - download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - - logger.info('Loading and processing card data') - df = pd.read_csv( - f'{CSV_DIRECTORY}/cards.csv', - low_memory=False, - on_bad_lines='skip', # Skip malformed rows (MTGJSON CSV has escaping issues) - encoding_errors='replace' # Replace bad encoding chars - ) - - logger.info(f'Regenerating {color} cards CSV') - # Use shared utilities to base-filter once then slice color, honoring bans - base_df = filter_dataframe(df, BANNED_CARDS) - base_df[base_df['colorIdentity'] == color_abv].to_csv( - f'{CSV_DIRECTORY}/{color}_cards.csv', index=False - ) - - logger.info(f'Successfully regenerated {color} cards database') - - except Exception as e: - logger.error(f'Failed to regenerate {color} cards: {str(e)}') - raise - -class SetupOption(Enum): - """Enum for setup menu options.""" - INITIAL_SETUP = 'Initial Setup' - REGENERATE_CSV = 'Regenerate CSV Files' - BACK = 'Back' - -def _display_setup_menu() -> SetupOption: - """Display the setup menu and return the selected option. - - Returns: - SetupOption: The selected menu option - """ - if inquirer is not None: - question: List[Dict[str, Any]] = [ - inquirer.List( - 'menu', - choices=[option.value for option in SetupOption], - carousel=True)] - answer = inquirer.prompt(question) - return SetupOption(answer['menu']) - - # Simple fallback when inquirer isn't installed (e.g., headless/container) - options = list(SetupOption) - print("\nSetup Menu:") - for idx, opt in enumerate(options, start=1): - print(f" {idx}) {opt.value}") - while True: - try: - sel = input("Select an option [1]: ").strip() or "1" - i = int(sel) - if 1 <= i <= len(options): - return options[i - 1] - except KeyboardInterrupt: - print("") - return SetupOption.BACK - except Exception: - pass - print("Invalid selection. Please try again.") - -def setup() -> bool: - """Run the setup process for the MTG Python Deckbuilder. - - This function provides a menu-driven interface to: - 1. Perform initial setup by downloading and processing card data - 2. Regenerate CSV files with updated card data - 3. Perform all tagging processes on the color-sorted csv files - - The function handles errors gracefully and provides feedback through logging. - - Returns: - bool: True if setup completed successfully, False otherwise - """ - try: - print('Which setup operation would you like to perform?\n' - 'If this is your first time setting up, do the initial setup.\n' - 'If you\'ve done the basic setup before, you can regenerate the CSV files\n') - - choice = _display_setup_menu() - - if choice == SetupOption.INITIAL_SETUP: - logger.info('Starting initial setup') - initial_setup() - logger.info('Initial setup completed successfully') - return True - - elif choice == SetupOption.REGENERATE_CSV: - logger.info('Starting CSV regeneration') - regenerate_csvs_all() - logger.info('CSV regeneration completed successfully') - return True - - elif choice == SetupOption.BACK: - logger.info('Setup cancelled by user') - return False - - except Exception as e: - logger.error(f'Error during setup: {e}') - raise - - return False diff --git a/code/file_setup/old/setup_constants.py b/code/file_setup/old/setup_constants.py deleted file mode 100644 index ccd6b4d..0000000 --- a/code/file_setup/old/setup_constants.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Dict, List -from settings import ( - SETUP_COLORS, - COLOR_ABRV, - CARD_DATA_COLUMNS as COLUMN_ORDER, # backward compatible alias - CARD_DATA_COLUMNS as TAGGED_COLUMN_ORDER, -) - -__all__ = [ - 'SETUP_COLORS', 'COLOR_ABRV', 'COLUMN_ORDER', 'TAGGED_COLUMN_ORDER', - 'BANNED_CARDS', 'MTGJSON_API_URL', 'LEGENDARY_OPTIONS', 'NON_LEGAL_SETS', - 'CARD_TYPES_TO_EXCLUDE', 'CSV_PROCESSING_COLUMNS', 'SORT_CONFIG', - 'FILTER_CONFIG' -] - -# Banned cards consolidated here (remains specific to setup concerns) -BANNED_CARDS: List[str] = [ - # Commander banned list - 'Ancestral Recall', 'Balance', 'Biorhythm', 'Black Lotus', - 'Chaos Orb', 'Channel', 'Dockside Extortionist', - 'Emrakul, the Aeons Torn', - 'Erayo, Soratami Ascendant', 'Falling Star', 'Fastbond', - 'Flash', 'Golos, Tireless Pilgrim', - 'Griselbrand', 'Hullbreacher', 'Iona, Shield of Emeria', - 'Karakas', 'Jeweled Lotus', 'Leovold, Emissary of Trest', - 'Library of Alexandria', 'Limited Resources', 'Lutri, the Spellchaser', - 'Mana Crypt', 'Mox Emerald', 'Mox Jet', 'Mox Pearl', 'Mox Ruby', - 'Mox Sapphire', 'Nadu, Winged Wisdom', - 'Paradox Engine', 'Primeval Titan', 'Prophet of Kruphix', - 'Recurring Nightmare', 'Rofellos, Llanowar Emissary', 'Shahrazad', - 'Sundering Titan', 'Sylvan Primordial', - 'Time Vault', 'Time Walk', 'Tinker', 'Tolarian Academy', - 'Trade Secrets', 'Upheaval', "Yawgmoth's Bargain", - # Problematic / culturally sensitive or banned in other formats - 'Invoke Prejudice', 'Cleanse', 'Stone-Throwing Devils', 'Pradesh Gypsies', - 'Jihad', 'Imprison', 'Crusade', - # Cards of the Hero type (non creature) - "The Protector", "The Hunter", "The Savant", "The Explorer", - "The Philosopher", "The Harvester", "The Tyrant", "The Vanquisher", - "The Avenger", "The Slayer", "The Warmonger", "The Destined", - "The Warrior", "The General", "The Provider", "The Champion", - # Hero Equipment - "Spear of the General", "Lash of the Tyrant", "Bow of the Hunter", - "Cloak of the Philosopher", "Axe of the Warmonger" -] - -# Constants for setup and CSV processing -MTGJSON_API_URL: str = 'https://mtgjson.com/api/v5/csv/cards.csv' - -LEGENDARY_OPTIONS: List[str] = [ - 'Legendary Creature', - 'Legendary Artifact', - 'Legendary Artifact Creature', - 'Legendary Enchantment Creature', - 'Legendary Planeswalker' -] - -NON_LEGAL_SETS: List[str] = [ - 'PHTR', 'PH17', 'PH18', 'PH19', 'PH20', 'PH21', - 'UGL', 'UND', 'UNH', 'UST' -] - -CARD_TYPES_TO_EXCLUDE: List[str] = [ - 'Plane —', - 'Conspiracy', - 'Vanguard', - 'Scheme', - 'Phenomenon', - 'Stickers', - 'Attraction', - 'Contraption' -] - -# Columns to keep when processing CSV files -CSV_PROCESSING_COLUMNS: List[str] = [ - 'name', # Card name - 'faceName', # Name of specific face for multi-faced cards - 'edhrecRank', # Card's rank on EDHREC - 'colorIdentity', # Color identity for Commander format - 'colors', # Actual colors in card's mana cost - 'manaCost', # Mana cost string - 'manaValue', # Converted mana cost - 'type', # Card type line - 'layout', # Card layout (normal, split, etc) - 'text', # Card text/rules - 'power', # Power (for creatures) - 'toughness', # Toughness (for creatures) - 'keywords', # Card's keywords - 'side' # Side identifier for multi-faced cards -] - -# Configuration for DataFrame sorting operations -SORT_CONFIG = { - 'columns': ['name', 'side'], # Columns to sort by - 'case_sensitive': False # Ignore case when sorting -} - -# Configuration for DataFrame filtering operations -FILTER_CONFIG: Dict[str, Dict[str, List[str]]] = { - 'layout': { - 'exclude': ['reversible_card'] - }, - 'availability': { - 'require': ['paper'] - }, - 'promoTypes': { - 'exclude': ['playtest'] - }, - 'securityStamp': { - 'exclude': ['Heart', 'Acorn'] - } -} - -# COLUMN_ORDER and TAGGED_COLUMN_ORDER now sourced from settings via CARD_DATA_COLUMNS \ No newline at end of file diff --git a/code/file_setup/old/setup_csv.py b/code/file_setup/old/setup_csv.py deleted file mode 100644 index 247597f..0000000 --- a/code/file_setup/old/setup_csv.py +++ /dev/null @@ -1,342 +0,0 @@ -"""MTG Python Deckbuilder setup module. - -This module provides the main setup functionality for the MTG Python Deckbuilder -application. It handles initial setup tasks such as downloading card data, -creating color-filtered card lists, and gener logger.info(f'Downloading latest card data for {color} cards') - download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - - logger.info('Loading and processing card data') - try: - df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) - except pd.errors.ParserError as e: - logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...') - df = pd.read_csv( - f'{CSV_DIRECTORY}/cards.csv', - low_memory=False, - on_bad_lines='warn', # Warn about malformed rows but continue - encoding_errors='replace' # Replace bad encoding chars - ) - logger.info('Successfully loaded card data with error handling (some rows may have been skipped)') - - logger.info(f'Regenerating {color} cards CSV')der-eligible card lists. - -Key Features: - - Initial setup and configuration - - Card data download and processing - - Color-based card filtering - - Commander card list generation - - CSV file management and validation - -The module works in conjunction with setup_utils.py for utility functions and -exceptions.py for error handling. -""" - -from __future__ import annotations - -# Standard library imports -from enum import Enum -import os -from typing import List, Dict, Any - -# Third-party imports (optional) -try: - import inquirer -except Exception: - inquirer = None # Fallback to simple input-based menu when unavailable -import pandas as pd - -# Local imports -import logging_util -from settings import CSV_DIRECTORY -from .setup_constants import BANNED_CARDS, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL -from .setup_utils import ( - download_cards_csv, - filter_dataframe, - process_legendary_cards, - check_csv_exists, - save_color_filtered_csvs, - enrich_commander_rows_with_tags, -) -from exceptions import ( - CSVFileNotFoundError, - CommanderValidationError, - MTGJSONDownloadError -) -from scripts import generate_background_cards as background_cards_script -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _generate_background_catalog(cards_path: str, output_path: str) -> None: - """Regenerate ``background_cards.csv`` from the latest cards dataset.""" - - logger.info('Generating background cards catalog') - args = [ - '--source', cards_path, - '--output', output_path, - ] - try: - background_cards_script.main(args) - except Exception: # pragma: no cover - surfaced to caller/test - logger.exception('Failed to generate background catalog') - raise - else: - logger.info('Background cards catalog generated successfully') - -# Create logger for this module -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - -# Create CSV directory if it doesn't exist -if not os.path.exists(CSV_DIRECTORY): - os.makedirs(CSV_DIRECTORY) - -## Note: using shared check_csv_exists from setup_utils to avoid duplication - -def initial_setup() -> None: - """Perform initial setup by downloading and processing card data. - - **MIGRATION NOTE**: This function now delegates to the Parquet-based setup - (initial_setup_parquet) instead of the legacy CSV workflow. The old CSV-based - setup is preserved in code/file_setup/old/setup.py for reference. - - Downloads the latest card data from MTGJSON as Parquet, processes it, and creates - the unified all_cards.parquet file. No color-specific files are generated - filtering - happens at query time instead. - - Raises: - Various exceptions from Parquet download/processing steps - """ - from .setup_parquet import initial_setup_parquet - initial_setup_parquet() - -## Removed local filter_by_color in favor of setup_utils.save_color_filtered_csvs - -def determine_commanders() -> None: - """Generate commander_cards.csv containing all cards eligible to be commanders. - - This function processes the card database to identify and validate commander-eligible cards, - applying comprehensive validation steps and filtering criteria. - - Raises: - CSVFileNotFoundError: If cards.csv is missing and cannot be downloaded - MTGJSONDownloadError: If downloading cards data fails - CommanderValidationError: If commander validation fails - DataFrameProcessingError: If data processing operations fail - """ - logger.info('Starting commander card generation process') - - try: - # Check for cards.csv with progress tracking - cards_file = f'{CSV_DIRECTORY}/cards.csv' - if not check_csv_exists(cards_file): - logger.info('cards.csv not found, initiating download') - download_cards_csv(MTGJSON_API_URL, cards_file) - else: - logger.info('cards.csv found, proceeding with processing') - - # Load and process cards data - logger.info('Loading card data from CSV') - df = pd.read_csv(cards_file, low_memory=False) - - # Process legendary cards with validation - logger.info('Processing and validating legendary cards') - try: - filtered_df = process_legendary_cards(df) - except CommanderValidationError as e: - logger.error(f'Commander validation failed: {str(e)}') - raise - - # Apply standard filters - logger.info('Applying standard card filters') - filtered_df = filter_dataframe(filtered_df, BANNED_CARDS) - - logger.info('Enriching commander metadata with theme and creature tags') - filtered_df = enrich_commander_rows_with_tags(filtered_df, CSV_DIRECTORY) - - # Save commander cards - logger.info('Saving validated commander cards') - commander_path = f'{CSV_DIRECTORY}/commander_cards.csv' - filtered_df.to_csv(commander_path, index=False) - - background_output = f'{CSV_DIRECTORY}/background_cards.csv' - _generate_background_catalog(cards_file, background_output) - - logger.info('Commander card generation completed successfully') - - except (CSVFileNotFoundError, MTGJSONDownloadError) as e: - logger.error(f'File operation error: {str(e)}') - raise - except CommanderValidationError as e: - logger.error(f'Commander validation error: {str(e)}') - raise - except Exception as e: - logger.error(f'Unexpected error during commander generation: {str(e)}') - raise - -def regenerate_csvs_all() -> None: - """Regenerate all color-filtered CSV files from latest card data. - - Downloads fresh card data and recreates all color-filtered CSV files. - Useful for updating the card database when new sets are released. - - Raises: - MTGJSONDownloadError: If card data download fails - DataFrameProcessingError: If data processing fails - ColorFilterError: If color filtering fails - """ - try: - logger.info('Downloading latest card data from MTGJSON') - download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - - logger.info('Loading and processing card data') - try: - df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) - except pd.errors.ParserError as e: - logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...') - df = pd.read_csv( - f'{CSV_DIRECTORY}/cards.csv', - low_memory=False, - on_bad_lines='warn', # Warn about malformed rows but continue - encoding_errors='replace' # Replace bad encoding chars - ) - logger.info(f'Successfully loaded card data with error handling (some rows may have been skipped)') - - logger.info('Regenerating color identity sorted files') - save_color_filtered_csvs(df, CSV_DIRECTORY) - - logger.info('Regenerating commander cards') - determine_commanders() - - logger.info('Card database regeneration complete') - - except Exception as e: - logger.error(f'Failed to regenerate card database: {str(e)}') - raise - # Once files are regenerated, create a new legendary list (already executed in try) - -def regenerate_csv_by_color(color: str) -> None: - """Regenerate CSV file for a specific color identity. - - Args: - color: Color name to regenerate CSV for (e.g. 'white', 'blue') - - Raises: - ValueError: If color is not valid - MTGJSONDownloadError: If card data download fails - DataFrameProcessingError: If data processing fails - ColorFilterError: If color filtering fails - """ - try: - if color not in SETUP_COLORS: - raise ValueError(f'Invalid color: {color}') - - color_abv = COLOR_ABRV[SETUP_COLORS.index(color)] - - logger.info(f'Downloading latest card data for {color} cards') - download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') - - logger.info('Loading and processing card data') - df = pd.read_csv( - f'{CSV_DIRECTORY}/cards.csv', - low_memory=False, - on_bad_lines='skip', # Skip malformed rows (MTGJSON CSV has escaping issues) - encoding_errors='replace' # Replace bad encoding chars - ) - - logger.info(f'Regenerating {color} cards CSV') - # Use shared utilities to base-filter once then slice color, honoring bans - base_df = filter_dataframe(df, BANNED_CARDS) - base_df[base_df['colorIdentity'] == color_abv].to_csv( - f'{CSV_DIRECTORY}/{color}_cards.csv', index=False - ) - - logger.info(f'Successfully regenerated {color} cards database') - - except Exception as e: - logger.error(f'Failed to regenerate {color} cards: {str(e)}') - raise - -class SetupOption(Enum): - """Enum for setup menu options.""" - INITIAL_SETUP = 'Initial Setup' - REGENERATE_CSV = 'Regenerate CSV Files' - BACK = 'Back' - -def _display_setup_menu() -> SetupOption: - """Display the setup menu and return the selected option. - - Returns: - SetupOption: The selected menu option - """ - if inquirer is not None: - question: List[Dict[str, Any]] = [ - inquirer.List( - 'menu', - choices=[option.value for option in SetupOption], - carousel=True)] - answer = inquirer.prompt(question) - return SetupOption(answer['menu']) - - # Simple fallback when inquirer isn't installed (e.g., headless/container) - options = list(SetupOption) - print("\nSetup Menu:") - for idx, opt in enumerate(options, start=1): - print(f" {idx}) {opt.value}") - while True: - try: - sel = input("Select an option [1]: ").strip() or "1" - i = int(sel) - if 1 <= i <= len(options): - return options[i - 1] - except KeyboardInterrupt: - print("") - return SetupOption.BACK - except Exception: - pass - print("Invalid selection. Please try again.") - -def setup() -> bool: - """Run the setup process for the MTG Python Deckbuilder. - - This function provides a menu-driven interface to: - 1. Perform initial setup by downloading and processing card data - 2. Regenerate CSV files with updated card data - 3. Perform all tagging processes on the color-sorted csv files - - The function handles errors gracefully and provides feedback through logging. - - Returns: - bool: True if setup completed successfully, False otherwise - """ - try: - print('Which setup operation would you like to perform?\n' - 'If this is your first time setting up, do the initial setup.\n' - 'If you\'ve done the basic setup before, you can regenerate the CSV files\n') - - choice = _display_setup_menu() - - if choice == SetupOption.INITIAL_SETUP: - logger.info('Starting initial setup') - initial_setup() - logger.info('Initial setup completed successfully') - return True - - elif choice == SetupOption.REGENERATE_CSV: - logger.info('Starting CSV regeneration') - regenerate_csvs_all() - logger.info('CSV regeneration completed successfully') - return True - - elif choice == SetupOption.BACK: - logger.info('Setup cancelled by user') - return False - - except Exception as e: - logger.error(f'Error during setup: {e}') - raise - - return False diff --git a/code/file_setup/old/setup_utils.py b/code/file_setup/old/setup_utils.py deleted file mode 100644 index e707269..0000000 --- a/code/file_setup/old/setup_utils.py +++ /dev/null @@ -1,776 +0,0 @@ -"""MTG Python Deckbuilder setup utilities. - -This module provides utility functions for setting up and managing the MTG Python Deckbuilder -application. It handles tasks such as downloading card data, filtering cards by various criteria, -and processing legendary creatures for commander format. - -Key Features: - - Card data download from MTGJSON - - DataFrame filtering and processing - - Color identity filtering - - Commander validation - - CSV file management - -The module integrates with settings.py for configuration and exceptions.py for error handling. -""" - -from __future__ import annotations - -# Standard library imports -import ast -import requests -from pathlib import Path -from typing import List, Optional, Union, TypedDict, Iterable, Dict, Any - -# Third-party imports -import pandas as pd -from tqdm import tqdm -import json -from datetime import datetime - -# Local application imports -from .setup_constants import ( - CSV_PROCESSING_COLUMNS, - CARD_TYPES_TO_EXCLUDE, - NON_LEGAL_SETS, - SORT_CONFIG, - FILTER_CONFIG, - COLUMN_ORDER, - TAGGED_COLUMN_ORDER, - SETUP_COLORS, - COLOR_ABRV, - BANNED_CARDS, -) -from exceptions import ( - MTGJSONDownloadError, - DataFrameProcessingError, - ColorFilterError, - CommanderValidationError -) -from type_definitions import CardLibraryDF -from settings import FILL_NA_COLUMNS, CSV_DIRECTORY -import logging_util - -# Create logger for this module -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - - -def _is_primary_side(value: object) -> bool: - """Return True when the provided side marker corresponds to a primary face.""" - try: - if pd.isna(value): - return True - except Exception: - pass - text = str(value).strip().lower() - return text in {"", "a"} - - -def _summarize_secondary_face_exclusions( - names: Iterable[str], - source_df: pd.DataFrame, -) -> List[Dict[str, Any]]: - summaries: List[Dict[str, Any]] = [] - if not names: - return summaries - - for raw_name in names: - name = str(raw_name) - group = source_df[source_df['name'] == name] - if group.empty: - continue - - primary_rows = group[group['side'].apply(_is_primary_side)] if 'side' in group.columns else pd.DataFrame() - primary_face = ( - str(primary_rows['faceName'].iloc[0]) - if not primary_rows.empty and 'faceName' in primary_rows.columns - else "" - ) - layout = str(group['layout'].iloc[0]) if 'layout' in group.columns and not group.empty else "" - faces = sorted(set(str(v) for v in group.get('faceName', pd.Series(dtype=str)).dropna().tolist())) - eligible_faces = sorted( - set( - str(v) - for v in group - .loc[~group['side'].apply(_is_primary_side) if 'side' in group.columns else [False] * len(group)] - .get('faceName', pd.Series(dtype=str)) - .dropna() - .tolist() - ) - ) - - summaries.append( - { - "name": name, - "primary_face": primary_face or name.split('//')[0].strip(), - "layout": layout, - "faces": faces, - "eligible_faces": eligible_faces, - "reason": "secondary_face_only", - } - ) - - return summaries - - -def _write_commander_exclusions_log(entries: List[Dict[str, Any]]) -> None: - """Persist commander exclusion diagnostics for downstream tooling.""" - - path = Path(CSV_DIRECTORY) / ".commander_exclusions.json" - - if not entries: - try: - path.unlink() - except FileNotFoundError: - return - except Exception as exc: - logger.debug("Unable to remove commander exclusion log: %s", exc) - return - - payload = { - "generated_at": datetime.now().isoformat(timespec='seconds'), - "secondary_face_only": entries, - } - - try: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open('w', encoding='utf-8') as handle: - json.dump(payload, handle, indent=2, ensure_ascii=False) - except Exception as exc: - logger.warning("Failed to write commander exclusion diagnostics: %s", exc) - - -def _enforce_primary_face_commander_rules( - candidate_df: pd.DataFrame, - source_df: pd.DataFrame, -) -> pd.DataFrame: - """Retain only primary faces and record any secondary-face-only exclusions.""" - - if candidate_df.empty or 'side' not in candidate_df.columns: - _write_commander_exclusions_log([]) - return candidate_df - - mask_primary = candidate_df['side'].apply(_is_primary_side) - primary_df = candidate_df[mask_primary].copy() - secondary_df = candidate_df[~mask_primary] - - primary_names = set(str(n) for n in primary_df.get('name', pd.Series(dtype=str))) - secondary_only_names = sorted( - set(str(n) for n in secondary_df.get('name', pd.Series(dtype=str))) - primary_names - ) - - if secondary_only_names: - logger.info( - "Excluding %d commander entries where only a secondary face is eligible: %s", - len(secondary_only_names), - ", ".join(secondary_only_names), - ) - - entries = _summarize_secondary_face_exclusions(secondary_only_names, source_df) - _write_commander_exclusions_log(entries) - - return primary_df - - -def _coerce_tag_list(value: object) -> List[str]: - """Normalize various list-like representations into a list of strings.""" - - if value is None: - return [] - if isinstance(value, float) and pd.isna(value): - return [] - if isinstance(value, (list, tuple, set)): - return [str(v).strip() for v in value if str(v).strip()] - text = str(value).strip() - if not text: - return [] - try: - parsed = ast.literal_eval(text) - if isinstance(parsed, (list, tuple, set)): - return [str(v).strip() for v in parsed if str(v).strip()] - except Exception: - pass - parts = [part.strip() for part in text.replace(";", ",").split(",")] - return [part for part in parts if part] - - -def _collect_commander_tag_metadata(csv_dir: Union[str, Path]) -> Dict[str, Dict[str, List[str]]]: - """Aggregate theme and creature tags from color-tagged CSV files.""" - - path = Path(csv_dir) - if not path.exists(): - return {} - - combined: Dict[str, Dict[str, set[str]]] = {} - columns = ("themeTags", "creatureTypes", "roleTags") - - for color in SETUP_COLORS: - color_path = path / f"{color}_cards.csv" - if not color_path.exists(): - continue - try: - df = pd.read_csv(color_path, low_memory=False) - except Exception as exc: - logger.debug("Unable to read %s for commander tag enrichment: %s", color_path, exc) - continue - - if df.empty or ("name" not in df.columns and "faceName" not in df.columns): - continue - - for _, row in df.iterrows(): - face_key = str(row.get("faceName", "")).strip() - name_key = str(row.get("name", "")).strip() - keys = {k for k in (face_key, name_key) if k} - if not keys: - continue - - for key in keys: - bucket = combined.setdefault(key, {col: set() for col in columns}) - for col in columns: - if col not in row: - continue - values = _coerce_tag_list(row.get(col)) - if values: - bucket[col].update(values) - - enriched: Dict[str, Dict[str, List[str]]] = {} - for key, data in combined.items(): - enriched[key] = {col: sorted(values) for col, values in data.items() if values} - return enriched - - -def enrich_commander_rows_with_tags( - df: pd.DataFrame, - csv_dir: Union[str, Path], -) -> pd.DataFrame: - """Attach theme and creature tag metadata to commander rows when available.""" - - if df.empty: - df = df.copy() - for column in ("themeTags", "creatureTypes", "roleTags"): - if column not in df.columns: - df[column] = [] - return df - - metadata = _collect_commander_tag_metadata(csv_dir) - if not metadata: - df = df.copy() - for column in ("themeTags", "creatureTypes", "roleTags"): - if column not in df.columns: - df[column] = [[] for _ in range(len(df))] - return df - - df = df.copy() - for column in ("themeTags", "creatureTypes", "roleTags"): - if column not in df.columns: - df[column] = [[] for _ in range(len(df))] - - theme_values: List[List[str]] = [] - creature_values: List[List[str]] = [] - role_values: List[List[str]] = [] - - for _, row in df.iterrows(): - face_key = str(row.get("faceName", "")).strip() - name_key = str(row.get("name", "")).strip() - - entry_face = metadata.get(face_key, {}) - entry_name = metadata.get(name_key, {}) - - combined: Dict[str, set[str]] = { - "themeTags": set(_coerce_tag_list(row.get("themeTags"))), - "creatureTypes": set(_coerce_tag_list(row.get("creatureTypes"))), - "roleTags": set(_coerce_tag_list(row.get("roleTags"))), - } - - for source in (entry_face, entry_name): - for column in combined: - combined[column].update(source.get(column, [])) - - theme_values.append(sorted(combined["themeTags"])) - creature_values.append(sorted(combined["creatureTypes"])) - role_values.append(sorted(combined["roleTags"])) - - df["themeTags"] = theme_values - df["creatureTypes"] = creature_values - df["roleTags"] = role_values - - enriched_rows = sum(1 for t, c, r in zip(theme_values, creature_values, role_values) if t or c or r) - logger.debug("Enriched %d commander rows with tag metadata", enriched_rows) - - return df - -# Type definitions -class FilterRule(TypedDict): - """Type definition for filter rules configuration.""" - exclude: Optional[List[str]] - require: Optional[List[str]] - -class FilterConfig(TypedDict): - """Type definition for complete filter configuration.""" - layout: FilterRule - availability: FilterRule - promoTypes: FilterRule - securityStamp: FilterRule -def download_cards_csv(url: str, output_path: Union[str, Path]) -> None: - """Download cards data from MTGJSON and save to CSV. - - Downloads card data from the specified MTGJSON URL and saves it to a local CSV file. - Shows a progress bar during download using tqdm. - - Args: - url: URL to download cards data from (typically MTGJSON API endpoint) - output_path: Path where the downloaded CSV file will be saved - - Raises: - MTGJSONDownloadError: If download fails due to network issues or invalid response - - Example: - >>> download_cards_csv('https://mtgjson.com/api/v5/cards.csv', 'cards.csv') - """ - try: - response = requests.get(url, stream=True) - response.raise_for_status() - total_size = int(response.headers.get('content-length', 0)) - - with open(output_path, 'wb') as f: - with tqdm(total=total_size, unit='iB', unit_scale=True, desc='Downloading cards data') as pbar: - for chunk in response.iter_content(chunk_size=8192): - size = f.write(chunk) - pbar.update(size) - - except requests.RequestException as e: - logger.error(f'Failed to download cards data from {url}') - raise MTGJSONDownloadError( - "Failed to download cards data", - url, - getattr(e.response, 'status_code', None) if hasattr(e, 'response') else None - ) from e -def check_csv_exists(filepath: Union[str, Path]) -> bool: - """Check if a CSV file exists at the specified path. - - Verifies the existence of a CSV file at the given path. This function is used - to determine if card data needs to be downloaded or if it already exists locally. - - Args: - filepath: Path to the CSV file to check - - Returns: - bool: True if the file exists, False otherwise - - Example: - >>> if not check_csv_exists('cards.csv'): - ... download_cards_csv(MTGJSON_API_URL, 'cards.csv') - """ - return Path(filepath).is_file() - -def save_color_filtered_csvs(df: pd.DataFrame, out_dir: Union[str, Path]) -> None: - """Generate and save color-identity filtered CSVs for all configured colors. - - Iterates across configured color names and their corresponding color identity - abbreviations, filters the provided DataFrame using standard filters plus - color identity, and writes each filtered set to CSV in the provided directory. - - Args: - df: Source DataFrame containing card data. - out_dir: Output directory for the generated CSV files. - - Raises: - DataFrameProcessingError: If filtering fails. - ColorFilterError: If color filtering fails for a specific color. - """ - out_path = Path(out_dir) - out_path.mkdir(parents=True, exist_ok=True) - - # Base-filter once for efficiency, then per-color filter without redoing base filters - try: - # Apply full standard filtering including banned list once, then slice per color - base_df = filter_dataframe(df, BANNED_CARDS) - except Exception as e: - # Wrap any unexpected issues as DataFrameProcessingError - raise DataFrameProcessingError( - "Failed to prepare base DataFrame for color filtering", - "base_color_filtering", - str(e) - ) from e - - for color_name, color_id in zip(SETUP_COLORS, COLOR_ABRV): - try: - logger.info(f"Generating {color_name}_cards.csv") - color_df = base_df[base_df['colorIdentity'] == color_id] - color_df.to_csv(out_path / f"{color_name}_cards.csv", index=False) - except Exception as e: - raise ColorFilterError( - "Failed to generate color CSV", - color_id, - str(e) - ) from e - -def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame: - """Apply standard filters to the cards DataFrame using configuration from settings. - - Applies a series of filters to the cards DataFrame based on configuration from settings.py. - This includes handling null values, applying basic filters, removing illegal sets and banned cards, - and processing special card types. - - Args: - df: pandas DataFrame containing card data to filter - banned_cards: List of card names that are banned and should be excluded - - Returns: - pd.DataFrame: A new DataFrame containing only the cards that pass all filters - - Raises: - DataFrameProcessingError: If any filtering operation fails - - Example: - >>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus']) - """ - try: - logger.info('Starting standard DataFrame filtering') - - # Fill null values according to configuration - for col, fill_value in FILL_NA_COLUMNS.items(): - if col == 'faceName': - fill_value = df['name'] - df[col] = df[col].fillna(fill_value) - logger.debug(f'Filled NA values in {col} with {fill_value}') - - # Apply basic filters from configuration - filtered_df = df.copy() - filter_config: FilterConfig = FILTER_CONFIG # Type hint for configuration - for field, rules in filter_config.items(): - if field not in filtered_df.columns: - logger.warning('Skipping filter for missing field %s', field) - continue - - for rule_type, values in rules.items(): - if not values: - continue - - if rule_type == 'exclude': - for value in values: - mask = filtered_df[field].astype(str).str.contains( - value, - case=False, - na=False, - regex=False - ) - filtered_df = filtered_df[~mask] - elif rule_type == 'require': - for value in values: - mask = filtered_df[field].astype(str).str.contains( - value, - case=False, - na=False, - regex=False - ) - filtered_df = filtered_df[mask] - else: - logger.warning('Unknown filter rule type %s for field %s', rule_type, field) - continue - - logger.debug(f'Applied {rule_type} filter for {field}: {values}') - - # Remove illegal sets - for set_code in NON_LEGAL_SETS: - filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)] - logger.debug('Removed illegal sets') - - # Remove banned cards (exact, case-insensitive match on name or faceName) - if banned_cards: - banned_set = {b.casefold() for b in banned_cards} - name_lc = filtered_df['name'].astype(str).str.casefold() - face_lc = filtered_df['faceName'].astype(str).str.casefold() - mask = ~(name_lc.isin(banned_set) | face_lc.isin(banned_set)) - before = len(filtered_df) - filtered_df = filtered_df[mask] - after = len(filtered_df) - logger.debug(f'Removed banned cards: {before - after} filtered out') - - # Remove special card types - for card_type in CARD_TYPES_TO_EXCLUDE: - filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)] - logger.debug('Removed special card types') - - # Select columns, sort, and drop duplicates - filtered_df = filtered_df[CSV_PROCESSING_COLUMNS] - filtered_df = filtered_df.sort_values( - by=SORT_CONFIG['columns'], - key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col - ) - filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first') - logger.info('Completed standard DataFrame filtering') - - return filtered_df - - except Exception as e: - logger.error(f'Failed to filter DataFrame: {str(e)}') - raise DataFrameProcessingError( - "Failed to filter DataFrame", - "standard_filtering", - str(e) - ) from e -def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFrame: - """Filter DataFrame by color identity with additional color-specific processing. - - This function extends the base filter_dataframe functionality with color-specific - filtering logic. It is used by setup.py's filter_by_color function but provides - a more robust and configurable implementation. - - Args: - df: DataFrame to filter - color_identity: Color identity to filter by (e.g., 'W', 'U,B', 'Colorless') - - Returns: - DataFrame filtered by color identity - - Raises: - ColorFilterError: If color identity is invalid or filtering fails - DataFrameProcessingError: If general filtering operations fail - """ - try: - logger.info(f'Filtering cards for color identity: {color_identity}') - - # Validate color identity - with tqdm(total=1, desc='Validating color identity') as pbar: - if not isinstance(color_identity, str): - raise ColorFilterError( - "Invalid color identity type", - str(color_identity), - "Color identity must be a string" - ) - pbar.update(1) - - # Apply base filtering - with tqdm(total=1, desc='Applying base filtering') as pbar: - filtered_df = filter_dataframe(df, BANNED_CARDS) - pbar.update(1) - - # Filter by color identity - with tqdm(total=1, desc='Filtering by color identity') as pbar: - filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity] - logger.debug(f'Applied color identity filter: {color_identity}') - pbar.update(1) - - # Additional color-specific processing - with tqdm(total=1, desc='Performing color-specific processing') as pbar: - # Placeholder for future color-specific processing - pbar.update(1) - logger.info(f'Completed color identity filtering for {color_identity}') - return filtered_df - - except DataFrameProcessingError as e: - raise ColorFilterError( - "Color filtering failed", - color_identity, - str(e) - ) from e - except Exception as e: - raise ColorFilterError( - "Unexpected error during color filtering", - color_identity, - str(e) - ) from e - -def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame: - """Process and filter legendary cards for commander eligibility with comprehensive validation. - - Args: - df: DataFrame containing all cards - - Returns: - DataFrame containing only commander-eligible cards - - Raises: - CommanderValidationError: If validation fails for legendary status, special cases, or set legality - DataFrameProcessingError: If general processing fails - """ - try: - logger.info('Starting commander validation process') - - filtered_df = df.copy() - # Step 1: Check legendary status - try: - with tqdm(total=1, desc='Checking legendary status') as pbar: - # Normalize type line for matching - type_line = filtered_df['type'].astype(str).str.lower() - - # Base predicates - is_legendary = type_line.str.contains('legendary') - is_creature = type_line.str.contains('creature') - # Planeswalkers are only eligible if they explicitly state they can be your commander (handled in special cases step) - is_enchantment = type_line.str.contains('enchantment') - is_artifact = type_line.str.contains('artifact') - is_vehicle_or_spacecraft = type_line.str.contains('vehicle') | type_line.str.contains('spacecraft') - - # 1. Always allow Legendary Creatures (includes artifact/enchantment creatures already) - allow_legendary_creature = is_legendary & is_creature - - # 2. Allow Legendary Enchantment Creature (already covered by legendary creature) – ensure no plain legendary enchantments without creature type slip through - allow_enchantment_creature = is_legendary & is_enchantment & is_creature - - # 3. Allow certain Legendary Artifacts: - # a) Vehicles/Spacecraft that have printed power & toughness - has_power_toughness = filtered_df['power'].notna() & filtered_df['toughness'].notna() - allow_artifact_vehicle = is_legendary & is_artifact & is_vehicle_or_spacecraft & has_power_toughness - - # (Artifacts or planeswalkers with explicit permission text will be added in special cases step.) - - baseline_mask = allow_legendary_creature | allow_enchantment_creature | allow_artifact_vehicle - filtered_df = filtered_df[baseline_mask].copy() - - if filtered_df.empty: - raise CommanderValidationError( - "No baseline eligible commanders found", - "legendary_check", - "After applying commander rules no cards qualified" - ) - - logger.debug( - "Baseline commander counts: total=%d legendary_creatures=%d enchantment_creatures=%d artifact_vehicles=%d", - len(filtered_df), - int((allow_legendary_creature).sum()), - int((allow_enchantment_creature).sum()), - int((allow_artifact_vehicle).sum()) - ) - pbar.update(1) - except Exception as e: - raise CommanderValidationError( - "Legendary status check failed", - "legendary_check", - str(e) - ) from e - - # Step 2: Validate special cases - try: - with tqdm(total=1, desc='Validating special cases') as pbar: - # Add any card (including planeswalkers, artifacts, non-legendary cards) that explicitly allow being a commander - special_cases = df['text'].str.contains('can be your commander', na=False, case=False) - special_commanders = df[special_cases].copy() - filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates() - logger.debug(f'Added {len(special_commanders)} special commander cards') - pbar.update(1) - except Exception as e: - raise CommanderValidationError( - "Special case validation failed", - "special_cases", - str(e) - ) from e - - # Step 3: Verify set legality - try: - with tqdm(total=1, desc='Verifying set legality') as pbar: - initial_count = len(filtered_df) - for set_code in NON_LEGAL_SETS: - filtered_df = filtered_df[ - ~filtered_df['printings'].str.contains(set_code, na=False) - ] - removed_count = initial_count - len(filtered_df) - logger.debug(f'Removed {removed_count} cards from illegal sets') - pbar.update(1) - except Exception as e: - raise CommanderValidationError( - "Set legality verification failed", - "set_legality", - str(e) - ) from e - filtered_df = _enforce_primary_face_commander_rules(filtered_df, df) - - logger.info('Commander validation complete. %d valid commanders found', len(filtered_df)) - return filtered_df - - except CommanderValidationError: - raise - except Exception as e: - raise DataFrameProcessingError( - "Failed to process legendary cards", - "commander_processing", - str(e) - ) from e - -def process_card_dataframe(df: CardLibraryDF, batch_size: int = 1000, columns_to_keep: Optional[List[str]] = None, - include_commander_cols: bool = False, skip_availability_checks: bool = False) -> CardLibraryDF: - """Process DataFrame with common operations in batches. - - Args: - df: DataFrame to process - batch_size: Size of batches for processing - columns_to_keep: List of columns to keep (default: COLUMN_ORDER) - include_commander_cols: Whether to include commander-specific columns - skip_availability_checks: Whether to skip availability and security checks (default: False) - - Args: - df: DataFrame to process - batch_size: Size of batches for processing - columns_to_keep: List of columns to keep (default: COLUMN_ORDER) - include_commander_cols: Whether to include commander-specific columns - - Returns: - CardLibraryDF: Processed DataFrame with standardized structure - """ - logger.info("Processing card DataFrame...") - - if columns_to_keep is None: - columns_to_keep = TAGGED_COLUMN_ORDER.copy() - if include_commander_cols: - commander_cols = ['printings', 'text', 'power', 'toughness', 'keywords'] - columns_to_keep.extend(col for col in commander_cols if col not in columns_to_keep) - - # Fill NA values - df.loc[:, 'colorIdentity'] = df['colorIdentity'].fillna('Colorless') - df.loc[:, 'faceName'] = df['faceName'].fillna(df['name']) - - # Process in batches - total_batches = len(df) // batch_size + 1 - processed_dfs = [] - - for i in tqdm(range(total_batches), desc="Processing batches"): - start_idx = i * batch_size - end_idx = min((i + 1) * batch_size, len(df)) - batch = df.iloc[start_idx:end_idx].copy() - - if not skip_availability_checks: - columns_to_keep = COLUMN_ORDER.copy() - logger.debug("Performing column checks...") - # Common processing steps - batch = batch[batch['availability'].str.contains('paper', na=False)] - batch = batch.loc[batch['layout'] != 'reversible_card'] - batch = batch.loc[batch['promoTypes'] != 'playtest'] - batch = batch.loc[batch['securityStamp'] != 'heart'] - batch = batch.loc[batch['securityStamp'] != 'acorn'] - # Keep only specified columns - batch = batch[columns_to_keep] - processed_dfs.append(batch) - else: - logger.debug("Skipping column checks...") - # Even when skipping availability checks, still ensure columns_to_keep if provided - if columns_to_keep is not None: - try: - batch = batch[columns_to_keep] - except Exception: - # If requested columns are not present, keep as-is - pass - processed_dfs.append(batch) - - # Combine processed batches - result = pd.concat(processed_dfs, ignore_index=True) - - # Final processing - result.drop_duplicates(subset='faceName', keep='first', inplace=True) - result.sort_values(by=['name', 'side'], key=lambda col: col.str.lower(), inplace=True) - - logger.info("DataFrame processing completed") - return result - -# Backward-compatibility wrapper used by deck_builder.builder -def regenerate_csvs_all() -> None: # pragma: no cover - simple delegator - """Delegate to setup.regenerate_csvs_all to preserve existing imports. - - Some modules import regenerate_csvs_all from setup_utils. Keep this - function as a stable indirection to avoid breaking callers. - """ - from . import setup as setup_module # local import to avoid circular import - setup_module.regenerate_csvs_all() diff --git a/code/file_setup/scryfall_bulk_data.py b/code/file_setup/scryfall_bulk_data.py deleted file mode 100644 index fd41d90..0000000 --- a/code/file_setup/scryfall_bulk_data.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Scryfall Bulk Data API client. - -Fetches bulk data JSON files from Scryfall's bulk data API, which provides -all card information including image URLs without hitting rate limits. - -See: https://scryfall.com/docs/api/bulk-data -""" - -import logging -import os -import time -from typing import Any -from urllib.request import Request, urlopen - -logger = logging.getLogger(__name__) - -BULK_DATA_API_URL = "https://api.scryfall.com/bulk-data" -DEFAULT_BULK_TYPE = "default_cards" # All cards in Scryfall's database -RATE_LIMIT_DELAY = 0.1 # 100ms between requests (50-100ms per Scryfall guidelines) - - -class ScryfallBulkDataClient: - """Client for fetching Scryfall bulk data.""" - - def __init__(self, rate_limit_delay: float = RATE_LIMIT_DELAY): - """ - Initialize Scryfall bulk data client. - - Args: - rate_limit_delay: Seconds to wait between API requests (default 100ms) - """ - self.rate_limit_delay = rate_limit_delay - self._last_request_time: float = 0.0 - - def _rate_limit_wait(self) -> None: - """Wait to respect rate limits between API calls.""" - elapsed = time.time() - self._last_request_time - if elapsed < self.rate_limit_delay: - time.sleep(self.rate_limit_delay - elapsed) - self._last_request_time = time.time() - - def _make_request(self, url: str) -> Any: - """ - Make HTTP request with rate limiting and error handling. - - Args: - url: URL to fetch - - Returns: - Parsed JSON response - - Raises: - Exception: If request fails after retries - """ - self._rate_limit_wait() - - try: - req = Request(url) - req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") - with urlopen(req, timeout=30) as response: - import json - return json.loads(response.read().decode("utf-8")) - except Exception as e: - logger.error(f"Failed to fetch {url}: {e}") - raise - - def get_bulk_data_info(self, bulk_type: str = DEFAULT_BULK_TYPE) -> dict[str, Any]: - """ - Get bulk data metadata (download URL, size, last updated). - - Args: - bulk_type: Type of bulk data to fetch (default: default_cards) - - Returns: - Dictionary with bulk data info including 'download_uri' - - Raises: - ValueError: If bulk_type not found - Exception: If API request fails - """ - logger.info(f"Fetching bulk data info for type: {bulk_type}") - response = self._make_request(BULK_DATA_API_URL) - - # Find the requested bulk data type - for item in response.get("data", []): - if item.get("type") == bulk_type: - logger.info( - f"Found bulk data: {item.get('name')} " - f"(size: {item.get('size', 0) / 1024 / 1024:.1f} MB, " - f"updated: {item.get('updated_at', 'unknown')})" - ) - return item - - raise ValueError(f"Bulk data type '{bulk_type}' not found") - - def download_bulk_data( - self, download_uri: str, output_path: str, progress_callback=None - ) -> None: - """ - Download bulk data JSON file. - - Args: - download_uri: Direct download URL from get_bulk_data_info() - output_path: Local path to save the JSON file - progress_callback: Optional callback(bytes_downloaded, total_bytes) - - Raises: - Exception: If download fails - """ - logger.info(f"Downloading bulk data from: {download_uri}") - logger.info(f"Saving to: {output_path}") - - # No rate limit on bulk data downloads per Scryfall docs - try: - req = Request(download_uri) - req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") - - with urlopen(req, timeout=60) as response: - total_size = int(response.headers.get("Content-Length", 0)) - downloaded = 0 - chunk_size = 1024 * 1024 # 1MB chunks - - # Ensure output directory exists - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - with open(output_path, "wb") as f: - while True: - chunk = response.read(chunk_size) - if not chunk: - break - f.write(chunk) - downloaded += len(chunk) - if progress_callback: - progress_callback(downloaded, total_size) - - logger.info(f"Downloaded {downloaded / 1024 / 1024:.1f} MB successfully") - - except Exception as e: - logger.error(f"Failed to download bulk data: {e}") - # Clean up partial download - if os.path.exists(output_path): - os.remove(output_path) - raise - - def get_bulk_data( - self, - bulk_type: str = DEFAULT_BULK_TYPE, - output_path: str = "card_files/raw/scryfall_bulk_data.json", - progress_callback=None, - ) -> str: - """ - Fetch bulk data info and download the JSON file. - - Args: - bulk_type: Type of bulk data to fetch - output_path: Where to save the JSON file - progress_callback: Optional progress callback - - Returns: - Path to downloaded file - - Raises: - Exception: If fetch or download fails - """ - info = self.get_bulk_data_info(bulk_type) - download_uri = info["download_uri"] - self.download_bulk_data(download_uri, output_path, progress_callback) - return output_path diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index 62a8165..b377017 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -1,412 +1,362 @@ -"""Parquet-based setup for MTG Python Deckbuilder. +"""MTG Python Deckbuilder setup module. -This module handles downloading and processing MTGJSON Parquet data for the -MTG Python Deckbuilder. It replaces the old CSV-based multi-file approach -with a single-file Parquet workflow. +This module provides the main setup functionality for the MTG Python Deckbuilder +application. It handles initial setup tasks such as downloading card data, +creating color-filtered card lists, and gener logger.info(f'Downloading latest card data for {color} cards') + download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') -Key Changes from CSV approach: -- Single all_cards.parquet file instead of 18+ color-specific CSVs -- Downloads from MTGJSON Parquet API (faster, smaller) -- Adds isCommander and isBackground boolean flags -- Filters to essential columns only (14 base + 4 custom = 18 total) -- Uses DataLoader abstraction for format flexibility + logger.info('Loading and processing card data') + try: + df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) + except pd.errors.ParserError as e: + logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...') + df = pd.read_csv( + f'{CSV_DIRECTORY}/cards.csv', + low_memory=False, + on_bad_lines='warn', # Warn about malformed rows but continue + encoding_errors='replace' # Replace bad encoding chars + ) + logger.info('Successfully loaded card data with error handling (some rows may have been skipped)') -Introduced in v3.0.0 as part of CSV→Parquet migration. + logger.info(f'Regenerating {color} cards CSV')der-eligible card lists. + +Key Features: + - Initial setup and configuration + - Card data download and processing + - Color-based card filtering + - Commander card list generation + - CSV file management and validation + +The module works in conjunction with setup_utils.py for utility functions and +exceptions.py for error handling. """ from __future__ import annotations +# Standard library imports +from enum import Enum import os +from typing import List, Dict, Any +# Third-party imports (optional) +try: + import inquirer # type: ignore +except Exception: + inquirer = None # Fallback to simple input-based menu when unavailable import pandas as pd -import requests -from tqdm import tqdm -from .data_loader import DataLoader, validate_schema -from .setup_constants import ( - CSV_PROCESSING_COLUMNS, - CARD_TYPES_TO_EXCLUDE, - NON_LEGAL_SETS, - BANNED_CARDS, - FILTER_CONFIG, - SORT_CONFIG, -) +# Local imports import logging_util -from path_util import card_files_raw_dir, get_processed_cards_path -import settings - -logger = logging_util.get_logger(__name__) - -# MTGJSON Parquet API URL -MTGJSON_PARQUET_URL = "https://mtgjson.com/api/v5/parquet/cards.parquet" +from settings import CSV_DIRECTORY +from .setup_constants import BANNED_CARDS, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL +from .setup_utils import ( + download_cards_csv, + filter_dataframe, + process_legendary_cards, + check_csv_exists, + save_color_filtered_csvs, + enrich_commander_rows_with_tags, +) +from exceptions import ( + CSVFileNotFoundError, + CommanderValidationError, + MTGJSONDownloadError +) +from scripts import generate_background_cards as background_cards_script +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -def download_parquet_from_mtgjson(output_path: str) -> None: - """Download MTGJSON cards.parquet file. - - Args: - output_path: Where to save the downloaded Parquet file - - Raises: - requests.RequestException: If download fails - IOError: If file cannot be written - """ - logger.info(f"Downloading MTGJSON Parquet from {MTGJSON_PARQUET_URL}") - +def _generate_background_catalog(cards_path: str, output_path: str) -> None: + """Regenerate ``background_cards.csv`` from the latest cards dataset.""" + + logger.info('Generating background cards catalog') + args = [ + '--source', cards_path, + '--output', output_path, + ] try: - response = requests.get(MTGJSON_PARQUET_URL, stream=True, timeout=60) - response.raise_for_status() - - # Get file size for progress bar - total_size = int(response.headers.get('content-length', 0)) - - # Ensure output directory exists - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # Download with progress bar - with open(output_path, 'wb') as f, tqdm( - total=total_size, - unit='B', - unit_scale=True, - desc='Downloading cards.parquet' - ) as pbar: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - pbar.update(len(chunk)) - - logger.info(f"✓ Downloaded {total_size / (1024**2):.2f} MB to {output_path}") - - except requests.RequestException as e: - logger.error(f"Failed to download MTGJSON Parquet: {e}") - raise - except IOError as e: - logger.error(f"Failed to write Parquet file: {e}") + background_cards_script.main(args) + except Exception: # pragma: no cover - surfaced to caller/test + logger.exception('Failed to generate background catalog') raise + else: + logger.info('Background cards catalog generated successfully') +# Create logger for this module +logger = logging_util.logging.getLogger(__name__) +logger.setLevel(logging_util.LOG_LEVEL) +logger.addHandler(logging_util.file_handler) +logger.addHandler(logging_util.stream_handler) -def is_valid_commander(row: pd.Series) -> bool: - """Determine if a card can be a commander. - - Criteria: - - Legendary Creature - - OR: Has "can be your commander" in text - - OR: Background (Partner with Background) - - Args: - row: DataFrame row with card data - - Returns: - True if card can be a commander - """ - type_line = str(row.get('type', '')) - text = str(row.get('text', '')).lower() - - # Legendary Creature - if 'Legendary' in type_line and 'Creature' in type_line: - return True - - # Special text (e.g., "can be your commander") - if 'can be your commander' in text: - return True - - # Backgrounds can be commanders (with Choose a Background) - if 'Background' in type_line: - return True - - return False - - -def is_background(row: pd.Series) -> bool: - """Determine if a card is a Background. - - Args: - row: DataFrame row with card data - - Returns: - True if card has Background type - """ - type_line = str(row.get('type', '')) - return 'Background' in type_line - - -def extract_creature_types(row: pd.Series) -> str: - """Extract creature types from type line. - - Args: - row: DataFrame row with card data - - Returns: - Comma-separated creature types or empty string - """ - type_line = str(row.get('type', '')) - - # Check if it's a creature - if 'Creature' not in type_line: - return '' - - # Split on — to get subtypes - if '—' in type_line: - parts = type_line.split('—') - if len(parts) >= 2: - # Get everything after the dash, strip whitespace - subtypes = parts[1].strip() - return subtypes - - return '' - - -def process_raw_parquet(raw_path: str, output_path: str) -> pd.DataFrame: - """Process raw MTGJSON Parquet into processed all_cards.parquet. - - This function: - 1. Loads raw Parquet (all ~82 columns) - 2. Filters to essential columns (CSV_PROCESSING_COLUMNS) - 3. Applies standard filtering (banned cards, illegal sets, special types) - 4. Deduplicates by faceName (keep first printing only) - 5. Adds custom columns: creatureTypes, themeTags, isCommander, isBackground - 6. Validates schema - 7. Writes to processed directory - - Args: - raw_path: Path to raw cards.parquet from MTGJSON - output_path: Path to save processed all_cards.parquet - - Returns: - Processed DataFrame - - Raises: - ValueError: If schema validation fails - """ - logger.info(f"Processing {raw_path}") - - # Load raw Parquet with DataLoader - loader = DataLoader() - df = loader.read_cards(raw_path) - - logger.info(f"Loaded {len(df)} cards with {len(df.columns)} columns") - - # Step 1: Fill NA values - logger.info("Filling NA values") - for col, fill_value in settings.FILL_NA_COLUMNS.items(): - if col in df.columns: - if col == 'faceName': - df[col] = df[col].fillna(df['name']) - else: - df[col] = df[col].fillna(fill_value) - - # Step 2: Apply configuration-based filters (FILTER_CONFIG) - logger.info("Applying configuration filters") - for field, rules in FILTER_CONFIG.items(): - if field not in df.columns: - logger.warning(f"Skipping filter for missing field: {field}") - continue - - for rule_type, values in rules.items(): - if not values: - continue - - if rule_type == 'exclude': - for value in values: - mask = df[field].astype(str).str.contains(value, case=False, na=False, regex=False) - before = len(df) - df = df[~mask] - logger.debug(f"Excluded {field} containing '{value}': {before - len(df)} removed") - elif rule_type == 'require': - for value in values: - mask = df[field].astype(str).str.contains(value, case=False, na=False, regex=False) - before = len(df) - df = df[mask] - logger.debug(f"Required {field} containing '{value}': {before - len(df)} removed") - - # Step 3: Remove illegal sets - if 'printings' in df.columns: - logger.info("Removing illegal sets") - for set_code in NON_LEGAL_SETS: - before = len(df) - df = df[~df['printings'].str.contains(set_code, na=False)] - if len(df) < before: - logger.debug(f"Removed set {set_code}: {before - len(df)} cards") - - # Step 4: Remove banned cards - logger.info("Removing banned cards") - banned_set = {b.casefold() for b in BANNED_CARDS} - name_lc = df['name'].astype(str).str.casefold() - face_lc = df['faceName'].astype(str).str.casefold() if 'faceName' in df.columns else name_lc - mask = ~(name_lc.isin(banned_set) | face_lc.isin(banned_set)) - before = len(df) - df = df[mask] - logger.debug(f"Removed banned cards: {before - len(df)} filtered out") - - # Step 5: Remove special card types - logger.info("Removing special card types") - for card_type in CARD_TYPES_TO_EXCLUDE: - before = len(df) - df = df[~df['type'].str.contains(card_type, na=False)] - if len(df) < before: - logger.debug(f"Removed type {card_type}: {before - len(df)} cards") - - # Step 6: Filter to essential columns only (reduce from ~82 to 14) - logger.info(f"Filtering to {len(CSV_PROCESSING_COLUMNS)} essential columns") - df = df[CSV_PROCESSING_COLUMNS] - - # Step 7: Sort and deduplicate (CRITICAL: keeps only one printing per unique card) - logger.info("Sorting and deduplicating cards") - df = df.sort_values( - by=SORT_CONFIG['columns'], - key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col - ) - before = len(df) - df = df.drop_duplicates(subset='faceName', keep='first') - logger.info(f"Deduplicated: {before} → {len(df)} cards ({before - len(df)} duplicate printings removed)") - - # Step 8: Add custom columns - logger.info("Adding custom columns: creatureTypes, themeTags, isCommander, isBackground") - - # creatureTypes: extracted from type line - df['creatureTypes'] = df.apply(extract_creature_types, axis=1) - - # themeTags: empty placeholder (filled during tagging) - df['themeTags'] = '' - - # isCommander: boolean flag - df['isCommander'] = df.apply(is_valid_commander, axis=1) - - # isBackground: boolean flag - df['isBackground'] = df.apply(is_background, axis=1) - - # Reorder columns to match CARD_DATA_COLUMNS - # CARD_DATA_COLUMNS has: name, faceName, edhrecRank, colorIdentity, colors, - # manaCost, manaValue, type, creatureTypes, text, - # power, toughness, keywords, themeTags, layout, side - # We need to add isCommander and isBackground at the end - final_columns = settings.CARD_DATA_COLUMNS + ['isCommander', 'isBackground'] - - # Ensure all columns exist - for col in final_columns: - if col not in df.columns: - logger.warning(f"Column {col} missing, adding empty column") - df[col] = '' - - df = df[final_columns] - - logger.info(f"Final dataset: {len(df)} cards, {len(df.columns)} columns") - logger.info(f"Commanders: {df['isCommander'].sum()}") - logger.info(f"Backgrounds: {df['isBackground'].sum()}") - - # Validate schema (check required columns present) - try: - validate_schema(df) - logger.info("✓ Schema validation passed") - except ValueError as e: - logger.error(f"Schema validation failed: {e}") - raise - - # Write to processed directory - logger.info(f"Writing processed Parquet to {output_path}") - os.makedirs(os.path.dirname(output_path), exist_ok=True) - loader.write_cards(df, output_path) - - logger.info(f"✓ Created {output_path}") - - return df +# Create CSV directory if it doesn't exist +if not os.path.exists(CSV_DIRECTORY): + os.makedirs(CSV_DIRECTORY) +## Note: using shared check_csv_exists from setup_utils to avoid duplication def initial_setup() -> None: - """Download and process MTGJSON Parquet data. + """Perform initial setup by downloading card data and creating filtered CSV files. - Modern Parquet-based setup workflow (replaces legacy CSV approach). - - Workflow: - 1. Download cards.parquet from MTGJSON → card_files/raw/cards.parquet - 2. Process and filter → card_files/processed/all_cards.parquet - 3. No color-specific files (filter at query time instead) + Downloads the latest card data from MTGJSON if needed, creates color-filtered CSV files, + and generates commander-eligible cards list. Uses utility functions from setup_utils.py + for file operations and data processing. Raises: - Various exceptions from download/processing steps + CSVFileNotFoundError: If required CSV files cannot be found + MTGJSONDownloadError: If card data download fails + DataFrameProcessingError: If data processing fails + ColorFilterError: If color filtering fails """ - logger.info("=" * 80) - logger.info("Starting Parquet-based initial setup") - logger.info("=" * 80) + logger.info('Checking for cards.csv file') - # Step 1: Download raw Parquet - raw_dir = card_files_raw_dir() - raw_path = os.path.join(raw_dir, "cards.parquet") - - if os.path.exists(raw_path): - logger.info(f"Raw Parquet already exists: {raw_path}") - logger.info("Skipping download (delete file to re-download)") - else: - download_parquet_from_mtgjson(raw_path) - - # Step 2: Process raw → processed - processed_path = get_processed_cards_path() - - logger.info(f"Processing raw Parquet → {processed_path}") - process_raw_parquet(raw_path, processed_path) - - logger.info("=" * 80) - logger.info("✓ Parquet setup complete") - logger.info(f" Raw: {raw_path}") - logger.info(f" Processed: {processed_path}") - logger.info("=" * 80) - - # Step 3: Optional image caching (if enabled) try: - from code.file_setup.image_cache import ImageCache - cache = ImageCache() + cards_file = f'{CSV_DIRECTORY}/cards.csv' + try: + with open(cards_file, 'r', encoding='utf-8'): + logger.info('cards.csv exists') + except FileNotFoundError: + logger.info('cards.csv not found, downloading from mtgjson') + download_cards_csv(MTGJSON_API_URL, cards_file) - if cache.is_enabled(): - logger.info("=" * 80) - logger.info("Card image caching enabled - starting download") - logger.info("=" * 80) - - # Download bulk data - logger.info("Downloading Scryfall bulk data...") - cache.download_bulk_data() - - # Download images - logger.info("Downloading card images (this may take 1-2 hours)...") - - def progress(current, total, card_name): - if current % 100 == 0: # Log every 100 cards - pct = (current / total) * 100 - logger.info(f" Progress: {current}/{total} ({pct:.1f}%) - {card_name}") - - stats = cache.download_images(progress_callback=progress) - - logger.info("=" * 80) - logger.info("✓ Image cache complete") - logger.info(f" Downloaded: {stats['downloaded']}") - logger.info(f" Skipped: {stats['skipped']}") - logger.info(f" Failed: {stats['failed']}") - logger.info("=" * 80) + df = pd.read_csv(cards_file, low_memory=False) + + logger.info('Checking for color identity sorted files') + # Generate color-identity filtered CSVs in one pass + save_color_filtered_csvs(df, CSV_DIRECTORY) + + # Generate commander list + determine_commanders() + + except Exception as e: + logger.error(f'Error during initial setup: {str(e)}') + raise + +## Removed local filter_by_color in favor of setup_utils.save_color_filtered_csvs + +def determine_commanders() -> None: + """Generate commander_cards.csv containing all cards eligible to be commanders. + + This function processes the card database to identify and validate commander-eligible cards, + applying comprehensive validation steps and filtering criteria. + + Raises: + CSVFileNotFoundError: If cards.csv is missing and cannot be downloaded + MTGJSONDownloadError: If downloading cards data fails + CommanderValidationError: If commander validation fails + DataFrameProcessingError: If data processing operations fail + """ + logger.info('Starting commander card generation process') + + try: + # Check for cards.csv with progress tracking + cards_file = f'{CSV_DIRECTORY}/cards.csv' + if not check_csv_exists(cards_file): + logger.info('cards.csv not found, initiating download') + download_cards_csv(MTGJSON_API_URL, cards_file) else: - logger.info("Card image caching disabled (CACHE_CARD_IMAGES=0)") - logger.info("Images will be fetched from Scryfall API on demand") + logger.info('cards.csv found, proceeding with processing') + + # Load and process cards data + logger.info('Loading card data from CSV') + df = pd.read_csv(cards_file, low_memory=False) + + # Process legendary cards with validation + logger.info('Processing and validating legendary cards') + try: + filtered_df = process_legendary_cards(df) + except CommanderValidationError as e: + logger.error(f'Commander validation failed: {str(e)}') + raise + + # Apply standard filters + logger.info('Applying standard card filters') + filtered_df = filter_dataframe(filtered_df, BANNED_CARDS) + + logger.info('Enriching commander metadata with theme and creature tags') + filtered_df = enrich_commander_rows_with_tags(filtered_df, CSV_DIRECTORY) + + # Save commander cards + logger.info('Saving validated commander cards') + commander_path = f'{CSV_DIRECTORY}/commander_cards.csv' + filtered_df.to_csv(commander_path, index=False) + + background_output = f'{CSV_DIRECTORY}/background_cards.csv' + _generate_background_catalog(cards_file, background_output) + + logger.info('Commander card generation completed successfully') + + except (CSVFileNotFoundError, MTGJSONDownloadError) as e: + logger.error(f'File operation error: {str(e)}') + raise + except CommanderValidationError as e: + logger.error(f'Commander validation error: {str(e)}') + raise + except Exception as e: + logger.error(f'Unexpected error during commander generation: {str(e)}') + raise + +def regenerate_csvs_all() -> None: + """Regenerate all color-filtered CSV files from latest card data. + + Downloads fresh card data and recreates all color-filtered CSV files. + Useful for updating the card database when new sets are released. + + Raises: + MTGJSONDownloadError: If card data download fails + DataFrameProcessingError: If data processing fails + ColorFilterError: If color filtering fails + """ + try: + logger.info('Downloading latest card data from MTGJSON') + download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') + + logger.info('Loading and processing card data') + try: + df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False) + except pd.errors.ParserError as e: + logger.warning(f'CSV parsing error encountered: {e}. Retrying with error handling...') + df = pd.read_csv( + f'{CSV_DIRECTORY}/cards.csv', + low_memory=False, + on_bad_lines='warn', # Warn about malformed rows but continue + encoding_errors='replace' # Replace bad encoding chars + ) + logger.info(f'Successfully loaded card data with error handling (some rows may have been skipped)') + + logger.info('Regenerating color identity sorted files') + save_color_filtered_csvs(df, CSV_DIRECTORY) + + logger.info('Regenerating commander cards') + determine_commanders() + + logger.info('Card database regeneration complete') + + except Exception as e: + logger.error(f'Failed to regenerate card database: {str(e)}') + raise + # Once files are regenerated, create a new legendary list (already executed in try) + +def regenerate_csv_by_color(color: str) -> None: + """Regenerate CSV file for a specific color identity. + + Args: + color: Color name to regenerate CSV for (e.g. 'white', 'blue') + + Raises: + ValueError: If color is not valid + MTGJSONDownloadError: If card data download fails + DataFrameProcessingError: If data processing fails + ColorFilterError: If color filtering fails + """ + try: + if color not in SETUP_COLORS: + raise ValueError(f'Invalid color: {color}') + + color_abv = COLOR_ABRV[SETUP_COLORS.index(color)] + + logger.info(f'Downloading latest card data for {color} cards') + download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv') + + logger.info('Loading and processing card data') + df = pd.read_csv( + f'{CSV_DIRECTORY}/cards.csv', + low_memory=False, + on_bad_lines='skip', # Skip malformed rows (MTGJSON CSV has escaping issues) + encoding_errors='replace' # Replace bad encoding chars + ) + + logger.info(f'Regenerating {color} cards CSV') + # Use shared utilities to base-filter once then slice color, honoring bans + base_df = filter_dataframe(df, BANNED_CARDS) + base_df[base_df['colorIdentity'] == color_abv].to_csv( + f'{CSV_DIRECTORY}/{color}_cards.csv', index=False + ) + + logger.info(f'Successfully regenerated {color} cards database') + + except Exception as e: + logger.error(f'Failed to regenerate {color} cards: {str(e)}') + raise + +class SetupOption(Enum): + """Enum for setup menu options.""" + INITIAL_SETUP = 'Initial Setup' + REGENERATE_CSV = 'Regenerate CSV Files' + BACK = 'Back' + +def _display_setup_menu() -> SetupOption: + """Display the setup menu and return the selected option. + + Returns: + SetupOption: The selected menu option + """ + if inquirer is not None: + question: List[Dict[str, Any]] = [ + inquirer.List( + 'menu', + choices=[option.value for option in SetupOption], + carousel=True)] + answer = inquirer.prompt(question) + return SetupOption(answer['menu']) + + # Simple fallback when inquirer isn't installed (e.g., headless/container) + options = list(SetupOption) + print("\nSetup Menu:") + for idx, opt in enumerate(options, start=1): + print(f" {idx}) {opt.value}") + while True: + try: + sel = input("Select an option [1]: ").strip() or "1" + i = int(sel) + if 1 <= i <= len(options): + return options[i - 1] + except KeyboardInterrupt: + print("") + return SetupOption.BACK + except Exception: + pass + print("Invalid selection. Please try again.") + +def setup() -> bool: + """Run the setup process for the MTG Python Deckbuilder. + + This function provides a menu-driven interface to: + 1. Perform initial setup by downloading and processing card data + 2. Regenerate CSV files with updated card data + 3. Perform all tagging processes on the color-sorted csv files + + The function handles errors gracefully and provides feedback through logging. + + Returns: + bool: True if setup completed successfully, False otherwise + """ + try: + print('Which setup operation would you like to perform?\n' + 'If this is your first time setting up, do the initial setup.\n' + 'If you\'ve done the basic setup before, you can regenerate the CSV files\n') + + choice = _display_setup_menu() + + if choice == SetupOption.INITIAL_SETUP: + logger.info('Starting initial setup') + initial_setup() + logger.info('Initial setup completed successfully') + return True + + elif choice == SetupOption.REGENERATE_CSV: + logger.info('Starting CSV regeneration') + regenerate_csvs_all() + logger.info('CSV regeneration completed successfully') + return True + + elif choice == SetupOption.BACK: + logger.info('Setup cancelled by user') + return False except Exception as e: - logger.error(f"Failed to cache images (continuing anyway): {e}") - logger.error("Images will be fetched from Scryfall API on demand") - - -def regenerate_processed_parquet() -> None: - """Regenerate processed Parquet from existing raw file. + logger.error(f'Error during setup: {e}') + raise - Useful when: - - Column processing logic changes - - Adding new custom columns - - Testing without re-downloading - """ - logger.info("Regenerating processed Parquet from raw file") - - raw_path = os.path.join(card_files_raw_dir(), "cards.parquet") - - if not os.path.exists(raw_path): - logger.error(f"Raw Parquet not found: {raw_path}") - logger.error("Run initial_setup_parquet() first to download") - raise FileNotFoundError(f"Raw Parquet not found: {raw_path}") - - processed_path = get_processed_cards_path() - process_raw_parquet(raw_path, processed_path) - - logger.info(f"✓ Regenerated {processed_path}") + return False diff --git a/code/file_setup/setup_constants.py b/code/file_setup/setup_constants.py index c713327..ccd6b4d 100644 --- a/code/file_setup/setup_constants.py +++ b/code/file_setup/setup_constants.py @@ -16,8 +16,8 @@ __all__ = [ # Banned cards consolidated here (remains specific to setup concerns) BANNED_CARDS: List[str] = [ # Commander banned list - '1996 World Champion', 'Ancestral Recall', 'Balance', 'Biorhythm', - 'Black Lotus', 'Chaos Orb', 'Channel', 'Dockside Extortionist', + 'Ancestral Recall', 'Balance', 'Biorhythm', 'Black Lotus', + 'Chaos Orb', 'Channel', 'Dockside Extortionist', 'Emrakul, the Aeons Torn', 'Erayo, Soratami Ascendant', 'Falling Star', 'Fastbond', 'Flash', 'Golos, Tireless Pilgrim', diff --git a/code/headless_runner.py b/code/headless_runner.py index ff3bfbc..66f39d9 100644 --- a/code/headless_runner.py +++ b/code/headless_runner.py @@ -31,22 +31,18 @@ def _is_stale(file1: str, file2: str) -> bool: return os.path.getmtime(file2) < os.path.getmtime(file1) def _ensure_data_ready(): - # M4: Check for Parquet file instead of CSV - from path_util import get_processed_cards_path - - parquet_path = get_processed_cards_path() + cards_csv = os.path.join("csv_files", "cards.csv") tagging_json = os.path.join("csv_files", ".tagging_complete.json") - - # If all_cards.parquet is missing, run full setup+tagging - if not os.path.isfile(parquet_path): - print("all_cards.parquet not found, running full setup and tagging...") + # If cards.csv is missing, run full setup+tagging + if not os.path.isfile(cards_csv): + print("cards.csv not found, running full setup and tagging...") initial_setup() - tagger.run_tagging(parallel=True) # Use parallel tagging for performance + tagger.run_tagging() _write_tagging_flag(tagging_json) # If tagging_complete is missing or stale, run tagging - elif not os.path.isfile(tagging_json) or _is_stale(parquet_path, tagging_json): + elif not os.path.isfile(tagging_json) or _is_stale(cards_csv, tagging_json): print(".tagging_complete.json missing or stale, running tagging...") - tagger.run_tagging(parallel=True) # Use parallel tagging for performance + tagger.run_tagging() _write_tagging_flag(tagging_json) def _write_tagging_flag(tagging_json): @@ -139,7 +135,7 @@ def _validate_commander_available(command_name: str) -> None: return try: - from commander_exclusions import lookup_commander_detail as _lookup_commander_detail + from commander_exclusions import lookup_commander_detail as _lookup_commander_detail # type: ignore[import-not-found] except ImportError: # pragma: no cover _lookup_commander_detail = None @@ -281,12 +277,12 @@ def run( # Optional deterministic seed for Random Modes (does not affect core when unset) try: if seed is not None: - builder.set_seed(seed) + builder.set_seed(seed) # type: ignore[attr-defined] except Exception: pass # Mark this run as headless so builder can adjust exports and logging try: - builder.headless = True + builder.headless = True # type: ignore[attr-defined] except Exception: pass @@ -294,9 +290,9 @@ def run( secondary_clean = (secondary_commander or "").strip() background_clean = (background or "").strip() try: - builder.partner_feature_enabled = partner_feature_enabled - builder.requested_secondary_commander = secondary_clean or None - builder.requested_background = background_clean or None + builder.partner_feature_enabled = partner_feature_enabled # type: ignore[attr-defined] + builder.requested_secondary_commander = secondary_clean or None # type: ignore[attr-defined] + builder.requested_background = background_clean or None # type: ignore[attr-defined] except Exception: pass @@ -313,11 +309,11 @@ def run( # Configure include/exclude settings (M1: Config + Validation + Persistence) try: - builder.include_cards = list(include_cards or []) - builder.exclude_cards = list(exclude_cards or []) - builder.enforcement_mode = enforcement_mode - builder.allow_illegal = allow_illegal - builder.fuzzy_matching = fuzzy_matching + builder.include_cards = list(include_cards or []) # type: ignore[attr-defined] + builder.exclude_cards = list(exclude_cards or []) # type: ignore[attr-defined] + builder.enforcement_mode = enforcement_mode # type: ignore[attr-defined] + builder.allow_illegal = allow_illegal # type: ignore[attr-defined] + builder.fuzzy_matching = fuzzy_matching # type: ignore[attr-defined] except Exception: pass @@ -336,16 +332,16 @@ def run( ) try: - builder.theme_match_mode = theme_resolution.mode - builder.theme_catalog_version = theme_resolution.catalog_version - builder.user_theme_requested = list(theme_resolution.requested) - builder.user_theme_resolved = list(theme_resolution.resolved) - builder.user_theme_matches = list(theme_resolution.matches) - builder.user_theme_unresolved = list(theme_resolution.unresolved) - builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) - builder.user_theme_resolution = theme_resolution + builder.theme_match_mode = theme_resolution.mode # type: ignore[attr-defined] + builder.theme_catalog_version = theme_resolution.catalog_version # type: ignore[attr-defined] + builder.user_theme_requested = list(theme_resolution.requested) # type: ignore[attr-defined] + builder.user_theme_resolved = list(theme_resolution.resolved) # type: ignore[attr-defined] + builder.user_theme_matches = list(theme_resolution.matches) # type: ignore[attr-defined] + builder.user_theme_unresolved = list(theme_resolution.unresolved) # type: ignore[attr-defined] + builder.user_theme_fuzzy_corrections = dict(theme_resolution.fuzzy_corrections) # type: ignore[attr-defined] + builder.user_theme_resolution = theme_resolution # type: ignore[attr-defined] if user_theme_weight is not None: - builder.user_theme_weight = float(user_theme_weight) + builder.user_theme_weight = float(user_theme_weight) # type: ignore[attr-defined] except Exception: pass @@ -356,7 +352,7 @@ def run( ic: Dict[str, int] = {} for k, v in ideal_counts.items(): try: - iv = int(v) if v is not None else None + iv = int(v) if v is not None else None # type: ignore except Exception: continue if iv is None: @@ -365,7 +361,7 @@ def run( if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}: ic[k] = iv if ic: - builder.ideal_counts.update(ic) + builder.ideal_counts.update(ic) # type: ignore[attr-defined] except Exception: pass builder.run_initial_setup() @@ -518,24 +514,24 @@ def _apply_combined_commander_to_builder(builder: DeckBuilder, combined_commande """Attach combined commander metadata to the builder for downstream use.""" try: - builder.combined_commander = combined_commander + builder.combined_commander = combined_commander # type: ignore[attr-defined] except Exception: pass try: - builder.partner_mode = combined_commander.partner_mode + builder.partner_mode = combined_commander.partner_mode # type: ignore[attr-defined] except Exception: pass try: - builder.secondary_commander = combined_commander.secondary_name + builder.secondary_commander = combined_commander.secondary_name # type: ignore[attr-defined] except Exception: pass try: - builder.combined_color_identity = combined_commander.color_identity - builder.combined_theme_tags = combined_commander.theme_tags - builder.partner_warnings = combined_commander.warnings + builder.combined_color_identity = combined_commander.color_identity # type: ignore[attr-defined] + builder.combined_theme_tags = combined_commander.theme_tags # type: ignore[attr-defined] + builder.partner_warnings = combined_commander.warnings # type: ignore[attr-defined] except Exception: pass @@ -557,7 +553,7 @@ def _export_outputs(builder: DeckBuilder) -> None: # Persist for downstream reuse (e.g., random_entrypoint / reroll flows) so they don't re-export if csv_path: try: - builder.last_csv_path = csv_path + builder.last_csv_path = csv_path # type: ignore[attr-defined] except Exception: pass except Exception: @@ -572,7 +568,7 @@ def _export_outputs(builder: DeckBuilder) -> None: finally: if txt_generated: try: - builder.last_txt_path = txt_generated + builder.last_txt_path = txt_generated # type: ignore[attr-defined] except Exception: pass else: @@ -582,7 +578,7 @@ def _export_outputs(builder: DeckBuilder) -> None: finally: if txt_generated: try: - builder.last_txt_path = txt_generated + builder.last_txt_path = txt_generated # type: ignore[attr-defined] except Exception: pass except Exception: @@ -1196,7 +1192,7 @@ def _run_random_mode(config: RandomRunConfig) -> int: RandomConstraintsImpossibleError, RandomThemeNoMatchError, build_random_full_deck, - ) + ) # type: ignore except Exception as exc: print(f"Random mode unavailable: {exc}") return 1 diff --git a/code/main.py b/code/main.py index 3a719ba..d29011f 100644 --- a/code/main.py +++ b/code/main.py @@ -25,7 +25,6 @@ from file_setup.setup import initial_setup from tagging import tagger import logging_util from settings import CSV_DIRECTORY -from path_util import get_processed_cards_path # Create logger for this module logger = logging_util.logging.getLogger(__name__) @@ -41,24 +40,24 @@ def _ensure_data_ready() -> None: Path('deck_files').mkdir(parents=True, exist_ok=True) Path('logs').mkdir(parents=True, exist_ok=True) - # Ensure required Parquet file exists and is tagged before proceeding + # Ensure required CSVs exist and are tagged before proceeding try: import time import json as _json from datetime import datetime as _dt - parquet_path = get_processed_cards_path() + cards_path = os.path.join(CSV_DIRECTORY, 'cards.csv') flag_path = os.path.join(CSV_DIRECTORY, '.tagging_complete.json') refresh_needed = False - # Missing Parquet file forces refresh - if not os.path.exists(parquet_path): - logger.info("all_cards.parquet not found. Running initial setup and tagging...") + # Missing CSV forces refresh + if not os.path.exists(cards_path): + logger.info("cards.csv not found. Running initial setup and tagging...") refresh_needed = True else: - # Stale Parquet file (>7 days) forces refresh + # Stale CSV (>7 days) forces refresh try: - age_seconds = time.time() - os.path.getmtime(parquet_path) + age_seconds = time.time() - os.path.getmtime(cards_path) if age_seconds > 7 * 24 * 60 * 60: - logger.info("all_cards.parquet is older than 7 days. Refreshing data (setup + tagging)...") + logger.info("cards.csv is older than 7 days. Refreshing data (setup + tagging)...") refresh_needed = True except Exception: pass @@ -68,7 +67,7 @@ def _ensure_data_ready() -> None: refresh_needed = True if refresh_needed: initial_setup() - tagger.run_tagging(parallel=True) # Use parallel tagging for performance + tagger.run_tagging() # Write tagging completion flag try: os.makedirs(CSV_DIRECTORY, exist_ok=True) diff --git a/code/path_util.py b/code/path_util.py index 6fe77f0..184910f 100644 --- a/code/path_util.py +++ b/code/path_util.py @@ -7,8 +7,6 @@ def csv_dir() -> str: """Return the base directory for CSV files. Defaults to 'csv_files'. Override with CSV_FILES_DIR for tests or advanced setups. - - NOTE: DEPRECATED in v3.0.0 - Use card_files_dir() instead. """ try: base = os.getenv("CSV_FILES_DIR") @@ -16,84 +14,3 @@ def csv_dir() -> str: return base or "csv_files" except Exception: return "csv_files" - - -# New Parquet-based directory utilities (v3.0.0+) - -def card_files_dir() -> str: - """Return the base directory for card files (Parquet and metadata). - - Defaults to 'card_files'. Override with CARD_FILES_DIR environment variable. - """ - try: - base = os.getenv("CARD_FILES_DIR") - base = base.strip() if isinstance(base, str) else None - return base or "card_files" - except Exception: - return "card_files" - - -def card_files_raw_dir() -> str: - """Return the directory for raw MTGJSON Parquet files. - - Defaults to 'card_files/raw'. Override with CARD_FILES_RAW_DIR environment variable. - """ - try: - base = os.getenv("CARD_FILES_RAW_DIR") - base = base.strip() if isinstance(base, str) else None - return base or os.path.join(card_files_dir(), "raw") - except Exception: - return os.path.join(card_files_dir(), "raw") - - -def card_files_processed_dir() -> str: - """Return the directory for processed/tagged Parquet files. - - Defaults to 'card_files/processed'. Override with CARD_FILES_PROCESSED_DIR environment variable. - """ - try: - base = os.getenv("CARD_FILES_PROCESSED_DIR") - base = base.strip() if isinstance(base, str) else None - return base or os.path.join(card_files_dir(), "processed") - except Exception: - return os.path.join(card_files_dir(), "processed") - - -def get_raw_cards_path() -> str: - """Get the path to the raw MTGJSON Parquet file. - - Returns: - Path to card_files/raw/cards.parquet - """ - return os.path.join(card_files_raw_dir(), "cards.parquet") - - -def get_processed_cards_path() -> str: - """Get the path to the processed/tagged Parquet file. - - Returns: - Path to card_files/processed/all_cards.parquet - """ - return os.path.join(card_files_processed_dir(), "all_cards.parquet") - - -def get_commander_cards_path() -> str: - """Get the path to the pre-filtered commander-only Parquet file. - - Returns: - Path to card_files/processed/commander_cards.parquet - """ - return os.path.join(card_files_processed_dir(), "commander_cards.parquet") - - -def get_batch_path(batch_id: int) -> str: - """Get the path to a batch Parquet file. - - Args: - batch_id: Batch number (e.g., 0, 1, 2, ...) - - Returns: - Path to card_files/processed/batch_NNNN.parquet - """ - return os.path.join(card_files_processed_dir(), f"batch_{batch_id:04d}.parquet") - diff --git a/code/scripts/aggregate_cards.py b/code/scripts/aggregate_cards.py deleted file mode 100644 index 9e56100..0000000 --- a/code/scripts/aggregate_cards.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -""" -Aggregate Cards CLI Script - -Command-line interface for consolidating individual card CSV files into a single -Parquet file. Useful for manual aggregation runs, testing, and recovery. - -Usage: - python code/scripts/aggregate_cards.py - python code/scripts/aggregate_cards.py --source csv_files --output card_files/all_cards.parquet - python code/scripts/aggregate_cards.py --validate-only - python code/scripts/aggregate_cards.py --incremental -""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - -# Add project root to path for imports -project_root = Path(__file__).parent.parent.parent -sys.path.insert(0, str(project_root)) - -from code.file_setup.card_aggregator import CardAggregator -from code.logging_util import get_logger -from code.settings import CSV_DIRECTORY, CARD_FILES_DIRECTORY - -# Initialize logger -logger = get_logger(__name__) - - -def main() -> int: - """Main entry point for aggregate_cards CLI.""" - parser = argparse.ArgumentParser( - description="Aggregate individual card CSV files into consolidated Parquet file", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - parser.add_argument( - "--source", - "-s", - default=CSV_DIRECTORY, - help=f"Source directory containing card CSV files (default: {CSV_DIRECTORY})", - ) - - parser.add_argument( - "--output", - "-o", - default=None, - help="Output Parquet file path (default: card_files/all_cards.parquet)", - ) - - parser.add_argument( - "--output-dir", - default=CARD_FILES_DIRECTORY, - help=f"Output directory for Parquet files (default: {CARD_FILES_DIRECTORY})", - ) - - parser.add_argument( - "--validate-only", - action="store_true", - help="Only validate existing output file, don't aggregate", - ) - - parser.add_argument( - "--incremental", - "-i", - action="store_true", - help="Perform incremental update (only changed files)", - ) - - parser.add_argument( - "--keep-versions", - type=int, - default=3, - help="Number of historical versions to keep (default: 3)", - ) - - args = parser.parse_args() - - # Initialize aggregator - aggregator = CardAggregator(output_dir=args.output_dir) - - # Determine output path - output_path = args.output or f"{args.output_dir}/all_cards.parquet" - - try: - if args.validate_only: - # Validation only mode - logger.info(f"Validating {output_path}...") - is_valid, errors = aggregator.validate_output(output_path, args.source) - - if is_valid: - logger.info("✓ Validation passed") - return 0 - else: - logger.error("✗ Validation failed:") - for error in errors: - logger.error(f" - {error}") - return 1 - - elif args.incremental: - # Incremental update mode - logger.info("Starting incremental aggregation...") - metadata_path = f"{args.output_dir}/.aggregate_metadata.json" - changed_files = aggregator.detect_changes(args.source, metadata_path) - - if not changed_files: - logger.info("No changes detected, skipping aggregation") - return 0 - - stats = aggregator.incremental_update(changed_files, output_path) - - else: - # Full aggregation mode - logger.info("Starting full aggregation...") - stats = aggregator.aggregate_all(args.source, output_path) - - # Print summary - print("\n" + "=" * 60) - print("AGGREGATION SUMMARY") - print("=" * 60) - print(f"Files processed: {stats['files_processed']}") - print(f"Total cards: {stats['total_cards']:,}") - print(f"Duplicates removed: {stats['duplicates_removed']:,}") - print(f"File size: {stats['file_size_mb']:.2f} MB") - print(f"Time elapsed: {stats['elapsed_seconds']:.2f} seconds") - print(f"Output: {output_path}") - print("=" * 60) - - # Run validation - logger.info("\nValidating output...") - is_valid, errors = aggregator.validate_output(output_path, args.source) - - if is_valid: - logger.info("✓ Validation passed") - return 0 - else: - logger.error("✗ Validation failed:") - for error in errors: - logger.error(f" - {error}") - return 1 - - except FileNotFoundError as e: - logger.error(f"Error: {e}") - return 1 - except ValueError as e: - logger.error(f"Error: {e}") - return 1 - except Exception as e: - logger.error(f"Unexpected error: {e}") - import traceback - - traceback.print_exc() - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/code/scripts/audit_protection_full_v2.py b/code/scripts/audit_protection_full_v2.py new file mode 100644 index 0000000..a10d415 --- /dev/null +++ b/code/scripts/audit_protection_full_v2.py @@ -0,0 +1,203 @@ +""" +Full audit of Protection-tagged cards with kindred metadata support (M2 Phase 2). + +Created: October 8, 2025 +Purpose: Audit and validate Protection tag precision after implementing grant detection. + Can be re-run periodically to check tagging quality. + +This script audits ALL Protection-tagged cards and categorizes them: +- Grant: Gives broad protection to other permanents YOU control +- Kindred: Gives protection to specific creature types (metadata tags) +- Mixed: Both broad and kindred/inherent +- Inherent: Only has protection itself +- ConditionalSelf: Only conditionally grants to itself +- Opponent: Grants to opponent's permanents +- Neither: False positive + +Outputs: +- m2_audit_v2.json: Full analysis with summary +- m2_audit_v2_grant.csv: Cards for main Protection tag +- m2_audit_v2_kindred.csv: Cards for kindred metadata tags +- m2_audit_v2_mixed.csv: Cards with both broad and kindred grants +- m2_audit_v2_conditional.csv: Conditional self-grants (exclude) +- m2_audit_v2_inherent.csv: Inherent protection only (exclude) +- m2_audit_v2_opponent.csv: Opponent grants (exclude) +- m2_audit_v2_neither.csv: False positives (exclude) +- m2_audit_v2_all.csv: All cards combined +""" + +import sys +from pathlib import Path +import pandas as pd +import json + +# Add project root to path +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from code.tagging.protection_grant_detection import ( + categorize_protection_card, + get_kindred_protection_tags, + is_granting_protection, +) + +def load_all_cards(): + """Load all cards from color/identity CSV files.""" + csv_dir = project_root / 'csv_files' + + # Get all color/identity CSVs (not the raw cards.csv) + csv_files = list(csv_dir.glob('*_cards.csv')) + csv_files = [f for f in csv_files if f.stem not in ['cards', 'testdata']] + + all_cards = [] + for csv_file in csv_files: + try: + df = pd.read_csv(csv_file) + all_cards.append(df) + except Exception as e: + print(f"Warning: Could not load {csv_file.name}: {e}") + + # Combine all DataFrames + combined = pd.concat(all_cards, ignore_index=True) + + # Drop duplicates (cards appear in multiple color files) + combined = combined.drop_duplicates(subset=['name'], keep='first') + + return combined + +def audit_all_protection_cards(): + """Audit all Protection-tagged cards.""" + print("Loading all cards...") + df = load_all_cards() + + print(f"Total cards loaded: {len(df)}") + + # Filter to Protection-tagged cards (column is 'themeTags' in color CSVs) + df_prot = df[df['themeTags'].str.contains('Protection', case=False, na=False)].copy() + + print(f"Protection-tagged cards: {len(df_prot)}") + + # Categorize each card + categories = [] + grants_list = [] + kindred_tags_list = [] + + for idx, row in df_prot.iterrows(): + name = row['name'] + text = str(row.get('text', '')).replace('\\n', '\n') # Convert escaped newlines to real newlines + keywords = str(row.get('keywords', '')) + card_type = str(row.get('type', '')) + + # Categorize with kindred exclusion enabled + category = categorize_protection_card(name, text, keywords, card_type, exclude_kindred=True) + + # Check if it grants broadly + grants_broad = is_granting_protection(text, keywords, exclude_kindred=True) + + # Get kindred tags + kindred_tags = get_kindred_protection_tags(text) + + categories.append(category) + grants_list.append(grants_broad) + kindred_tags_list.append(', '.join(sorted(kindred_tags)) if kindred_tags else '') + + df_prot['category'] = categories + df_prot['grants_broad'] = grants_list + df_prot['kindred_tags'] = kindred_tags_list + + # Generate summary (convert numpy types to native Python for JSON serialization) + summary = { + 'total': int(len(df_prot)), + 'categories': {k: int(v) for k, v in df_prot['category'].value_counts().to_dict().items()}, + 'grants_broad_count': int(df_prot['grants_broad'].sum()), + 'kindred_cards_count': int((df_prot['kindred_tags'] != '').sum()), + } + + # Calculate keep vs remove + keep_categories = {'Grant', 'Mixed'} + kindred_only = df_prot[df_prot['category'] == 'Kindred'] + keep_count = len(df_prot[df_prot['category'].isin(keep_categories)]) + remove_count = len(df_prot[~df_prot['category'].isin(keep_categories | {'Kindred'})]) + + summary['keep_main_tag'] = keep_count + summary['kindred_metadata'] = len(kindred_only) + summary['remove'] = remove_count + summary['precision_estimate'] = round((keep_count / len(df_prot)) * 100, 1) if len(df_prot) > 0 else 0 + + # Print summary + print(f"\n{'='*60}") + print("AUDIT SUMMARY") + print(f"{'='*60}") + print(f"Total Protection-tagged cards: {summary['total']}") + print(f"\nCategories:") + for cat, count in sorted(summary['categories'].items()): + pct = (count / summary['total']) * 100 + print(f" {cat:20s} {count:4d} ({pct:5.1f}%)") + + print(f"\n{'='*60}") + print(f"Main Protection tag: {keep_count:4d} ({keep_count/len(df_prot)*100:5.1f}%)") + print(f"Kindred metadata only: {len(kindred_only):4d} ({len(kindred_only)/len(df_prot)*100:5.1f}%)") + print(f"Remove: {remove_count:4d} ({remove_count/len(df_prot)*100:5.1f}%)") + print(f"{'='*60}") + print(f"Precision estimate: {summary['precision_estimate']}%") + print(f"{'='*60}\n") + + # Export results + output_dir = project_root / 'logs' / 'roadmaps' / 'source' / 'tagging_refinement' + output_dir.mkdir(parents=True, exist_ok=True) + + # Export JSON summary + with open(output_dir / 'm2_audit_v2.json', 'w') as f: + json.dump({ + 'summary': summary, + 'cards': df_prot[['name', 'type', 'category', 'grants_broad', 'kindred_tags', 'keywords', 'text']].to_dict(orient='records') + }, f, indent=2) + + # Export CSVs by category + export_cols = ['name', 'type', 'category', 'grants_broad', 'kindred_tags', 'keywords', 'text'] + + # Grant category + df_grant = df_prot[df_prot['category'] == 'Grant'] + df_grant[export_cols].to_csv(output_dir / 'm2_audit_v2_grant.csv', index=False) + print(f"Exported {len(df_grant)} Grant cards to m2_audit_v2_grant.csv") + + # Kindred category + df_kindred = df_prot[df_prot['category'] == 'Kindred'] + df_kindred[export_cols].to_csv(output_dir / 'm2_audit_v2_kindred.csv', index=False) + print(f"Exported {len(df_kindred)} Kindred cards to m2_audit_v2_kindred.csv") + + # Mixed category + df_mixed = df_prot[df_prot['category'] == 'Mixed'] + df_mixed[export_cols].to_csv(output_dir / 'm2_audit_v2_mixed.csv', index=False) + print(f"Exported {len(df_mixed)} Mixed cards to m2_audit_v2_mixed.csv") + + # ConditionalSelf category + df_conditional = df_prot[df_prot['category'] == 'ConditionalSelf'] + df_conditional[export_cols].to_csv(output_dir / 'm2_audit_v2_conditional.csv', index=False) + print(f"Exported {len(df_conditional)} ConditionalSelf cards to m2_audit_v2_conditional.csv") + + # Inherent category + df_inherent = df_prot[df_prot['category'] == 'Inherent'] + df_inherent[export_cols].to_csv(output_dir / 'm2_audit_v2_inherent.csv', index=False) + print(f"Exported {len(df_inherent)} Inherent cards to m2_audit_v2_inherent.csv") + + # Opponent category + df_opponent = df_prot[df_prot['category'] == 'Opponent'] + df_opponent[export_cols].to_csv(output_dir / 'm2_audit_v2_opponent.csv', index=False) + print(f"Exported {len(df_opponent)} Opponent cards to m2_audit_v2_opponent.csv") + + # Neither category + df_neither = df_prot[df_prot['category'] == 'Neither'] + df_neither[export_cols].to_csv(output_dir / 'm2_audit_v2_neither.csv', index=False) + print(f"Exported {len(df_neither)} Neither cards to m2_audit_v2_neither.csv") + + # All cards + df_prot[export_cols].to_csv(output_dir / 'm2_audit_v2_all.csv', index=False) + print(f"Exported {len(df_prot)} total cards to m2_audit_v2_all.csv") + + print(f"\nAll files saved to: {output_dir}") + + return df_prot, summary + +if __name__ == '__main__': + df_results, summary = audit_all_protection_cards() diff --git a/code/scripts/benchmark_parquet.py b/code/scripts/benchmark_parquet.py deleted file mode 100644 index cb7ea9e..0000000 --- a/code/scripts/benchmark_parquet.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Benchmark Parquet vs CSV performance.""" - -import pandas as pd -import time -import os - -def benchmark_full_load(): - """Benchmark loading full dataset.""" - csv_path = 'csv_files/cards.csv' - parquet_path = 'csv_files/cards_parquet_test.parquet' - - print("=== FULL LOAD BENCHMARK ===\n") - - # CSV load - print("Loading CSV...") - start = time.time() - df_csv = pd.read_csv(csv_path, low_memory=False) - csv_time = time.time() - start - csv_rows = len(df_csv) - csv_memory = df_csv.memory_usage(deep=True).sum() / 1024 / 1024 - print(f" Time: {csv_time:.3f}s") - print(f" Rows: {csv_rows:,}") - print(f" Memory: {csv_memory:.2f} MB") - - # Parquet load - print("\nLoading Parquet...") - start = time.time() - df_parquet = pd.read_parquet(parquet_path) - parquet_time = time.time() - start - parquet_rows = len(df_parquet) - parquet_memory = df_parquet.memory_usage(deep=True).sum() / 1024 / 1024 - print(f" Time: {parquet_time:.3f}s") - print(f" Rows: {parquet_rows:,}") - print(f" Memory: {parquet_memory:.2f} MB") - - # Comparison - speedup = csv_time / parquet_time - memory_reduction = (1 - parquet_memory / csv_memory) * 100 - print(f"\n📊 Results:") - print(f" Speedup: {speedup:.2f}x faster") - print(f" Memory: {memory_reduction:.1f}% less") - - return df_csv, df_parquet - -def benchmark_column_selection(): - """Benchmark loading with column selection (Parquet optimization).""" - parquet_path = 'csv_files/cards_parquet_test.parquet' - - print("\n\n=== COLUMN SELECTION BENCHMARK (Parquet only) ===\n") - - # Essential columns for deck building - essential_columns = ['name', 'colorIdentity', 'type', 'types', 'manaValue', - 'manaCost', 'power', 'toughness', 'text', 'rarity'] - - # Full load - print("Loading all columns...") - start = time.time() - df_full = pd.read_parquet(parquet_path) - full_time = time.time() - start - full_memory = df_full.memory_usage(deep=True).sum() / 1024 / 1024 - print(f" Time: {full_time:.3f}s") - print(f" Columns: {len(df_full.columns)}") - print(f" Memory: {full_memory:.2f} MB") - - # Selective load - print(f"\nLoading {len(essential_columns)} essential columns...") - start = time.time() - df_selective = pd.read_parquet(parquet_path, columns=essential_columns) - selective_time = time.time() - start - selective_memory = df_selective.memory_usage(deep=True).sum() / 1024 / 1024 - print(f" Time: {selective_time:.3f}s") - print(f" Columns: {len(df_selective.columns)}") - print(f" Memory: {selective_memory:.2f} MB") - - # Comparison - speedup = full_time / selective_time - memory_reduction = (1 - selective_memory / full_memory) * 100 - print(f"\n📊 Results:") - print(f" Speedup: {speedup:.2f}x faster") - print(f" Memory: {memory_reduction:.1f}% less") - -def benchmark_filtering(): - """Benchmark filtering by colorIdentity (single file approach).""" - parquet_path = 'csv_files/cards_parquet_test.parquet' - - print("\n\n=== COLOR IDENTITY FILTERING BENCHMARK ===\n") - - # Load data - print("Loading Parquet with essential columns...") - essential_columns = ['name', 'colorIdentity', 'type', 'manaValue'] - start = time.time() - df = pd.read_parquet(parquet_path, columns=essential_columns) - load_time = time.time() - start - print(f" Load time: {load_time:.3f}s") - print(f" Total cards: {len(df):,}") - - # Test different color identities - test_cases = [ - ("Colorless (C)", ["C", ""]), - ("Mono-White (W)", ["W", "C", ""]), - ("Bant (GUW)", ["C", "", "G", "U", "W", "G,U", "G,W", "U,W", "G,U,W"]), - ("5-Color (WUBRG)", ["C", "", "W", "U", "B", "R", "G", - "W,U", "W,B", "W,R", "W,G", "U,B", "U,R", "U,G", "B,R", "B,G", "R,G", - "W,U,B", "W,U,R", "W,U,G", "W,B,R", "W,B,G", "W,R,G", "U,B,R", "U,B,G", "U,R,G", "B,R,G", - "W,U,B,R", "W,U,B,G", "W,U,R,G", "W,B,R,G", "U,B,R,G", - "W,U,B,R,G"]), - ] - - for test_name, valid_identities in test_cases: - print(f"\n{test_name}:") - start = time.time() - filtered = df[df['colorIdentity'].isin(valid_identities)] - filter_time = (time.time() - start) * 1000 # Convert to ms - print(f" Filter time: {filter_time:.1f}ms") - print(f" Cards found: {len(filtered):,}") - print(f" % of total: {len(filtered) / len(df) * 100:.1f}%") - -def benchmark_data_types(): - """Check data types and list handling.""" - parquet_path = 'csv_files/cards_parquet_test.parquet' - - print("\n\n=== DATA TYPE ANALYSIS ===\n") - - df = pd.read_parquet(parquet_path) - - # Check list-type columns - list_cols = [] - for col in df.columns: - sample = df[col].dropna().iloc[0] if df[col].notna().any() else None - if isinstance(sample, (list, tuple)): - list_cols.append(col) - - print(f"Columns stored as lists: {len(list_cols)}") - for col in list_cols: - sample = df[col].dropna().iloc[0] - print(f" {col}: {sample}") - - # Check critical columns for deck building - critical_cols = ['name', 'colorIdentity', 'type', 'types', 'subtypes', - 'manaValue', 'manaCost', 'text', 'keywords'] - - print(f"\n✓ Critical columns for deck building:") - for col in critical_cols: - if col in df.columns: - dtype = str(df[col].dtype) - null_pct = (df[col].isna().sum() / len(df)) * 100 - sample = df[col].dropna().iloc[0] if df[col].notna().any() else None - sample_type = type(sample).__name__ - print(f" {col:20s} dtype={dtype:10s} null={null_pct:5.1f}% sample_type={sample_type}") - -if __name__ == "__main__": - # Run benchmarks - df_csv, df_parquet = benchmark_full_load() - benchmark_column_selection() - benchmark_filtering() - benchmark_data_types() - - print("\n\n=== SUMMARY ===") - print("✅ All benchmarks complete!") - print("📁 File size: 77.2% smaller (88.94 MB → 20.27 MB)") diff --git a/code/scripts/build_similarity_cache_parquet.py b/code/scripts/build_similarity_cache_parquet.py deleted file mode 100644 index cc39f6d..0000000 --- a/code/scripts/build_similarity_cache_parquet.py +++ /dev/null @@ -1,446 +0,0 @@ -""" -Build similarity cache for all cards in the database using Parquet format. - -Pre-computes and stores similarity calculations for ~29k cards to improve -card detail page performance from 2-6s down to <500ms. - -NOTE: This script assumes card data and tagging are already complete. -Run setup and tagging separately before building the cache. - -Usage: - python -m code.scripts.build_similarity_cache_parquet [--parallel] [--checkpoint-interval 100] - -Options: - --parallel Enable parallel processing (faster but uses more CPU) - --checkpoint-interval Save cache every N cards (default: 100) - --force Rebuild cache even if it exists - --dry-run Calculate without saving (for testing) - --workers N Number of parallel workers (default: auto-detect) -""" - -import argparse -import logging -import sys -import time -import pandas as pd -from concurrent.futures import ProcessPoolExecutor, as_completed -from datetime import datetime -from pathlib import Path - -# Add project root to path -project_root = Path(__file__).parents[2] -sys.path.insert(0, str(project_root)) - -from code.web.services.card_similarity import CardSimilarity -from code.web.services.similarity_cache import SimilarityCache, get_cache - -# Setup logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - -# Shared data for worker processes (passed during initialization, not reloaded per worker) -_shared_cards_df = None -_shared_theme_frequencies = None -_shared_cleaned_tags = None -_worker_similarity = None - - -def _init_worker(cards_df_pickled: bytes, theme_frequencies: dict, cleaned_tags: dict): - """ - Initialize worker process with shared data. - Called once when each worker process starts. - - Args: - cards_df_pickled: Pickled DataFrame of all cards - theme_frequencies: Pre-computed theme frequency dict - cleaned_tags: Pre-computed cleaned tags cache - """ - import pickle - import logging - - global _shared_cards_df, _shared_theme_frequencies, _shared_cleaned_tags, _worker_similarity - - # Unpickle shared data once per worker - _shared_cards_df = pickle.loads(cards_df_pickled) - _shared_theme_frequencies = theme_frequencies - _shared_cleaned_tags = cleaned_tags - - # Create worker-level CardSimilarity instance with shared data - _worker_similarity = CardSimilarity(cards_df=_shared_cards_df) - - # Override pre-computed data to avoid recomputation - _worker_similarity.theme_frequencies = _shared_theme_frequencies - _worker_similarity.cleaned_tags_cache = _shared_cleaned_tags - - # Suppress verbose logging in workers - logging.getLogger("card_similarity").setLevel(logging.WARNING) - - -def calculate_similarity_for_card(args: tuple) -> tuple[str, list[dict], bool]: - """ - Calculate similarity for a single card (worker function for parallel processing). - - Args: - args: Tuple of (card_name, threshold, min_results, limit) - - Returns: - Tuple of (card_name, similar_cards, success) - """ - card_name, threshold, min_results, limit = args - - try: - # Use the global worker-level CardSimilarity instance - global _worker_similarity - if _worker_similarity is None: - # Fallback if initializer wasn't called (shouldn't happen) - _worker_similarity = CardSimilarity() - - # Calculate without using cache (we're building it) - similar_cards = _worker_similarity.find_similar( - card_name=card_name, - threshold=threshold, - min_results=min_results, - limit=limit, - adaptive=True, - use_cache=False, - ) - - return card_name, similar_cards, True - - except Exception as e: - logger.error(f"Failed to calculate similarity for '{card_name}': {e}") - return card_name, [], False - - -def _add_results_to_cache(cache_df: pd.DataFrame, card_name: str, similar_cards: list[dict]) -> pd.DataFrame: - """ - Add similarity results for a card to the cache DataFrame. - - Args: - cache_df: Existing cache DataFrame - card_name: Name of the card - similar_cards: List of similar cards with scores - - Returns: - Updated DataFrame - """ - # Build new rows - new_rows = [] - for rank, card in enumerate(similar_cards): - new_rows.append({ - "card_name": card_name, - "similar_name": card["name"], - "similarity": card["similarity"], - "edhrecRank": card.get("edhrecRank", float("inf")), - "rank": rank, - }) - - if new_rows: - new_df = pd.DataFrame(new_rows) - cache_df = pd.concat([cache_df, new_df], ignore_index=True) - - return cache_df - - -def build_cache( - parallel: bool = False, - workers: int | None = None, - checkpoint_interval: int = 100, - force: bool = False, - dry_run: bool = False, -) -> None: - """ - Build similarity cache for all cards. - - NOTE: Assumes card data (card_files/processed/all_cards.parquet) and tagged data already exist. - Run setup and tagging separately before building cache. - - Args: - parallel: Enable parallel processing - workers: Number of parallel workers (None = auto-detect) - checkpoint_interval: Save cache every N cards - force: Rebuild even if cache exists - dry_run: Calculate without saving - """ - logger.info("=" * 80) - logger.info("Similarity Cache Builder (Parquet Edition)") - logger.info("=" * 80) - logger.info("") - - # Initialize cache - cache = get_cache() - - # Quick check for complete cache - if metadata says build is done, exit - if not force and cache.cache_path.exists() and not dry_run: - metadata = cache._metadata or {} - is_complete = metadata.get("build_complete", False) - - if is_complete: - stats = cache.get_stats() - logger.info(f"Cache already complete with {stats['total_cards']:,} cards") - logger.info("Use --force to rebuild") - return - else: - stats = cache.get_stats() - logger.info(f"Resuming incomplete cache with {stats['total_cards']:,} cards") - - if dry_run: - logger.info("DRY RUN MODE - No changes will be saved") - logger.info("") - - # Initialize similarity engine - logger.info("Initializing similarity engine...") - similarity = CardSimilarity() - total_cards = len(similarity.cards_df) - logger.info(f"Loaded {total_cards:,} cards") - logger.info("") - - # Filter out low-value lands (single-sided with <3 tags) - df = similarity.cards_df - df["is_land"] = df["type"].str.contains("Land", case=False, na=False) - df["is_multifaced"] = df["layout"].str.lower().isin(["modal_dfc", "transform", "reversible_card", "double_faced_token"]) - # M4: themeTags is now a list (Parquet format), not a pipe-delimited string - df["tag_count"] = df["themeTags"].apply(lambda x: len(x) if isinstance(x, list) else 0) - - # Keep cards that are either: - # 1. Not lands, OR - # 2. Multi-faced lands, OR - # 3. Single-sided lands with >= 3 tags - keep_mask = (~df["is_land"]) | (df["is_multifaced"]) | (df["is_land"] & (df["tag_count"] >= 3)) - - card_names = df[keep_mask]["name"].tolist() - skipped_lands = (~keep_mask & df["is_land"]).sum() - - logger.info(f"Filtered out {skipped_lands} low-value lands (single-sided with <3 tags)") - logger.info(f"Processing {len(card_names):,} cards ({len(card_names)/total_cards*100:.1f}% of total)") - logger.info("") - - # Configuration for similarity calculation - threshold = 0.8 - min_results = 3 - limit = 20 # Cache up to 20 similar cards per card for variety - - # Initialize cache data structure - try to load existing for resume - existing_cache_df = cache.load_cache() - already_processed = set() - - if len(existing_cache_df) > 0 and not dry_run: - # Resume from checkpoint - keep existing data - cache_df = existing_cache_df - already_processed = set(existing_cache_df["card_name"].unique()) - logger.info(f"Resuming from checkpoint with {len(already_processed):,} cards already processed") - - # Setup metadata - metadata = cache._metadata or cache._empty_metadata() - else: - # Start fresh - cache_df = cache._empty_cache_df() - metadata = cache._empty_metadata() - metadata["build_date"] = datetime.now().isoformat() - metadata["threshold"] = threshold - metadata["min_results"] = min_results - - # Track stats - start_time = time.time() - processed = len(already_processed) # Start count from checkpoint - failed = 0 - checkpoint_count = 0 - - try: - if parallel: - # Parallel processing - use available CPU cores - import os - import pickle - - if workers is not None: - max_workers = max(1, workers) # User-specified, minimum 1 - logger.info(f"Using {max_workers} worker processes (user-specified)") - else: - cpu_count = os.cpu_count() or 4 - # Use CPU count - 1 to leave one core for system, minimum 4 - max_workers = max(4, cpu_count - 1) - logger.info(f"Detected {cpu_count} CPUs, using {max_workers} worker processes") - - # Prepare shared data (pickle DataFrame once, share with all workers) - logger.info("Preparing shared data for workers...") - cards_df_pickled = pickle.dumps(similarity.cards_df) - theme_frequencies = similarity.theme_frequencies.copy() - cleaned_tags = similarity.cleaned_tags_cache.copy() - logger.info(f"Shared data prepared: {len(cards_df_pickled):,} bytes (DataFrame), " - f"{len(theme_frequencies)} themes, {len(cleaned_tags)} cleaned tag sets") - - # Prepare arguments for cards not yet processed - cards_to_process = [name for name in card_names if name not in already_processed] - logger.info(f"Cards to process: {len(cards_to_process):,} (skipping {len(already_processed):,} already done)") - - card_args = [(name, threshold, min_results, limit) for name in cards_to_process] - - with ProcessPoolExecutor( - max_workers=max_workers, - initializer=_init_worker, - initargs=(cards_df_pickled, theme_frequencies, cleaned_tags) - ) as executor: - # Submit all tasks - future_to_card = { - executor.submit(calculate_similarity_for_card, args): args[0] - for args in card_args - } - - # Process results as they complete - for future in as_completed(future_to_card): - card_name, similar_cards, success = future.result() - - if success: - cache_df = _add_results_to_cache(cache_df, card_name, similar_cards) - processed += 1 - else: - failed += 1 - - # Progress reporting - total_to_process = len(card_names) - if processed % 100 == 0: - elapsed = time.time() - start_time - # Calculate rate based on cards processed THIS session - cards_this_session = processed - len(already_processed) - rate = cards_this_session / elapsed if elapsed > 0 else 0 - cards_remaining = total_to_process - processed - eta = cards_remaining / rate if rate > 0 else 0 - logger.info( - f"Progress: {processed}/{total_to_process} " - f"({processed/total_to_process*100:.1f}%) - " - f"Rate: {rate:.1f} cards/sec - " - f"ETA: {eta/60:.1f} min" - ) - - # Checkpoint save - if not dry_run and processed % checkpoint_interval == 0: - checkpoint_count += 1 - cache.save_cache(cache_df, metadata) - logger.info(f"Checkpoint {checkpoint_count}: Saved cache with {processed:,} cards") - - else: - # Serial processing - skip already processed cards - cards_to_process = [name for name in card_names if name not in already_processed] - logger.info(f"Cards to process: {len(cards_to_process):,} (skipping {len(already_processed):,} already done)") - - for i, card_name in enumerate(cards_to_process, start=1): - try: - similar_cards = similarity.find_similar( - card_name=card_name, - threshold=threshold, - min_results=min_results, - limit=limit, - adaptive=True, - use_cache=False, - ) - - cache_df = _add_results_to_cache(cache_df, card_name, similar_cards) - processed += 1 - - except Exception as e: - logger.error(f"Failed to process '{card_name}': {e}") - failed += 1 - - # Progress reporting - if i % 100 == 0: - elapsed = time.time() - start_time - rate = i / elapsed if elapsed > 0 else 0 - cards_remaining = len(card_names) - i - eta = cards_remaining / rate if rate > 0 else 0 - logger.info( - f"Progress: {i}/{len(card_names)} " - f"({i/len(card_names)*100:.1f}%) - " - f"Rate: {rate:.1f} cards/sec - " - f"ETA: {eta/60:.1f} min" - ) - - # Checkpoint save - if not dry_run and i % checkpoint_interval == 0: - checkpoint_count += 1 - cache.save_cache(cache_df, metadata) - logger.info(f"Checkpoint {checkpoint_count}: Saved cache with {i:,} cards") - - # Final save - if not dry_run: - metadata["last_updated"] = datetime.now().isoformat() - metadata["build_complete"] = True - cache.save_cache(cache_df, metadata) - - # Summary - elapsed = time.time() - start_time - logger.info("") - logger.info("=" * 80) - logger.info("Build Complete") - logger.info("=" * 80) - logger.info(f"Total time: {elapsed/60:.2f} minutes") - logger.info(f"Cards processed: {processed:,}") - logger.info(f"Failed: {failed}") - logger.info(f"Checkpoints saved: {checkpoint_count}") - - if processed > 0: - logger.info(f"Average rate: {processed/elapsed:.2f} cards/sec") - - if not dry_run: - stats = cache.get_stats() - logger.info(f"Cache file size: {stats.get('file_size_mb', 0):.2f} MB") - logger.info(f"Cache location: {cache.cache_path}") - - except KeyboardInterrupt: - logger.warning("\nBuild interrupted by user") - - # Save partial cache - if not dry_run and len(cache_df) > 0: - metadata["last_updated"] = datetime.now().isoformat() - cache.save_cache(cache_df, metadata) - logger.info(f"Saved partial cache with {processed:,} cards") - - -def main(): - """CLI entry point.""" - parser = argparse.ArgumentParser( - description="Build similarity cache for all cards (Parquet format)" - ) - parser.add_argument( - "--parallel", - action="store_true", - help="Enable parallel processing", - ) - parser.add_argument( - "--workers", - type=int, - default=None, - help="Number of parallel workers (default: auto-detect)", - ) - parser.add_argument( - "--checkpoint-interval", - type=int, - default=100, - help="Save cache every N cards (default: 100)", - ) - parser.add_argument( - "--force", - action="store_true", - help="Rebuild cache even if it exists", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Calculate without saving (for testing)", - ) - - args = parser.parse_args() - - build_cache( - parallel=args.parallel, - workers=args.workers, - checkpoint_interval=args.checkpoint_interval, - force=args.force, - dry_run=args.dry_run, - ) - - -if __name__ == "__main__": - main() diff --git a/code/scripts/build_theme_catalog.py b/code/scripts/build_theme_catalog.py index 4f2f722..f638094 100644 --- a/code/scripts/build_theme_catalog.py +++ b/code/scripts/build_theme_catalog.py @@ -36,7 +36,7 @@ except Exception: # pragma: no cover try: # Support running as `python code/scripts/build_theme_catalog.py` when 'code' already on path - from scripts.extract_themes import ( + from scripts.extract_themes import ( # type: ignore BASE_COLORS, collect_theme_tags_from_constants, collect_theme_tags_from_tagger_source, @@ -51,7 +51,7 @@ try: ) except ModuleNotFoundError: # Fallback: direct relative import when running within scripts package context - from extract_themes import ( + from extract_themes import ( # type: ignore BASE_COLORS, collect_theme_tags_from_constants, collect_theme_tags_from_tagger_source, @@ -66,7 +66,7 @@ except ModuleNotFoundError: ) try: - from scripts.export_themes_to_yaml import slugify as slugify_theme + from scripts.export_themes_to_yaml import slugify as slugify_theme # type: ignore except Exception: _SLUG_RE = re.compile(r'[^a-z0-9-]') @@ -730,18 +730,6 @@ def build_catalog(limit: int, verbose: bool) -> Dict[str, Any]: merged = [s for s in merged if s not in special_noise] # If theme is one of the special ones, keep the other if present (no action needed beyond above filter logic). - # Land type theme filtering: Gates/Caves/Spheres are land types, not artifact/token mechanics. - # Rationale: These themes tag specific land cards, creating spurious correlations with artifact/token - # themes when those cards happen to also produce artifacts/tokens (e.g., Tireless Tracker in Gates decks). - # Filter out artifact/token synergies that don't make thematic sense for land-type-matters strategies. - land_type_themes = {"Gates Matter"} - incompatible_with_land_types = { - "Investigate", "Clue Token", "Detective Kindred" - } - if theme in land_type_themes: - merged = [s for s in merged if s not in incompatible_with_land_types] - # For non-land-type themes, don't filter (they can legitimately synergize with these) - if synergy_cap > 0 and len(merged) > synergy_cap: ce_len = len(curated_list) + len([s for s in enforced_list if s not in curated_list]) if ce_len < synergy_cap: @@ -951,7 +939,7 @@ def main(): # pragma: no cover if args.schema: # Lazy import to avoid circular dependency: replicate minimal schema inline from models file if present try: - from type_definitions_theme_catalog import ThemeCatalog + from type_definitions_theme_catalog import ThemeCatalog # type: ignore import json as _json print(_json.dumps(ThemeCatalog.model_json_schema(), indent=2)) return @@ -990,8 +978,8 @@ def main(): # pragma: no cover # Safeguard: if catalog dir missing, attempt to auto-export Phase A YAML first if not CATALOG_DIR.exists(): # pragma: no cover (environmental) try: - from scripts.export_themes_to_yaml import main as export_main - export_main(['--force']) + from scripts.export_themes_to_yaml import main as export_main # type: ignore + export_main(['--force']) # type: ignore[arg-type] except Exception as _e: print(f"[build_theme_catalog] WARNING: catalog dir missing and auto export failed: {_e}", file=sys.stderr) if yaml is None: @@ -1013,7 +1001,7 @@ def main(): # pragma: no cover meta_block = raw.get('metadata_info') if isinstance(raw.get('metadata_info'), dict) else {} # Legacy migration: if no metadata_info but legacy provenance present, adopt it if not meta_block and isinstance(raw.get('provenance'), dict): - meta_block = raw.get('provenance') + meta_block = raw.get('provenance') # type: ignore changed = True if force or not meta_block.get('last_backfill'): meta_block['last_backfill'] = time.strftime('%Y-%m-%dT%H:%M:%S') diff --git a/code/scripts/check_random_theme_perf.py b/code/scripts/check_random_theme_perf.py new file mode 100644 index 0000000..5b739e5 --- /dev/null +++ b/code/scripts/check_random_theme_perf.py @@ -0,0 +1,118 @@ +"""Opt-in guard that compares multi-theme filter performance to a stored baseline. + +Run inside the project virtual environment: + + python -m code.scripts.check_random_theme_perf --baseline config/random_theme_perf_baseline.json + +The script executes the same profiling loop as `profile_multi_theme_filter` and fails +if the observed mean or p95 timings regress more than the allowed threshold. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, Tuple + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BASELINE = PROJECT_ROOT / "config" / "random_theme_perf_baseline.json" + +if str(PROJECT_ROOT) not in sys.path: + sys.path.append(str(PROJECT_ROOT)) + +from code.scripts.profile_multi_theme_filter import run_profile # type: ignore # noqa: E402 + + +def _load_baseline(path: Path) -> Dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(f"Baseline file not found: {path}") + data = json.loads(path.read_text(encoding="utf-8")) + return data + + +def _extract(metric: Dict[str, Any], key: str) -> float: + try: + value = float(metric.get(key, 0.0)) + except Exception: + value = 0.0 + return value + + +def _check_section(name: str, actual: Dict[str, Any], baseline: Dict[str, Any], threshold: float) -> Tuple[bool, str]: + a_mean = _extract(actual, "mean_ms") + b_mean = _extract(baseline, "mean_ms") + a_p95 = _extract(actual, "p95_ms") + b_p95 = _extract(baseline, "p95_ms") + + allowed_mean = b_mean * (1.0 + threshold) + allowed_p95 = b_p95 * (1.0 + threshold) + + mean_ok = a_mean <= allowed_mean or b_mean == 0.0 + p95_ok = a_p95 <= allowed_p95 or b_p95 == 0.0 + + status = mean_ok and p95_ok + + def _format_row(label: str, actual_val: float, baseline_val: float, allowed_val: float, ok: bool) -> str: + trend = ((actual_val - baseline_val) / baseline_val * 100.0) if baseline_val else 0.0 + trend_str = f"{trend:+.1f}%" if baseline_val else "n/a" + limit_str = f"≤ {allowed_val:.3f}ms" if baseline_val else "n/a" + return f" {label:<6} actual={actual_val:.3f}ms baseline={baseline_val:.3f}ms ({trend_str}), limit {limit_str} -> {'OK' if ok else 'FAIL'}" + + rows = [f"Section: {name}"] + rows.append(_format_row("mean", a_mean, b_mean, allowed_mean, mean_ok)) + rows.append(_format_row("p95", a_p95, b_p95, allowed_p95, p95_ok)) + return status, "\n".join(rows) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Check multi-theme filtering performance against a baseline") + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE, help="Baseline JSON file (default: config/random_theme_perf_baseline.json)") + parser.add_argument("--iterations", type=int, default=400, help="Number of iterations to sample (default: 400)") + parser.add_argument("--seed", type=int, default=None, help="Optional RNG seed for reproducibility") + parser.add_argument("--threshold", type=float, default=0.15, help="Allowed regression threshold as a fraction (default: 0.15 = 15%)") + parser.add_argument("--update-baseline", action="store_true", help="Overwrite the baseline file with the newly collected metrics") + args = parser.parse_args(argv) + + baseline_path = args.baseline if args.baseline else DEFAULT_BASELINE + if args.update_baseline and not baseline_path.parent.exists(): + baseline_path.parent.mkdir(parents=True, exist_ok=True) + + if not args.update_baseline: + baseline = _load_baseline(baseline_path) + else: + baseline = {} + + results = run_profile(args.iterations, args.seed) + + cascade_status, cascade_report = _check_section("cascade", results.get("cascade", {}), baseline.get("cascade", {}), args.threshold) + synergy_status, synergy_report = _check_section("synergy", results.get("synergy", {}), baseline.get("synergy", {}), args.threshold) + + print("Iterations:", results.get("iterations")) + print("Seed:", results.get("seed")) + print(cascade_report) + print(synergy_report) + + overall_ok = cascade_status and synergy_status + + if args.update_baseline: + payload = { + "iterations": results.get("iterations"), + "seed": results.get("seed"), + "cascade": results.get("cascade"), + "synergy": results.get("synergy"), + } + baseline_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(f"Baseline updated → {baseline_path}") + return 0 + + if not overall_ok: + print(f"FAIL: performance regressions exceeded {args.threshold * 100:.1f}% threshold", file=sys.stderr) + return 1 + + print("PASS: performance within allowed threshold") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/code/scripts/enrich_themes.py b/code/scripts/enrich_themes.py deleted file mode 100644 index a52348c..0000000 --- a/code/scripts/enrich_themes.py +++ /dev/null @@ -1,135 +0,0 @@ -"""CLI wrapper for theme enrichment pipeline. - -Runs the consolidated theme enrichment pipeline with command-line options. -For backward compatibility, individual scripts can still be run separately, -but this provides a faster single-pass alternative. - -Usage: - python code/scripts/enrich_themes.py --write - python code/scripts/enrich_themes.py --dry-run --enforce-min -""" -from __future__ import annotations - -import argparse -import os -import sys -from pathlib import Path - -# Add project root to path -ROOT = Path(__file__).resolve().parents[2] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -# Import after adding to path -from code.tagging.theme_enrichment import run_enrichment_pipeline # noqa: E402 - - -def main() -> int: - """Run theme enrichment pipeline from CLI.""" - parser = argparse.ArgumentParser( - description='Consolidated theme metadata enrichment pipeline', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Dry run (no changes written): - python code/scripts/enrich_themes.py --dry-run - - # Write changes: - python code/scripts/enrich_themes.py --write - - # Enforce minimum examples (errors if insufficient): - python code/scripts/enrich_themes.py --write --enforce-min - - # Strict validation for cornerstone themes: - python code/scripts/enrich_themes.py --write --strict - -Note: This replaces running 7 separate scripts (autofill, pad, cleanup, purge, -augment, suggestions, lint) with a single 5-10x faster operation. - """ - ) - - parser.add_argument( - '--write', - action='store_true', - help='Write changes to disk (default: dry run)' - ) - parser.add_argument( - '--dry-run', - action='store_true', - help='Dry run mode: show what would be changed without writing' - ) - parser.add_argument( - '--min', - '--min-examples', - type=int, - default=None, - metavar='N', - help='Minimum number of example commanders (default: $EDITORIAL_MIN_EXAMPLES or 5)' - ) - parser.add_argument( - '--enforce-min', - action='store_true', - help='Treat minimum examples violations as errors' - ) - parser.add_argument( - '--strict', - action='store_true', - help='Enable strict validation (cornerstone themes must have examples)' - ) - - args = parser.parse_args() - - # Determine write mode - if args.dry_run: - write = False - elif args.write: - write = True - else: - # Default to dry run if neither specified - write = False - print("Note: Running in dry-run mode (use --write to save changes)\n") - - # Get minimum examples threshold - if args.min is not None: - min_examples = args.min - else: - min_examples = int(os.environ.get('EDITORIAL_MIN_EXAMPLES', '5')) - - print("Theme Enrichment Pipeline") - print("========================") - print(f"Mode: {'WRITE' if write else 'DRY RUN'}") - print(f"Min examples: {min_examples}") - print(f"Enforce min: {args.enforce_min}") - print(f"Strict: {args.strict}") - print() - - try: - stats = run_enrichment_pipeline( - root=ROOT, - min_examples=min_examples, - write=write, - enforce_min=args.enforce_min, - strict=args.strict, - progress_callback=None, # Use default print - ) - - # Return non-zero if there are lint errors - if stats.lint_errors > 0: - print(f"\n❌ Enrichment completed with {stats.lint_errors} error(s)") - return 1 - - print("\n✅ Enrichment completed successfully") - return 0 - - except KeyboardInterrupt: - print("\n\nInterrupted by user") - return 130 - except Exception as e: - print(f"\n❌ Error: {e}", file=sys.stderr) - if '--debug' in sys.argv: - raise - return 1 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/code/scripts/export_themes_to_yaml.py b/code/scripts/export_themes_to_yaml.py index 6f1d904..524799a 100644 --- a/code/scripts/export_themes_to_yaml.py +++ b/code/scripts/export_themes_to_yaml.py @@ -41,7 +41,7 @@ SCRIPT_ROOT = Path(__file__).resolve().parent CODE_ROOT = SCRIPT_ROOT.parent if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) -from scripts.extract_themes import derive_synergies_for_tags +from scripts.extract_themes import derive_synergies_for_tags # type: ignore ROOT = Path(__file__).resolve().parents[2] THEME_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' @@ -123,9 +123,6 @@ def main(): enforced_set = set(enforced_synergies) inferred_synergies = [s for s in synergy_list if s not in curated_set and s not in enforced_set] - example_cards_value = entry.get('example_cards', []) - example_commanders_value = entry.get('example_commanders', []) - doc = { 'id': slug, 'display_name': theme_name, @@ -135,40 +132,13 @@ def main(): 'inferred_synergies': inferred_synergies, 'primary_color': entry.get('primary_color'), 'secondary_color': entry.get('secondary_color'), - 'example_cards': example_cards_value, - 'example_commanders': example_commanders_value, - 'synergy_example_cards': entry.get('synergy_example_cards', []), - 'synergy_commanders': entry.get('synergy_commanders', []), - 'deck_archetype': entry.get('deck_archetype'), - 'popularity_hint': entry.get('popularity_hint'), - 'popularity_bucket': entry.get('popularity_bucket'), - 'editorial_quality': entry.get('editorial_quality'), - 'description': entry.get('description'), 'notes': '' } - # Drop None/empty keys for cleanliness + # Drop None color keys for cleanliness if doc['primary_color'] is None: doc.pop('primary_color') if doc.get('secondary_color') is None: doc.pop('secondary_color') - if not doc.get('example_cards'): - doc.pop('example_cards') - if not doc.get('example_commanders'): - doc.pop('example_commanders') - if not doc.get('synergy_example_cards'): - doc.pop('synergy_example_cards') - if not doc.get('synergy_commanders'): - doc.pop('synergy_commanders') - if doc.get('deck_archetype') is None: - doc.pop('deck_archetype') - if doc.get('popularity_hint') is None: - doc.pop('popularity_hint') - if doc.get('popularity_bucket') is None: - doc.pop('popularity_bucket') - if doc.get('editorial_quality') is None: - doc.pop('editorial_quality') - if doc.get('description') is None: - doc.pop('description') with path.open('w', encoding='utf-8') as f: yaml.safe_dump(doc, f, sort_keys=False, allow_unicode=True) exported += 1 diff --git a/code/scripts/extract_themes.py b/code/scripts/extract_themes.py index c4c1216..d3b4fdc 100644 --- a/code/scripts/extract_themes.py +++ b/code/scripts/extract_themes.py @@ -18,8 +18,8 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) if ROOT not in sys.path: sys.path.insert(0, ROOT) -from code.settings import CSV_DIRECTORY -from code.tagging import tag_constants +from code.settings import CSV_DIRECTORY # type: ignore +from code.tagging import tag_constants # type: ignore BASE_COLORS = { 'white': 'W', @@ -126,7 +126,7 @@ def tally_tag_frequencies_by_base_color() -> Dict[str, Dict[str, int]]: return derived # Iterate rows for _, row in df.iterrows(): - tags = list(row['themeTags']) if hasattr(row.get('themeTags'), '__len__') and not isinstance(row.get('themeTags'), str) else [] + tags = row['themeTags'] if isinstance(row['themeTags'], list) else [] # Compute base colors contribution ci = row['colorIdentity'] if 'colorIdentity' in row else None letters = set(ci) if isinstance(ci, list) else set() @@ -162,7 +162,7 @@ def gather_theme_tag_rows() -> List[List[str]]: if 'themeTags' not in df.columns: continue for _, row in df.iterrows(): - tags = list(row['themeTags']) if hasattr(row.get('themeTags'), '__len__') and not isinstance(row.get('themeTags'), str) else [] + tags = row['themeTags'] if isinstance(row['themeTags'], list) else [] if tags: rows.append(tags) return rows @@ -523,4 +523,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/code/scripts/generate_theme_catalog.py b/code/scripts/generate_theme_catalog.py index 0ee68d4..622de89 100644 --- a/code/scripts/generate_theme_catalog.py +++ b/code/scripts/generate_theme_catalog.py @@ -19,26 +19,16 @@ from datetime import datetime, timezone from pathlib import Path from typing import Dict, Iterable, List, Optional, Sequence -try: - import pandas as pd - HAS_PANDAS = True -except ImportError: - HAS_PANDAS = False - pd = None # type: ignore - ROOT = Path(__file__).resolve().parents[2] CODE_ROOT = ROOT / "code" if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) try: - from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY + from code.settings import CSV_DIRECTORY as DEFAULT_CSV_DIRECTORY # type: ignore except Exception: # pragma: no cover - fallback for adhoc execution DEFAULT_CSV_DIRECTORY = "csv_files" -# Parquet support requires pandas (imported at top of file, uses pyarrow under the hood) -HAS_PARQUET_SUPPORT = HAS_PANDAS - DEFAULT_OUTPUT_PATH = ROOT / "config" / "themes" / "theme_catalog.csv" HEADER_COMMENT_PREFIX = "# theme_catalog" @@ -73,12 +63,6 @@ def canonical_key(raw: str) -> str: def parse_theme_tags(value: object) -> List[str]: if value is None: return [] - # Handle numpy arrays (from Parquet files) - if hasattr(value, '__array__') or hasattr(value, 'tolist'): - try: - value = value.tolist() if hasattr(value, 'tolist') else list(value) - except Exception: - pass if isinstance(value, list): return [str(v) for v in value if isinstance(v, str) and v.strip()] if isinstance(value, str): @@ -103,77 +87,33 @@ def parse_theme_tags(value: object) -> List[str]: return [] -def _load_theme_counts_from_parquet( - parquet_path: Path, - theme_variants: Dict[str, set[str]] -) -> Counter[str]: - """Load theme counts from a parquet file using pandas (which uses pyarrow). - - Args: - parquet_path: Path to the parquet file (commander_cards.parquet or all_cards.parquet) - theme_variants: Dict to accumulate theme name variants - - Returns: - Counter of theme occurrences - """ - if pd is None: - print(" pandas not available, skipping parquet load") - return Counter() - +def _load_theme_counts(csv_path: Path, theme_variants: Dict[str, set[str]]) -> Counter[str]: counts: Counter[str] = Counter() - - if not parquet_path.exists(): - print(f" Parquet file does not exist: {parquet_path}") + if not csv_path.exists(): return counts - - # Read only themeTags column for efficiency - try: - df = pd.read_parquet(parquet_path, columns=["themeTags"]) - print(f" Loaded {len(df)} rows from parquet") - except Exception as e: - # If themeTags column doesn't exist, return empty - print(f" Failed to read themeTags column: {e}") - return counts - - # Convert to list for fast iteration (faster than iterrows) - theme_tags_list = df["themeTags"].tolist() - - # Debug: check first few entries - non_empty_count = 0 - for i, raw_value in enumerate(theme_tags_list[:10]): - if raw_value is not None and not (isinstance(raw_value, float) and pd.isna(raw_value)): - non_empty_count += 1 - if i < 3: # Show first 3 non-empty - print(f" Sample tag {i}: {raw_value!r} (type: {type(raw_value).__name__})") - - if non_empty_count == 0: - print(" WARNING: No non-empty themeTags found in first 10 rows") - - for raw_value in theme_tags_list: - if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)): - continue - tags = parse_theme_tags(raw_value) - if not tags: - continue - seen_in_row: set[str] = set() - for tag in tags: - display = normalize_theme_display(tag) - if not display: + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames or "themeTags" not in reader.fieldnames: + return counts + for row in reader: + raw_value = row.get("themeTags") + tags = parse_theme_tags(raw_value) + if not tags: continue - key = canonical_key(display) - if key in seen_in_row: - continue - seen_in_row.add(key) - counts[key] += 1 - theme_variants[key].add(display) - - print(f" Found {len(counts)} unique themes from parquet") + seen_in_row: set[str] = set() + for tag in tags: + display = normalize_theme_display(tag) + if not display: + continue + key = canonical_key(display) + if key in seen_in_row: + continue + seen_in_row.add(key) + counts[key] += 1 + theme_variants[key].add(display) return counts -# CSV fallback removed in M4 migration - Parquet is now required - - def _select_display_name(options: Sequence[str]) -> str: if not options: return "" @@ -203,95 +143,27 @@ def build_theme_catalog( output_path: Path, *, generated_at: Optional[datetime] = None, + commander_filename: str = "commander_cards.csv", + cards_filename: str = "cards.csv", logs_directory: Optional[Path] = None, - min_card_count: int = 3, ) -> CatalogBuildResult: - """Build theme catalog from Parquet card data. - - Args: - csv_directory: Base directory (used to locate card_files/processed/all_cards.parquet) - output_path: Where to write the catalog CSV - generated_at: Optional timestamp for generation - logs_directory: Optional directory to copy output to - min_card_count: Minimum number of cards required to include theme (default: 3) - - Returns: - CatalogBuildResult with generated rows and metadata - - Raises: - RuntimeError: If pandas/pyarrow not available - FileNotFoundError: If all_cards.parquet doesn't exist - RuntimeError: If no theme tags found in Parquet file - """ csv_directory = csv_directory.resolve() output_path = output_path.resolve() theme_variants: Dict[str, set[str]] = defaultdict(set) - # Parquet-only mode (M4 migration: CSV files removed) - if not HAS_PARQUET_SUPPORT: - raise RuntimeError( - "Pandas is required for theme catalog generation. " - "Install with: pip install pandas pyarrow" - ) - - # Use processed parquet files (M4 migration) - parquet_dir = csv_directory.parent / "card_files" / "processed" - all_cards_parquet = parquet_dir / "all_cards.parquet" - - print(f"Loading theme data from parquet: {all_cards_parquet}") - print(f" File exists: {all_cards_parquet.exists()}") - - if not all_cards_parquet.exists(): - raise FileNotFoundError( - f"Required Parquet file not found: {all_cards_parquet}\n" - f"Run tagging first: python -c \"from code.tagging.tagger import run_tagging; run_tagging()\"" - ) - - # Load all card counts from all_cards.parquet (includes commanders) - card_counts = _load_theme_counts_from_parquet( - all_cards_parquet, theme_variants=theme_variants - ) - - # For commander counts, filter all_cards by isCommander column - df_commanders = pd.read_parquet(all_cards_parquet) - if 'isCommander' in df_commanders.columns: - df_commanders = df_commanders[df_commanders['isCommander']] + commander_counts = _load_theme_counts(csv_directory / commander_filename, theme_variants) + + card_counts: Counter[str] = Counter() + cards_path = csv_directory / cards_filename + if cards_path.exists(): + card_counts = _load_theme_counts(cards_path, theme_variants) else: - # Fallback: assume all cards could be commanders if column missing - pass - commander_counts = Counter() - for tags in df_commanders['themeTags'].tolist(): - if tags is None or (isinstance(tags, float) and pd.isna(tags)): - continue - # Functions are defined at top of this file, no import needed - parsed = parse_theme_tags(tags) - if not parsed: - continue - seen = set() - for tag in parsed: - display = normalize_theme_display(tag) - if not display: + # Fallback: scan all *_cards.csv except commander + for candidate in csv_directory.glob("*_cards.csv"): + if candidate.name == commander_filename: continue - key = canonical_key(display) - if key not in seen: - seen.add(key) - commander_counts[key] += 1 - theme_variants[key].add(display) - - # Verify we found theme tags - total_themes_found = len(card_counts) + len(commander_counts) - if total_themes_found == 0: - raise RuntimeError( - f"No theme tags found in {all_cards_parquet}\n" - f"The Parquet file exists but contains no themeTags data. " - f"This usually means tagging hasn't completed or failed.\n" - f"Check that 'themeTags' column exists and is populated." - ) - - print("✓ Loaded theme data from parquet files") - print(f" - Commanders: {len(commander_counts)} themes") - print(f" - All cards: {len(card_counts)} themes") + card_counts += _load_theme_counts(candidate, theme_variants) keys = sorted(set(card_counts.keys()) | set(commander_counts.keys())) generated_at_iso = _derive_generated_at(generated_at) @@ -299,19 +171,12 @@ def build_theme_catalog( version_hash = _compute_version_hash(display_names) rows: List[CatalogRow] = [] - filtered_count = 0 for key, display in zip(keys, display_names): if not display: continue card_count = int(card_counts.get(key, 0)) commander_count = int(commander_counts.get(key, 0)) source_count = card_count + commander_count - - # Filter out themes below minimum threshold - if source_count < min_card_count: - filtered_count += 1 - continue - rows.append( CatalogRow( theme=display, @@ -351,9 +216,6 @@ def build_theme_catalog( row.version, ]) - if filtered_count > 0: - print(f" Filtered {filtered_count} themes with <{min_card_count} cards") - if logs_directory is not None: logs_directory = logs_directory.resolve() logs_directory.mkdir(parents=True, exist_ok=True) @@ -400,13 +262,6 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult: default=None, help="Optional directory to mirror the generated catalog for diffing (e.g., logs/generated)", ) - parser.add_argument( - "--min-cards", - dest="min_cards", - type=int, - default=3, - help="Minimum number of cards required to include theme (default: 3)", - ) args = parser.parse_args(argv) csv_dir = _resolve_csv_directory(str(args.csv_dir) if args.csv_dir else None) @@ -414,7 +269,6 @@ def main(argv: Optional[Sequence[str]] = None) -> CatalogBuildResult: csv_directory=csv_dir, output_path=args.output, logs_directory=args.logs_dir, - min_card_count=args.min_cards, ) print( f"Generated {len(result.rows)} themes -> {result.output_path} (version={result.version})", diff --git a/code/scripts/inspect_parquet.py b/code/scripts/inspect_parquet.py deleted file mode 100644 index f04046c..0000000 --- a/code/scripts/inspect_parquet.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Inspect MTGJSON Parquet file schema and compare to CSV.""" - -import pandas as pd -import os -import sys - -def inspect_parquet(): - """Load and inspect Parquet file.""" - parquet_path = 'csv_files/cards_parquet_test.parquet' - - if not os.path.exists(parquet_path): - print(f"Error: {parquet_path} not found") - return - - print("Loading Parquet file...") - df = pd.read_parquet(parquet_path) - - print("\n=== PARQUET FILE INFO ===") - print(f"Rows: {len(df):,}") - print(f"Columns: {len(df.columns)}") - print(f"File size: {os.path.getsize(parquet_path) / 1024 / 1024:.2f} MB") - - print("\n=== PARQUET COLUMNS AND TYPES ===") - for col in sorted(df.columns): - dtype = str(df[col].dtype) - non_null = df[col].notna().sum() - null_pct = (1 - non_null / len(df)) * 100 - print(f" {col:30s} {dtype:15s} ({null_pct:5.1f}% null)") - - print("\n=== SAMPLE DATA (first card) ===") - first_card = df.iloc[0].to_dict() - for key, value in sorted(first_card.items()): - if isinstance(value, (list, dict)): - print(f" {key}: {type(value).__name__} with {len(value)} items") - else: - value_str = str(value)[:80] - print(f" {key}: {value_str}") - - return df - -def compare_to_csv(): - """Compare Parquet columns to CSV columns.""" - csv_path = 'csv_files/cards.csv' - parquet_path = 'csv_files/cards_parquet_test.parquet' - - if not os.path.exists(csv_path): - print(f"\nNote: {csv_path} not found, skipping comparison") - return - - print("\n\n=== CSV FILE INFO ===") - print("Loading CSV file...") - df_csv = pd.read_csv(csv_path, low_memory=False, nrows=1) - - csv_size = os.path.getsize(csv_path) / 1024 / 1024 - print(f"File size: {csv_size:.2f} MB") - print(f"Columns: {len(df_csv.columns)}") - - print("\n=== CSV COLUMNS ===") - csv_cols = set(df_csv.columns) - for col in sorted(df_csv.columns): - print(f" {col}") - - # Load parquet columns - df_parquet = pd.read_parquet(parquet_path) - parquet_cols = set(df_parquet.columns) - - print("\n\n=== SCHEMA COMPARISON ===") - - # Columns in both - common = csv_cols & parquet_cols - print(f"\n✓ Columns in both (n={len(common)}):") - for col in sorted(common): - csv_type = str(df_csv[col].dtype) - parquet_type = str(df_parquet[col].dtype) - if csv_type != parquet_type: - print(f" {col:30s} CSV: {csv_type:15s} Parquet: {parquet_type}") - else: - print(f" {col:30s} {csv_type}") - - # CSV only - csv_only = csv_cols - parquet_cols - if csv_only: - print(f"\n⚠ Columns only in CSV (n={len(csv_only)}):") - for col in sorted(csv_only): - print(f" {col}") - - # Parquet only - parquet_only = parquet_cols - csv_cols - if parquet_only: - print(f"\n✓ Columns only in Parquet (n={len(parquet_only)}):") - for col in sorted(parquet_only): - print(f" {col}") - - # File size comparison - parquet_size = os.path.getsize(parquet_path) / 1024 / 1024 - size_reduction = (1 - parquet_size / csv_size) * 100 - print(f"\n=== FILE SIZE COMPARISON ===") - print(f"CSV: {csv_size:.2f} MB") - print(f"Parquet: {parquet_size:.2f} MB") - print(f"Savings: {size_reduction:.1f}%") - -if __name__ == "__main__": - df = inspect_parquet() - compare_to_csv() diff --git a/code/scripts/preview_dfc_catalog_diff.py b/code/scripts/preview_dfc_catalog_diff.py new file mode 100644 index 0000000..6e791d1 --- /dev/null +++ b/code/scripts/preview_dfc_catalog_diff.py @@ -0,0 +1,305 @@ +"""Catalog diff helper for verifying multi-face merge output. + +This utility regenerates the card CSV catalog (optionally writing compatibility +snapshots) and then compares the merged outputs against the baseline snapshots. +It is intended to support the MDFC rollout checklist by providing a concise summary +of how many rows were merged, which cards collapsed into a single record, and +whether any tag unions diverge from expectations. + +Example usage (from repo root, inside virtualenv): + + python -m code.scripts.preview_dfc_catalog_diff --compat-snapshot --output logs/dfc_catalog_diff.json + +The script prints a human readable summary to stdout and optionally writes a JSON +artifact for release/staging review. +""" +from __future__ import annotations + +import argparse +import ast +import importlib +import json +import os +import sys +import time +from collections import Counter +from pathlib import Path +from typing import Any, Dict, Iterable, List, Sequence + +import pandas as pd + +from settings import COLORS, CSV_DIRECTORY + +DEFAULT_COMPAT_DIR = Path(os.getenv("DFC_COMPAT_DIR", "csv_files/compat_faces")) +CSV_ROOT = Path(CSV_DIRECTORY) + + +def _parse_list_cell(value: Any) -> List[str]: + """Convert serialized list cells ("['A', 'B']") into Python lists.""" + if isinstance(value, list): + return [str(item) for item in value] + if value is None: + return [] + if isinstance(value, float) and pd.isna(value): # type: ignore[arg-type] + return [] + text = str(value).strip() + if not text: + return [] + try: + parsed = ast.literal_eval(text) + except (SyntaxError, ValueError): + return [text] + if isinstance(parsed, list): + return [str(item) for item in parsed] + return [str(parsed)] + + +def _load_catalog(path: Path) -> pd.DataFrame: + if not path.exists(): + raise FileNotFoundError(f"Catalog file missing: {path}") + df = pd.read_csv(path) + for column in ("themeTags", "keywords", "creatureTypes"): + if column in df.columns: + df[column] = df[column].apply(_parse_list_cell) + return df + + +def _multi_face_names(df: pd.DataFrame) -> List[str]: + counts = Counter(df.get("name", [])) + return [name for name, count in counts.items() if isinstance(name, str) and count > 1] + + +def _collect_tags(series: Iterable[List[str]]) -> List[str]: + tags: List[str] = [] + for value in series: + if isinstance(value, list): + tags.extend(str(item) for item in value) + return sorted(set(tags)) + + +def _summarize_color( + color: str, + merged: pd.DataFrame, + baseline: pd.DataFrame, + sample_size: int, +) -> Dict[str, Any]: + merged_names = set(merged.get("name", [])) + baseline_names = list(baseline.get("name", [])) + baseline_name_set = set(name for name in baseline_names if isinstance(name, str)) + + multi_face = _multi_face_names(baseline) + collapsed = [] + tag_mismatches: List[str] = [] + missing_after_merge: List[str] = [] + + for name in multi_face: + group = baseline[baseline["name"] == name] + merged_row = merged[merged["name"] == name] + if merged_row.empty: + missing_after_merge.append(name) + continue + expected_tags = _collect_tags(group["themeTags"]) if "themeTags" in group else [] + merged_tags = _collect_tags(merged_row.iloc[[0]]["themeTags"]) if "themeTags" in merged_row else [] + if expected_tags != merged_tags: + tag_mismatches.append(name) + collapsed.append(name) + + removed_names = sorted(baseline_name_set - merged_names) + added_names = sorted(merged_names - baseline_name_set) + + return { + "rows_merged": len(merged), + "rows_baseline": len(baseline), + "row_delta": len(merged) - len(baseline), + "multi_face_groups": len(multi_face), + "collapsed_sample": collapsed[:sample_size], + "tag_union_mismatches": tag_mismatches[:sample_size], + "missing_after_merge": missing_after_merge[:sample_size], + "removed_names": removed_names[:sample_size], + "added_names": added_names[:sample_size], + } + + +def _refresh_catalog(colors: Sequence[str], compat_snapshot: bool) -> None: + os.environ.pop("ENABLE_DFC_MERGE", None) + os.environ["DFC_COMPAT_SNAPSHOT"] = "1" if compat_snapshot else "0" + importlib.invalidate_caches() + # Reload tagger to pick up the new env var + tagger = importlib.import_module("code.tagging.tagger") + tagger = importlib.reload(tagger) # type: ignore[assignment] + + for color in colors: + tagger.load_dataframe(color) + + +def generate_diff( + colors: Sequence[str], + compat_dir: Path, + sample_size: int, +) -> Dict[str, Any]: + per_color: Dict[str, Any] = {} + overall = { + "total_rows_merged": 0, + "total_rows_baseline": 0, + "total_multi_face_groups": 0, + "colors": len(colors), + "tag_union_mismatches": 0, + "missing_after_merge": 0, + } + + for color in colors: + merged_path = CSV_ROOT / f"{color}_cards.csv" + baseline_path = compat_dir / f"{color}_cards_unmerged.csv" + merged_df = _load_catalog(merged_path) + baseline_df = _load_catalog(baseline_path) + summary = _summarize_color(color, merged_df, baseline_df, sample_size) + per_color[color] = summary + overall["total_rows_merged"] += summary["rows_merged"] + overall["total_rows_baseline"] += summary["rows_baseline"] + overall["total_multi_face_groups"] += summary["multi_face_groups"] + overall["tag_union_mismatches"] += len(summary["tag_union_mismatches"]) + overall["missing_after_merge"] += len(summary["missing_after_merge"]) + + overall["row_delta_total"] = overall["total_rows_merged"] - overall["total_rows_baseline"] + return {"overall": overall, "per_color": per_color} + + +def main(argv: List[str]) -> int: + parser = argparse.ArgumentParser(description="Preview merged vs baseline DFC catalog diff") + parser.add_argument( + "--skip-refresh", + action="store_true", + help="Skip rebuilding the catalog in compatibility mode (requires existing compat snapshots)", + ) + parser.add_argument( + "--mode", + default="", + help="[Deprecated] Legacy ENABLE_DFC_MERGE value (compat|1|0 etc.)", + ) + parser.add_argument( + "--compat-snapshot", + dest="compat_snapshot", + action="store_true", + help="Write compatibility snapshots before diffing (default: off unless legacy --mode compat)", + ) + parser.add_argument( + "--no-compat-snapshot", + dest="compat_snapshot", + action="store_false", + help="Skip compatibility snapshots even if legacy --mode compat is supplied", + ) + parser.set_defaults(compat_snapshot=None) + parser.add_argument( + "--colors", + nargs="*", + help="Optional subset of colors to diff (defaults to full COLORS list)", + ) + parser.add_argument( + "--compat-dir", + type=Path, + default=DEFAULT_COMPAT_DIR, + help="Directory containing unmerged compatibility snapshots (default: %(default)s)", + ) + parser.add_argument( + "--output", + type=Path, + help="Optional JSON file to write with the diff summary", + ) + parser.add_argument( + "--sample-size", + type=int, + default=10, + help="Number of sample entries to include per section (default: %(default)s)", + ) + args = parser.parse_args(argv) + + colors = tuple(args.colors) if args.colors else tuple(COLORS) + compat_dir = args.compat_dir + + mode = str(args.mode or "").strip().lower() + if mode and mode not in {"compat", "dual", "both", "1", "on", "true", "0", "off", "false", "disabled"}: + print( + f"ℹ Legacy --mode value '{mode}' detected; merge remains enabled. Use --compat-snapshot as needed.", + flush=True, + ) + + if args.compat_snapshot is None: + compat_snapshot = mode in {"compat", "dual", "both"} + else: + compat_snapshot = args.compat_snapshot + if mode: + print( + "ℹ Ignoring deprecated --mode value because --compat-snapshot/--no-compat-snapshot was supplied.", + flush=True, + ) + + if mode in {"0", "off", "false", "disabled"}: + print( + "⚠ ENABLE_DFC_MERGE=off is deprecated; the merge remains enabled regardless of the value.", + flush=True, + ) + + if not args.skip_refresh: + start = time.perf_counter() + _refresh_catalog(colors, compat_snapshot) + duration = time.perf_counter() - start + snapshot_msg = "with compat snapshot" if compat_snapshot else "merged-only" + print(f"✔ Refreshed catalog in {duration:.1f}s ({snapshot_msg})") + else: + print("ℹ Using existing catalog outputs (refresh skipped)") + + try: + diff = generate_diff(colors, compat_dir, args.sample_size) + except FileNotFoundError as exc: + print(f"ERROR: {exc}") + print("Run without --skip-refresh (or ensure compat snapshots exist).", file=sys.stderr) + return 2 + + overall = diff["overall"] + print("\n=== DFC Catalog Diff Summary ===") + print( + f"Merged rows: {overall['total_rows_merged']:,} | Baseline rows: {overall['total_rows_baseline']:,} | " + f"Δ rows: {overall['row_delta_total']:,}" + ) + print( + f"Multi-face groups: {overall['total_multi_face_groups']:,} | " + f"Tag union mismatches: {overall['tag_union_mismatches']} | Missing after merge: {overall['missing_after_merge']}" + ) + + for color, summary in diff["per_color"].items(): + print(f"\n[{color}] baseline={summary['rows_baseline']} merged={summary['rows_merged']} Δ={summary['row_delta']}") + if summary["multi_face_groups"]: + print(f" multi-face groups: {summary['multi_face_groups']}") + if summary["collapsed_sample"]: + sample = ", ".join(summary["collapsed_sample"][:3]) + print(f" collapsed sample: {sample}") + if summary["tag_union_mismatches"]: + print(f" TAG MISMATCH sample: {', '.join(summary['tag_union_mismatches'])}") + if summary["missing_after_merge"]: + print(f" MISSING sample: {', '.join(summary['missing_after_merge'])}") + if summary["removed_names"]: + print(f" removed sample: {', '.join(summary['removed_names'])}") + if summary["added_names"]: + print(f" added sample: {', '.join(summary['added_names'])}") + + if args.output: + payload = { + "captured_at": int(time.time()), + "mode": args.mode, + "colors": colors, + "compat_dir": str(compat_dir), + "summary": diff, + } + try: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + print(f"\n📄 Wrote JSON summary to {args.output}") + except Exception as exc: # pragma: no cover + print(f"Failed to write output file {args.output}: {exc}", file=sys.stderr) + return 3 + + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/code/scripts/preview_metrics_snapshot.py b/code/scripts/preview_metrics_snapshot.py new file mode 100644 index 0000000..ba54bba --- /dev/null +++ b/code/scripts/preview_metrics_snapshot.py @@ -0,0 +1,105 @@ +"""CLI utility: snapshot preview metrics and emit summary/top slow themes. + +Usage (from repo root virtualenv): + python -m code.scripts.preview_metrics_snapshot --limit 10 --output logs/preview_metrics_snapshot.json + +Fetches /themes/metrics (requires WEB_THEME_PICKER_DIAGNOSTICS=1) and writes a compact JSON plus +human-readable summary to stdout. +""" +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path +from typing import Any, Dict + +import urllib.request +import urllib.error + +DEFAULT_URL = "http://localhost:8000/themes/metrics" + + +def fetch_metrics(url: str) -> Dict[str, Any]: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: # nosec B310 (local trusted) + data = resp.read().decode("utf-8", "replace") + try: + return json.loads(data) # type: ignore[return-value] + except json.JSONDecodeError as e: # pragma: no cover - unlikely if server OK + raise SystemExit(f"Invalid JSON from metrics endpoint: {e}\nRaw: {data[:400]}") + + +def summarize(metrics: Dict[str, Any], top_n: int) -> Dict[str, Any]: + preview = (metrics.get("preview") or {}) if isinstance(metrics, dict) else {} + per_theme = preview.get("per_theme") or {} + # Compute top slow themes by avg_ms + items = [] + for slug, info in per_theme.items(): + if not isinstance(info, dict): + continue + avg = info.get("avg_ms") + if isinstance(avg, (int, float)): + items.append((slug, float(avg), info)) + items.sort(key=lambda x: x[1], reverse=True) + top = items[:top_n] + return { + "preview_requests": preview.get("preview_requests"), + "preview_cache_hits": preview.get("preview_cache_hits"), + "preview_avg_build_ms": preview.get("preview_avg_build_ms"), + "preview_p95_build_ms": preview.get("preview_p95_build_ms"), + "preview_ttl_seconds": preview.get("preview_ttl_seconds"), + "editorial_curated_vs_sampled_pct": preview.get("editorial_curated_vs_sampled_pct"), + "top_slowest": [ + { + "slug": slug, + "avg_ms": avg, + "p95_ms": info.get("p95_ms"), + "builds": info.get("builds"), + "requests": info.get("requests"), + "avg_curated_pct": info.get("avg_curated_pct"), + } + for slug, avg, info in top + ], + } + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Snapshot preview metrics") + ap.add_argument("--url", default=DEFAULT_URL, help="Metrics endpoint URL (default: %(default)s)") + ap.add_argument("--limit", type=int, default=10, help="Top N slow themes to include (default: %(default)s)") + ap.add_argument("--output", type=Path, help="Optional output JSON file for snapshot") + ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary (still writes file if --output)") + args = ap.parse_args(argv) + + try: + raw = fetch_metrics(args.url) + except urllib.error.URLError as e: + print(f"ERROR: Failed fetching metrics endpoint: {e}", file=sys.stderr) + return 2 + + summary = summarize(raw, args.limit) + snapshot = { + "captured_at": int(time.time()), + "source": args.url, + "summary": summary, + } + + if args.output: + try: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(snapshot, indent=2, sort_keys=True), encoding="utf-8") + except Exception as e: # pragma: no cover + print(f"ERROR: writing snapshot file failed: {e}", file=sys.stderr) + return 3 + + if not args.quiet: + print("Preview Metrics Snapshot:") + print(json.dumps(summary, indent=2)) + + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/code/scripts/preview_perf_benchmark.py b/code/scripts/preview_perf_benchmark.py new file mode 100644 index 0000000..f1e60ed --- /dev/null +++ b/code/scripts/preview_perf_benchmark.py @@ -0,0 +1,349 @@ +"""Ad-hoc performance benchmark for theme preview build latency (Phase A validation). + +Runs warm-up plus measured request loops against several theme slugs and prints +aggregate latency stats (p50/p90/p95, cache hit ratio evolution). Intended to +establish or validate that refactor did not introduce >5% p95 regression. + +Usage (ensure server running locally – commonly :8080 in docker compose): + python -m code.scripts.preview_perf_benchmark --themes 8 --loops 40 \ + --url http://localhost:8080 --warm 1 --limit 12 + +Theme slug discovery hierarchy (when --theme not provided): + 1. Try /themes/index.json (legacy / planned static index) + 2. Fallback to /themes/api/themes (current API) and take the first N ids +The discovered slugs are sorted deterministically then truncated to N. + +NOTE: This is intentionally minimal (no external deps). For stable comparisons +run with identical parameters pre/post-change and commit the JSON output under +logs/perf/. +""" +from __future__ import annotations + +import argparse +import json +import statistics +import time +from typing import Any, Dict, List +import urllib.request +import urllib.error +import sys +from pathlib import Path + + +def _fetch_json(url: str) -> Dict[str, Any]: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 local dev + data = resp.read().decode("utf-8", "replace") + return json.loads(data) # type: ignore[return-value] + + +def _fetch_json_with_retry(url: str, attempts: int = 3, delay: float = 0.6) -> Dict[str, Any]: + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + try: + return _fetch_json(url) + except Exception as exc: # pragma: no cover - network variability + last_error = exc + if attempt < attempts: + print(json.dumps({ # noqa: T201 + "event": "preview_perf_fetch_retry", + "url": url, + "attempt": attempt, + "max_attempts": attempts, + "error": str(exc), + })) + time.sleep(delay * attempt) + else: + raise + raise last_error # pragma: no cover - defensive; should be unreachable + + +def select_theme_slugs(base_url: str, count: int) -> List[str]: + """Discover theme slugs for benchmarking. + + Attempts legacy static index first, then falls back to live API listing. + """ + errors: List[str] = [] + slugs: List[str] = [] + # Attempt 1: legacy /themes/index.json + try: + idx = _fetch_json(f"{base_url.rstrip('/')}/themes/index.json") + entries = idx.get("themes") or [] + for it in entries: + if not isinstance(it, dict): + continue + slug = it.get("slug") or it.get("id") or it.get("theme_id") + if isinstance(slug, str): + slugs.append(slug) + except Exception as e: # pragma: no cover - network variability + errors.append(f"index.json failed: {e}") + + if not slugs: + # Attempt 2: live API listing + try: + listing = _fetch_json(f"{base_url.rstrip('/')}/themes/api/themes") + items = listing.get("items") or [] + for it in items: + if not isinstance(it, dict): + continue + tid = it.get("id") or it.get("slug") or it.get("theme_id") + if isinstance(tid, str): + slugs.append(tid) + except Exception as e: # pragma: no cover - network variability + errors.append(f"api/themes failed: {e}") + + slugs = sorted(set(slugs))[:count] + if not slugs: + raise SystemExit("No theme slugs discovered; cannot benchmark (" + "; ".join(errors) + ")") + return slugs + + +def fetch_all_theme_slugs(base_url: str, page_limit: int = 200) -> List[str]: + """Fetch all theme slugs via paginated /themes/api/themes endpoint. + + Uses maximum page size (200) and iterates using offset until no next page. + Returns deterministic sorted unique list of slugs. + """ + slugs: List[str] = [] + offset = 0 + seen: set[str] = set() + page_attempts = 5 + page_delay = 1.2 + while True: + url = f"{base_url.rstrip('/')}/themes/api/themes?limit={page_limit}&offset={offset}" + data: Dict[str, Any] | None = None + last_error: Exception | None = None + for attempt in range(1, page_attempts + 1): + try: + data = _fetch_json_with_retry(url, attempts=4, delay=0.75) + break + except Exception as exc: # pragma: no cover - network variability + last_error = exc + if attempt < page_attempts: + print(json.dumps({ # noqa: T201 + "event": "preview_perf_page_retry", + "offset": offset, + "attempt": attempt, + "max_attempts": page_attempts, + "error": str(exc), + })) + time.sleep(page_delay * attempt) + else: + raise SystemExit(f"Failed fetching themes page offset={offset}: {exc}") + if data is None: # pragma: no cover - defensive + raise SystemExit(f"Failed fetching themes page offset={offset}: {last_error}") + items = data.get("items") or [] + for it in items: + if not isinstance(it, dict): + continue + tid = it.get("id") or it.get("slug") or it.get("theme_id") + if isinstance(tid, str) and tid not in seen: + seen.add(tid) + slugs.append(tid) + next_offset = data.get("next_offset") + if not next_offset or next_offset == offset: + break + offset = int(next_offset) + return sorted(slugs) + + +def percentile(values: List[float], pct: float) -> float: + if not values: + return 0.0 + sv = sorted(values) + k = (len(sv) - 1) * pct + f = int(k) + c = min(f + 1, len(sv) - 1) + if f == c: + return sv[f] + d0 = sv[f] * (c - k) + d1 = sv[c] * (k - f) + return d0 + d1 + + +def run_loop(base_url: str, slugs: List[str], loops: int, limit: int, warm: bool, path_template: str) -> Dict[str, Any]: + latencies: List[float] = [] + per_slug_counts = {s: 0 for s in slugs} + t_start = time.time() + for i in range(loops): + slug = slugs[i % len(slugs)] + # path_template may contain {slug} and {limit} + try: + rel = path_template.format(slug=slug, limit=limit) + except Exception: + rel = f"/themes/api/theme/{slug}/preview?limit={limit}" + if not rel.startswith('/'): + rel = '/' + rel + url = f"{base_url.rstrip('/')}{rel}" + t0 = time.time() + try: + _fetch_json(url) + except Exception as e: + print(json.dumps({"event": "perf_benchmark_error", "slug": slug, "error": str(e)})) # noqa: T201 + continue + ms = (time.time() - t0) * 1000.0 + latencies.append(ms) + per_slug_counts[slug] += 1 + elapsed = time.time() - t_start + return { + "warm": warm, + "loops": loops, + "slugs": slugs, + "per_slug_requests": per_slug_counts, + "elapsed_s": round(elapsed, 3), + "p50_ms": round(percentile(latencies, 0.50), 2), + "p90_ms": round(percentile(latencies, 0.90), 2), + "p95_ms": round(percentile(latencies, 0.95), 2), + "avg_ms": round(statistics.mean(latencies), 2) if latencies else 0.0, + "count": len(latencies), + "_latencies": latencies, # internal (removed in final result unless explicitly retained) + } + + +def _stats_from_latencies(latencies: List[float]) -> Dict[str, Any]: + if not latencies: + return {"count": 0, "p50_ms": 0.0, "p90_ms": 0.0, "p95_ms": 0.0, "avg_ms": 0.0} + return { + "count": len(latencies), + "p50_ms": round(percentile(latencies, 0.50), 2), + "p90_ms": round(percentile(latencies, 0.90), 2), + "p95_ms": round(percentile(latencies, 0.95), 2), + "avg_ms": round(statistics.mean(latencies), 2), + } + + +def main(argv: List[str]) -> int: + ap = argparse.ArgumentParser(description="Theme preview performance benchmark") + ap.add_argument("--url", default="http://localhost:8000", help="Base server URL (default: %(default)s)") + ap.add_argument("--themes", type=int, default=6, help="Number of theme slugs to exercise (default: %(default)s)") + ap.add_argument("--loops", type=int, default=60, help="Total request iterations (default: %(default)s)") + ap.add_argument("--limit", type=int, default=12, help="Preview size (default: %(default)s)") + ap.add_argument("--path-template", default="/themes/api/theme/{slug}/preview?limit={limit}", help="Format string for preview request path (default: %(default)s)") + ap.add_argument("--theme", action="append", dest="explicit_theme", help="Explicit theme slug(s); overrides automatic selection") + ap.add_argument("--warm", type=int, default=1, help="Number of warm-up loops (full cycles over selected slugs) (default: %(default)s)") + ap.add_argument("--output", type=Path, help="Optional JSON output path (committed under logs/perf)") + ap.add_argument("--all", action="store_true", help="Exercise ALL themes (ignores --themes; loops auto-set to passes*total_slugs unless --loops-explicit)") + ap.add_argument("--passes", type=int, default=1, help="When using --all, number of passes over the full theme set (default: %(default)s)") + # Hidden flag to detect if user explicitly set --loops (argparse has no direct support, so use sentinel technique) + # We keep original --loops for backwards compatibility; when --all we recompute unless user passed --loops-explicit + ap.add_argument("--loops-explicit", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--extract-warm-baseline", type=Path, help="If multi-pass (--all --passes >1), write a warm-only baseline JSON (final pass stats) to this path") + args = ap.parse_args(argv) + + try: + if args.explicit_theme: + slugs = args.explicit_theme + elif args.all: + slugs = fetch_all_theme_slugs(args.url) + else: + slugs = select_theme_slugs(args.url, args.themes) + except SystemExit as e: # pragma: no cover - dependency on live server + print(str(e), file=sys.stderr) + return 2 + + mode = "all" if args.all else "subset" + total_slugs = len(slugs) + if args.all and not args.loops_explicit: + # Derive loops = passes * total_slugs + args.loops = max(1, args.passes) * total_slugs + + print(json.dumps({ # noqa: T201 + "event": "preview_perf_start", + "mode": mode, + "total_slugs": total_slugs, + "planned_loops": args.loops, + "passes": args.passes if args.all else None, + })) + + # Execution paths: + # 1. Standard subset or single-pass all: warm cycles -> single measured run + # 2. Multi-pass all mode (--all --passes >1): iterate passes capturing per-pass stats (no separate warm loops) + if args.all and args.passes > 1: + pass_results: List[Dict[str, Any]] = [] + combined_latencies: List[float] = [] + t0_all = time.time() + for p in range(1, args.passes + 1): + r = run_loop(args.url, slugs, len(slugs), args.limit, warm=(p == 1), path_template=args.path_template) + lat = r.pop("_latencies", []) + combined_latencies.extend(lat) + pass_result = { + "pass": p, + "warm": r["warm"], + "elapsed_s": r["elapsed_s"], + "p50_ms": r["p50_ms"], + "p90_ms": r["p90_ms"], + "p95_ms": r["p95_ms"], + "avg_ms": r["avg_ms"], + "count": r["count"], + } + pass_results.append(pass_result) + total_elapsed = round(time.time() - t0_all, 3) + aggregate = _stats_from_latencies(combined_latencies) + result = { + "mode": mode, + "total_slugs": total_slugs, + "passes": args.passes, + "slugs": slugs, + "combined": { + **aggregate, + "elapsed_s": total_elapsed, + }, + "passes_results": pass_results, + "cold_pass_p95_ms": pass_results[0]["p95_ms"], + "warm_pass_p95_ms": pass_results[-1]["p95_ms"], + "cold_pass_p50_ms": pass_results[0]["p50_ms"], + "warm_pass_p50_ms": pass_results[-1]["p50_ms"], + } + print(json.dumps({"event": "preview_perf_result", **result}, indent=2)) # noqa: T201 + # Optional warm baseline extraction (final pass only; represents warmed steady-state) + if args.extract_warm_baseline: + try: + wb = pass_results[-1] + warm_obj = { + "event": "preview_perf_warm_baseline", + "mode": mode, + "total_slugs": total_slugs, + "warm_baseline": True, + "source_pass": wb["pass"], + "p50_ms": wb["p50_ms"], + "p90_ms": wb["p90_ms"], + "p95_ms": wb["p95_ms"], + "avg_ms": wb["avg_ms"], + "count": wb["count"], + "slugs": slugs, + } + args.extract_warm_baseline.parent.mkdir(parents=True, exist_ok=True) + args.extract_warm_baseline.write_text(json.dumps(warm_obj, indent=2, sort_keys=True), encoding="utf-8") + print(json.dumps({ # noqa: T201 + "event": "preview_perf_warm_baseline_written", + "path": str(args.extract_warm_baseline), + "p95_ms": wb["p95_ms"], + })) + except Exception as e: # pragma: no cover + print(json.dumps({"event": "preview_perf_warm_baseline_error", "error": str(e)})) # noqa: T201 + else: + # Warm-up loops first (if requested) + for w in range(args.warm): + run_loop(args.url, slugs, len(slugs), args.limit, warm=True, path_template=args.path_template) + result = run_loop(args.url, slugs, args.loops, args.limit, warm=False, path_template=args.path_template) + result.pop("_latencies", None) + result["slugs"] = slugs + result["mode"] = mode + result["total_slugs"] = total_slugs + if args.all: + result["passes"] = args.passes + print(json.dumps({"event": "preview_perf_result", **result}, indent=2)) # noqa: T201 + + if args.output: + try: + args.output.parent.mkdir(parents=True, exist_ok=True) + # Ensure we write the final result object (multi-pass already prepared above) + args.output.write_text(json.dumps(result, indent=2, sort_keys=True), encoding="utf-8") + except Exception as e: # pragma: no cover + print(f"ERROR: failed writing output file: {e}", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/code/scripts/preview_perf_ci_check.py b/code/scripts/preview_perf_ci_check.py new file mode 100644 index 0000000..5550e4b --- /dev/null +++ b/code/scripts/preview_perf_ci_check.py @@ -0,0 +1,106 @@ +"""CI helper: run a warm-pass benchmark candidate (single pass over all themes) +then compare against the committed warm baseline with threshold enforcement. + +Intended usage (example): + python -m code.scripts.preview_perf_ci_check --url http://localhost:8080 \ + --baseline logs/perf/theme_preview_warm_baseline.json --p95-threshold 5 + +Exit codes: + 0 success (within threshold) + 2 regression (p95 delta > threshold) + 3 setup / usage error + +Notes: +- Uses --all --passes 1 to create a fresh candidate snapshot that approximates + a warmed steady-state (server should have background refresh / typical load). +- If you prefer multi-pass then warm-only selection, adjust logic accordingly. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +def _wait_for_service(base_url: str, attempts: int = 12, delay: float = 1.5) -> bool: + health_url = base_url.rstrip("/") + "/healthz" + last_error: Exception | None = None + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(health_url, timeout=5) as resp: # nosec B310 local CI + if 200 <= resp.status < 300: + return True + except urllib.error.HTTPError as exc: + last_error = exc + if 400 <= exc.code < 500 and exc.code != 429: + # Treat permanent client errors (other than rate limit) as fatal + break + except Exception as exc: # pragma: no cover - network variability + last_error = exc + time.sleep(delay * attempt) + print(json.dumps({ + "event": "ci_perf_error", + "stage": "startup", + "message": "Service health check failed", + "url": health_url, + "attempts": attempts, + "error": str(last_error) if last_error else None, + })) + return False + +def run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, check=False) + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Preview performance CI regression gate") + ap.add_argument("--url", default="http://localhost:8080", help="Base URL of running web service") + ap.add_argument("--baseline", type=Path, required=True, help="Path to committed warm baseline JSON") + ap.add_argument("--p95-threshold", type=float, default=5.0, help="Max allowed p95 regression percent (default: %(default)s)") + ap.add_argument("--candidate-output", type=Path, default=Path("logs/perf/theme_preview_ci_candidate.json"), help="Where to write candidate benchmark JSON") + ap.add_argument("--multi-pass", action="store_true", help="Run a 2-pass all-themes benchmark and compare warm pass only (optional enhancement)") + args = ap.parse_args(argv) + + if not args.baseline.exists(): + print(json.dumps({"event":"ci_perf_error","message":"Baseline not found","path":str(args.baseline)})) + return 3 + + if not _wait_for_service(args.url): + return 3 + + # Run candidate single-pass all-themes benchmark (no extra warm cycles to keep CI fast) + # If multi-pass requested, run two passes over all themes so second pass represents warmed steady-state. + passes = "2" if args.multi_pass else "1" + bench_cmd = [sys.executable, "-m", "code.scripts.preview_perf_benchmark", "--url", args.url, "--all", "--passes", passes, "--output", str(args.candidate_output)] + bench_proc = run(bench_cmd) + if bench_proc.returncode != 0: + print(json.dumps({"event":"ci_perf_error","stage":"benchmark","code":bench_proc.returncode,"stderr":bench_proc.stderr})) + return 3 + print(bench_proc.stdout) + + if not args.candidate_output.exists(): + print(json.dumps({"event":"ci_perf_error","message":"Candidate output missing"})) + return 3 + + compare_cmd = [ + sys.executable, + "-m","code.scripts.preview_perf_compare", + "--baseline", str(args.baseline), + "--candidate", str(args.candidate_output), + "--warm-only", + "--p95-threshold", str(args.p95_threshold), + ] + cmp_proc = run(compare_cmd) + print(cmp_proc.stdout) + if cmp_proc.returncode == 2: + # Already printed JSON with failure status + return 2 + if cmp_proc.returncode != 0: + print(json.dumps({"event":"ci_perf_error","stage":"compare","code":cmp_proc.returncode,"stderr":cmp_proc.stderr})) + return 3 + return 0 + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(sys.argv[1:])) diff --git a/code/scripts/preview_perf_compare.py b/code/scripts/preview_perf_compare.py new file mode 100644 index 0000000..e177e4c --- /dev/null +++ b/code/scripts/preview_perf_compare.py @@ -0,0 +1,115 @@ +"""Compare two preview benchmark JSON result files and emit delta stats. + +Usage: + python -m code.scripts.preview_perf_compare --baseline logs/perf/theme_preview_baseline_all_pass1_20250923.json --candidate logs/perf/new_run.json + +Outputs JSON with percentage deltas for p50/p90/p95/avg (positive = regression/slower). +If multi-pass structures are present (combined & passes_results) those are included. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Dict + + +def load(path: Path) -> Dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + # Multi-pass result may store stats under combined + if "combined" in data: + core = data["combined"].copy() + # Inject representative fields for uniform comparison + core["p50_ms"] = core.get("p50_ms") or data.get("p50_ms") + core["p90_ms"] = core.get("p90_ms") or data.get("p90_ms") + core["p95_ms"] = core.get("p95_ms") or data.get("p95_ms") + core["avg_ms"] = core.get("avg_ms") or data.get("avg_ms") + data["_core_stats"] = core + else: + data["_core_stats"] = { + k: data.get(k) for k in ("p50_ms", "p90_ms", "p95_ms", "avg_ms", "count") + } + return data + + +def pct_delta(new: float, old: float) -> float: + if old == 0: + return 0.0 + return round(((new - old) / old) * 100.0, 2) + + +def compare(baseline: Dict[str, Any], candidate: Dict[str, Any]) -> Dict[str, Any]: + b = baseline["_core_stats"] + c = candidate["_core_stats"] + result = {"baseline_count": b.get("count"), "candidate_count": c.get("count")} + for k in ("p50_ms", "p90_ms", "p95_ms", "avg_ms"): + if b.get(k) is not None and c.get(k) is not None: + result[k] = { + "baseline": b[k], + "candidate": c[k], + "delta_pct": pct_delta(c[k], b[k]), + } + # If both have per-pass details include first and last pass p95/p50 + if "passes_results" in baseline and "passes_results" in candidate: + result["passes"] = { + "baseline": { + "cold_p95": baseline.get("cold_pass_p95_ms"), + "warm_p95": baseline.get("warm_pass_p95_ms"), + "cold_p50": baseline.get("cold_pass_p50_ms"), + "warm_p50": baseline.get("warm_pass_p50_ms"), + }, + "candidate": { + "cold_p95": candidate.get("cold_pass_p95_ms"), + "warm_p95": candidate.get("warm_pass_p95_ms"), + "cold_p50": candidate.get("cold_pass_p50_ms"), + "warm_p50": candidate.get("warm_pass_p50_ms"), + }, + } + return result + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Compare two preview benchmark JSON result files") + ap.add_argument("--baseline", required=True, type=Path, help="Baseline JSON path") + ap.add_argument("--candidate", required=True, type=Path, help="Candidate JSON path") + ap.add_argument("--p95-threshold", type=float, default=None, help="Fail (exit 2) if p95 regression exceeds this percent (positive delta)") + ap.add_argument("--warm-only", action="store_true", help="When both results have passes, compare warm pass p95/p50 instead of combined/core") + args = ap.parse_args(argv) + if not args.baseline.exists(): + raise SystemExit(f"Baseline not found: {args.baseline}") + if not args.candidate.exists(): + raise SystemExit(f"Candidate not found: {args.candidate}") + baseline = load(args.baseline) + candidate = load(args.candidate) + # If warm-only requested and both have warm pass stats, override _core_stats before compare + if args.warm_only and "warm_pass_p95_ms" in baseline and "warm_pass_p95_ms" in candidate: + baseline["_core_stats"] = { + "p50_ms": baseline.get("warm_pass_p50_ms"), + "p90_ms": baseline.get("_core_stats", {}).get("p90_ms"), # p90 not tracked per-pass; retain combined + "p95_ms": baseline.get("warm_pass_p95_ms"), + "avg_ms": baseline.get("_core_stats", {}).get("avg_ms"), + "count": baseline.get("_core_stats", {}).get("count"), + } + candidate["_core_stats"] = { + "p50_ms": candidate.get("warm_pass_p50_ms"), + "p90_ms": candidate.get("_core_stats", {}).get("p90_ms"), + "p95_ms": candidate.get("warm_pass_p95_ms"), + "avg_ms": candidate.get("_core_stats", {}).get("avg_ms"), + "count": candidate.get("_core_stats", {}).get("count"), + } + cmp = compare(baseline, candidate) + payload = {"event": "preview_perf_compare", **cmp} + if args.p95_threshold is not None and "p95_ms" in cmp: + delta = cmp["p95_ms"]["delta_pct"] + payload["threshold"] = {"p95_threshold": args.p95_threshold, "p95_delta_pct": delta} + if delta is not None and delta > args.p95_threshold: + payload["result"] = "fail" + print(json.dumps(payload, indent=2)) # noqa: T201 + return 2 + payload["result"] = "pass" + print(json.dumps(payload, indent=2)) # noqa: T201 + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main(__import__('sys').argv[1:])) diff --git a/code/scripts/profile_multi_theme_filter.py b/code/scripts/profile_multi_theme_filter.py index 795bc62..2af36c0 100644 --- a/code/scripts/profile_multi_theme_filter.py +++ b/code/scripts/profile_multi_theme_filter.py @@ -42,7 +42,7 @@ def _sample_combinations(tags: List[str], iterations: int) -> List[Tuple[str | N def _collect_tag_pool(df: pd.DataFrame) -> List[str]: tag_pool: set[str] = set() - for tags in df.get("_ltags", []): + for tags in df.get("_ltags", []): # type: ignore[assignment] if not tags: continue for token in tags: diff --git a/code/scripts/refresh_commander_catalog.py b/code/scripts/refresh_commander_catalog.py index 19b4634..c9f107e 100644 --- a/code/scripts/refresh_commander_catalog.py +++ b/code/scripts/refresh_commander_catalog.py @@ -37,7 +37,7 @@ def _refresh_setup() -> None: def _refresh_tags() -> None: tagger = importlib.import_module("code.tagging.tagger") - tagger = importlib.reload(tagger) + tagger = importlib.reload(tagger) # type: ignore[assignment] for color in SUPPORTED_COLORS: tagger.load_dataframe(color) diff --git a/code/scripts/report_random_theme_pool.py b/code/scripts/report_random_theme_pool.py index 09140ae..1b3833f 100644 --- a/code/scripts/report_random_theme_pool.py +++ b/code/scripts/report_random_theme_pool.py @@ -21,7 +21,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.append(str(PROJECT_ROOT)) -from deck_builder.random_entrypoint import ( # noqa: E402 +from deck_builder.random_entrypoint import ( # type: ignore # noqa: E402 _build_random_theme_pool, _ensure_theme_tag_cache, _load_commanders_df, diff --git a/code/scripts/synergy_promote_fill.py b/code/scripts/synergy_promote_fill.py index ca878f2..3c49af0 100644 --- a/code/scripts/synergy_promote_fill.py +++ b/code/scripts/synergy_promote_fill.py @@ -731,7 +731,7 @@ def main(): # pragma: no cover (script orchestration) if cand: theme_card_hits[display] = cand # Build global duplicate frequency map ONCE (baseline prior to this run) if threshold active - if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): + if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' not in globals(): # type: ignore freq: Dict[str, int] = {} total_themes = 0 for fp0 in CATALOG_DIR.glob('*.yml'): @@ -748,10 +748,10 @@ def main(): # pragma: no cover (script orchestration) continue seen_local.add(c) freq[c] = freq.get(c, 0) + 1 - globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) + globals()['GLOBAL_CARD_FREQ'] = (freq, total_themes) # type: ignore # Apply duplicate filtering to candidate lists (do NOT mutate existing example_cards) - if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): - freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] + if args.common_card_threshold > 0 and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore + freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore if total_prev > 0: # avoid div-by-zero cutoff = args.common_card_threshold def _filter(lst: List[Tuple[float, str, Set[str]]]) -> List[Tuple[float, str, Set[str]]]: @@ -803,8 +803,8 @@ def main(): # pragma: no cover (script orchestration) print(f"[promote] modified {changed_count} themes") if args.fill_example_cards: print(f"[cards] modified {cards_changed} themes (target {args.cards_target})") - if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): - freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] + if args.print_dup_metrics and 'GLOBAL_CARD_FREQ' in globals(): # type: ignore + freq_map, total_prev = globals()['GLOBAL_CARD_FREQ'] # type: ignore if total_prev: items = sorted(freq_map.items(), key=lambda x: (-x[1], x[0]))[:30] print('[dup-metrics] Top shared example_cards (baseline before this run):') diff --git a/code/scripts/validate_theme_catalog.py b/code/scripts/validate_theme_catalog.py index c6b3627..1b18962 100644 --- a/code/scripts/validate_theme_catalog.py +++ b/code/scripts/validate_theme_catalog.py @@ -31,9 +31,9 @@ CODE_ROOT = ROOT / 'code' if str(CODE_ROOT) not in sys.path: sys.path.insert(0, str(CODE_ROOT)) -from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile -from scripts.extract_themes import load_whitelist_config -from scripts.build_theme_catalog import build_catalog +from type_definitions_theme_catalog import ThemeCatalog, ThemeYAMLFile # type: ignore +from scripts.extract_themes import load_whitelist_config # type: ignore +from scripts.build_theme_catalog import build_catalog # type: ignore CATALOG_JSON = ROOT / 'config' / 'themes' / 'theme_list.json' diff --git a/code/scripts/warm_preview_traffic.py b/code/scripts/warm_preview_traffic.py new file mode 100644 index 0000000..0f54c73 --- /dev/null +++ b/code/scripts/warm_preview_traffic.py @@ -0,0 +1,91 @@ +"""Generate warm preview traffic to populate theme preview cache & metrics. + +Usage: + python -m code.scripts.warm_preview_traffic --count 25 --repeats 2 \ + --base-url http://localhost:8000 --delay 0.05 + +Requirements: + - FastAPI server running locally exposing /themes endpoints + - WEB_THEME_PICKER_DIAGNOSTICS=1 so /themes/metrics is accessible + +Strategy: + 1. Fetch /themes/fragment/list?limit=COUNT to obtain HTML table. + 2. Extract theme slugs via regex on data-theme-id attributes. + 3. Issue REPEATS preview fragment requests per slug in order. + 4. Print simple timing / status summary. + +This script intentionally uses stdlib only (urllib, re, time) to avoid extra deps. +""" +from __future__ import annotations + +import argparse +import re +import time +import urllib.request +import urllib.error +from typing import List + +LIST_PATH = "/themes/fragment/list" +PREVIEW_PATH = "/themes/fragment/preview/{slug}" + + +def fetch(url: str) -> str: + req = urllib.request.Request(url, headers={"User-Agent": "warm-preview/1"}) + with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 (local trusted) + return resp.read().decode("utf-8", "replace") + + +def extract_slugs(html: str, limit: int) -> List[str]: + slugs = [] + for m in re.finditer(r'data-theme-id="([^"]+)"', html): + s = m.group(1).strip() + if s and s not in slugs: + slugs.append(s) + if len(slugs) >= limit: + break + return slugs + + +def warm(base_url: str, count: int, repeats: int, delay: float) -> None: + list_url = f"{base_url}{LIST_PATH}?limit={count}&offset=0" + print(f"[warm] Fetching list: {list_url}") + try: + html = fetch(list_url) + except urllib.error.URLError as e: # pragma: no cover + raise SystemExit(f"Failed fetching list: {e}") + slugs = extract_slugs(html, count) + if not slugs: + raise SystemExit("No theme slugs extracted – cannot warm.") + print(f"[warm] Extracted {len(slugs)} slugs: {', '.join(slugs[:8])}{'...' if len(slugs)>8 else ''}") + total_requests = 0 + start = time.time() + for r in range(repeats): + print(f"[warm] Pass {r+1}/{repeats}") + for slug in slugs: + url = f"{base_url}{PREVIEW_PATH.format(slug=slug)}" + try: + fetch(url) + except Exception as e: # pragma: no cover + print(f" [warn] Failed {slug}: {e}") + else: + total_requests += 1 + if delay: + time.sleep(delay) + dur = time.time() - start + print(f"[warm] Completed {total_requests} preview requests in {dur:.2f}s ({total_requests/dur if dur>0 else 0:.1f} rps)") + print("[warm] Done. Now run metrics snapshot to capture warm p95.") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Generate warm preview traffic") + ap.add_argument("--base-url", default="http://localhost:8000", help="Base URL (default: %(default)s)") + ap.add_argument("--count", type=int, default=25, help="Number of distinct theme slugs to warm (default: %(default)s)") + ap.add_argument("--repeats", type=int, default=2, help="Repeat passes over slugs (default: %(default)s)") + ap.add_argument("--delay", type=float, default=0.05, help="Delay between requests in seconds (default: %(default)s)") + args = ap.parse_args(argv) + warm(args.base_url.rstrip("/"), args.count, args.repeats, args.delay) + return 0 + +if __name__ == "__main__": # pragma: no cover + import sys + raise SystemExit(main(sys.argv[1:])) diff --git a/code/services/__init__.py b/code/services/__init__.py deleted file mode 100644 index 19ad56b..0000000 --- a/code/services/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Services package for MTG Python Deckbuilder.""" - -from code.services.all_cards_loader import AllCardsLoader -from code.services.card_query_builder import CardQueryBuilder - -__all__ = ["AllCardsLoader", "CardQueryBuilder"] diff --git a/code/services/all_cards_loader.py b/code/services/all_cards_loader.py deleted file mode 100644 index 06c4780..0000000 --- a/code/services/all_cards_loader.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -All Cards Loader - -Provides efficient loading and querying of the consolidated all_cards.parquet file. -Features in-memory caching with TTL and automatic reload on file changes. - -Usage: - loader = AllCardsLoader() - - # Single card lookup - card = loader.get_by_name("Sol Ring") - - # Batch lookup - cards = loader.get_by_names(["Sol Ring", "Lightning Bolt", "Counterspell"]) - - # Filter by color identity - blue_cards = loader.filter_by_color_identity(["U"]) - - # Filter by themes - token_cards = loader.filter_by_themes(["tokens"], mode="any") - - # Simple text search - results = loader.search("create token", limit=100) -""" - -from __future__ import annotations - -import os -import time -from typing import Optional - -import pandas as pd - -from code.logging_util import get_logger - -# Initialize logger -logger = get_logger(__name__) - - -class AllCardsLoader: - """Loads and caches the consolidated all_cards.parquet file with query methods.""" - - def __init__(self, file_path: Optional[str] = None, cache_ttl: int = 300) -> None: - """ - Initialize AllCardsLoader. - - Args: - file_path: Path to all_cards.parquet (defaults to card_files/processed/all_cards.parquet) - cache_ttl: Time-to-live for cache in seconds (default: 300 = 5 minutes) - """ - if file_path is None: - from code.path_util import get_processed_cards_path - file_path = get_processed_cards_path() - - self.file_path = file_path - self.cache_ttl = cache_ttl - self._df: Optional[pd.DataFrame] = None - self._last_load_time: float = 0 - self._file_mtime: float = 0 - - def load(self, force_reload: bool = False) -> pd.DataFrame: - """ - Load all_cards.parquet with caching. - - Returns cached DataFrame if: - - Cache exists - - Cache is not expired (within TTL) - - File hasn't been modified since last load - - force_reload is False - - Args: - force_reload: Force reload from disk even if cached - - Returns: - DataFrame containing all cards - - Raises: - FileNotFoundError: If all_cards.parquet doesn't exist - """ - if not os.path.exists(self.file_path): - raise FileNotFoundError(f"All cards file not found: {self.file_path}") - - # Check if we need to reload - current_time = time.time() - file_mtime = os.path.getmtime(self.file_path) - - cache_valid = ( - self._df is not None - and not force_reload - and (current_time - self._last_load_time) < self.cache_ttl - and file_mtime == self._file_mtime - ) - - if cache_valid: - return self._df # type: ignore - - # Load from disk - logger.info(f"Loading all_cards from {self.file_path}...") - start_time = time.time() - self._df = pd.read_parquet(self.file_path, engine="pyarrow") - elapsed = time.time() - start_time - - self._last_load_time = current_time - self._file_mtime = file_mtime - - logger.info( - f"Loaded {len(self._df)} cards with {len(self._df.columns)} columns in {elapsed:.3f}s" - ) - - return self._df - - def get_by_name(self, name: str) -> Optional[pd.Series]: - """ - Get a single card by exact name match. - - Args: - name: Card name to search for - - Returns: - Series containing card data, or None if not found - """ - df = self.load() - if "name" not in df.columns: - logger.warning("'name' column not found in all_cards") - return None - - # Use .loc[] for faster exact match lookup - try: - matches = df.loc[df["name"] == name] - if matches.empty: - return None - return matches.iloc[0] - except (KeyError, IndexError): - return None - - def get_by_names(self, names: list[str]) -> pd.DataFrame: - """ - Get multiple cards by exact name matches (batch lookup). - - Args: - names: List of card names to search for - - Returns: - DataFrame containing matching cards (may be empty) - """ - df = self.load() - if "name" not in df.columns: - logger.warning("'name' column not found in all_cards") - return pd.DataFrame() - - return df[df["name"].isin(names)] - - def filter_by_color_identity(self, colors: list[str]) -> pd.DataFrame: - """ - Filter cards by color identity. - - Args: - colors: List of color codes (e.g., ["W", "U"], ["Colorless"], ["G", "R", "U"]) - - Returns: - DataFrame containing cards matching the color identity - """ - df = self.load() - if "colorIdentity" not in df.columns: - logger.warning("'colorIdentity' column not found in all_cards") - return pd.DataFrame() - - # Convert colors list to a set for comparison - color_set = set(colors) - - # Handle special case for colorless - if "Colorless" in color_set or "colorless" in color_set: - return df[df["colorIdentity"].isin(["Colorless", "colorless"])] - - # For multi-color searches, match any card that contains those colors - # This is a simple exact match - could be enhanced for subset/superset matching - if len(colors) == 1: - # Single color - exact match - return df[df["colorIdentity"] == colors[0]] - else: - # Multi-color - match any of the provided colors (could be refined) - return df[df["colorIdentity"].isin(colors)] - - def filter_by_themes(self, themes: list[str], mode: str = "any") -> pd.DataFrame: - """ - Filter cards by theme tags. - - Args: - themes: List of theme tags to search for - mode: "any" (at least one theme) or "all" (must have all themes) - - Returns: - DataFrame containing cards matching the theme criteria - """ - df = self.load() - if "themeTags" not in df.columns: - logger.warning("'themeTags' column not found in all_cards") - return pd.DataFrame() - - if mode == "all": - # Card must have all specified themes - mask = pd.Series([True] * len(df), index=df.index) - for theme in themes: - mask &= df["themeTags"].str.contains(theme, case=False, na=False) - return df[mask] - else: - # Card must have at least one of the specified themes (default) - mask = pd.Series([False] * len(df), index=df.index) - for theme in themes: - mask |= df["themeTags"].str.contains(theme, case=False, na=False) - return df[mask] - - def search(self, query: str, limit: int = 100) -> pd.DataFrame: - """ - Simple text search across card name, type, and oracle text. - - Args: - query: Search query string - limit: Maximum number of results to return - - Returns: - DataFrame containing matching cards (up to limit) - """ - df = self.load() - - # Search across multiple columns - mask = pd.Series([False] * len(df), index=df.index) - - if "name" in df.columns: - mask |= df["name"].str.contains(query, case=False, na=False) - - if "type" in df.columns: - mask |= df["type"].str.contains(query, case=False, na=False) - - if "text" in df.columns: - mask |= df["text"].str.contains(query, case=False, na=False) - - results = df[mask] - - if len(results) > limit: - return results.head(limit) - - return results - - def filter_by_type(self, type_query: str) -> pd.DataFrame: - """ - Filter cards by type line (supports partial matching). - - Args: - type_query: Type string to search for (e.g., "Creature", "Instant", "Artifact") - - Returns: - DataFrame containing cards matching the type - """ - df = self.load() - if "type" not in df.columns: - logger.warning("'type' column not found in all_cards") - return pd.DataFrame() - - return df[df["type"].str.contains(type_query, case=False, na=False)] - - def get_stats(self) -> dict: - """ - Get statistics about the loaded card data. - - Returns: - Dictionary with card count, column count, file size, and load time - """ - df = self.load() - - stats = { - "total_cards": len(df), - "columns": len(df.columns), - "file_path": self.file_path, - "file_size_mb": ( - round(os.path.getsize(self.file_path) / (1024 * 1024), 2) - if os.path.exists(self.file_path) - else 0 - ), - "cached": self._df is not None, - "cache_age_seconds": int(time.time() - self._last_load_time) - if self._last_load_time > 0 - else None, - } - - return stats - - def clear_cache(self) -> None: - """Clear the cached DataFrame, forcing next load to read from disk.""" - self._df = None - self._last_load_time = 0 - logger.info("Cache cleared") diff --git a/code/services/card_query_builder.py b/code/services/card_query_builder.py deleted file mode 100644 index 50f9a78..0000000 --- a/code/services/card_query_builder.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Card Query Builder - -Provides a fluent API for building complex card queries against the consolidated all_cards.parquet. - -Usage: - from code.services.card_query_builder import CardQueryBuilder - - # Simple query - builder = CardQueryBuilder() - cards = builder.colors(["W", "U"]).execute() - - # Complex query - cards = (CardQueryBuilder() - .colors(["G"]) - .themes(["tokens"], mode="any") - .types("Creature") - .limit(20) - .execute()) - - # Get specific cards - cards = CardQueryBuilder().names(["Sol Ring", "Lightning Bolt"]).execute() -""" - -from __future__ import annotations - -from typing import Optional - -import pandas as pd - -from code.services.all_cards_loader import AllCardsLoader - - -class CardQueryBuilder: - """Fluent API for building card queries.""" - - def __init__(self, loader: Optional[AllCardsLoader] = None) -> None: - """ - Initialize CardQueryBuilder. - - Args: - loader: AllCardsLoader instance (creates default if None) - """ - self._loader = loader or AllCardsLoader() - self._color_filter: Optional[list[str]] = None - self._theme_filter: Optional[list[str]] = None - self._theme_mode: str = "any" - self._type_filter: Optional[str] = None - self._name_filter: Optional[list[str]] = None - self._search_query: Optional[str] = None - self._limit: Optional[int] = None - - def colors(self, colors: list[str]) -> CardQueryBuilder: - """ - Filter by color identity. - - Args: - colors: List of color codes (e.g., ["W", "U"]) - - Returns: - Self for chaining - """ - self._color_filter = colors - return self - - def themes(self, themes: list[str], mode: str = "any") -> CardQueryBuilder: - """ - Filter by theme tags. - - Args: - themes: List of theme tags - mode: "any" (at least one) or "all" (must have all) - - Returns: - Self for chaining - """ - self._theme_filter = themes - self._theme_mode = mode - return self - - def types(self, type_query: str) -> CardQueryBuilder: - """ - Filter by type line (partial match). - - Args: - type_query: Type string to search for - - Returns: - Self for chaining - """ - self._type_filter = type_query - return self - - def names(self, names: list[str]) -> CardQueryBuilder: - """ - Filter by specific card names (batch lookup). - - Args: - names: List of card names - - Returns: - Self for chaining - """ - self._name_filter = names - return self - - def search(self, query: str) -> CardQueryBuilder: - """ - Add text search across name, type, and oracle text. - - Args: - query: Search query string - - Returns: - Self for chaining - """ - self._search_query = query - return self - - def limit(self, limit: int) -> CardQueryBuilder: - """ - Limit number of results. - - Args: - limit: Maximum number of results - - Returns: - Self for chaining - """ - self._limit = limit - return self - - def execute(self) -> pd.DataFrame: - """ - Execute the query and return results. - - Returns: - DataFrame containing matching cards - """ - # Start with all cards or specific names - if self._name_filter: - df = self._loader.get_by_names(self._name_filter) - else: - df = self._loader.load() - - # Apply color filter - if self._color_filter: - color_results = self._loader.filter_by_color_identity(self._color_filter) - df = df[df.index.isin(color_results.index)] - - # Apply theme filter - if self._theme_filter: - theme_results = self._loader.filter_by_themes(self._theme_filter, mode=self._theme_mode) - df = df[df.index.isin(theme_results.index)] - - # Apply type filter - if self._type_filter: - type_results = self._loader.filter_by_type(self._type_filter) - df = df[df.index.isin(type_results.index)] - - # Apply text search - if self._search_query: - search_results = self._loader.search(self._search_query, limit=999999) - df = df[df.index.isin(search_results.index)] - - # Apply limit - if self._limit and len(df) > self._limit: - df = df.head(self._limit) - - return df - - def count(self) -> int: - """ - Count results without returning full DataFrame. - - Returns: - Number of matching cards - """ - return len(self.execute()) - - def first(self) -> Optional[pd.Series]: - """ - Get first result only. - - Returns: - First matching card as Series, or None if no results - """ - results = self.execute() - if results.empty: - return None - return results.iloc[0] - - def reset(self) -> CardQueryBuilder: - """ - Reset all filters. - - Returns: - Self for chaining - """ - self._color_filter = None - self._theme_filter = None - self._theme_mode = "any" - self._type_filter = None - self._name_filter = None - self._search_query = None - self._limit = None - return self diff --git a/code/services/legacy_loader_adapter.py b/code/services/legacy_loader_adapter.py deleted file mode 100644 index b017984..0000000 --- a/code/services/legacy_loader_adapter.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -Legacy Loader Adapter - -Provides backward-compatible wrapper functions around AllCardsLoader for smooth migration. -Existing code can continue using old file-loading patterns while benefiting from -the new consolidated Parquet backend. - -This adapter will be maintained through v3.0.x and deprecated in v3.1+. - -Usage: - # Old code (still works): - from code.services.legacy_loader_adapter import load_cards_by_type - creatures = load_cards_by_type("Creature") - - # New code (preferred): - from code.services.all_cards_loader import AllCardsLoader - loader = AllCardsLoader() - creatures = loader.filter_by_type("Creature") -""" - -from __future__ import annotations - -import warnings -from typing import Optional - -import pandas as pd - -from code.logging_util import get_logger -from code.services.all_cards_loader import AllCardsLoader -from code.settings import USE_ALL_CARDS_FILE - -# Initialize logger -logger = get_logger(__name__) - -# Shared loader instance for performance -_shared_loader: Optional[AllCardsLoader] = None - - -def _get_loader() -> AllCardsLoader: - """Get or create shared AllCardsLoader instance.""" - global _shared_loader - if _shared_loader is None: - _shared_loader = AllCardsLoader() - return _shared_loader - - -def _deprecation_warning(func_name: str, replacement: str) -> None: - """Log deprecation warning for legacy functions.""" - warnings.warn( - f"{func_name} is deprecated and will be removed in v3.1+. " - f"Use {replacement} instead.", - DeprecationWarning, - stacklevel=3, - ) - logger.warning( - f"DEPRECATION: {func_name} called. Migrate to {replacement} before v3.1+" - ) - - -def load_all_cards(use_cache: bool = True) -> pd.DataFrame: - """ - Load all cards from consolidated Parquet file. - - Legacy function for backward compatibility. - - Args: - use_cache: Whether to use cached data (default: True) - - Returns: - DataFrame containing all cards - - Deprecated: - Use AllCardsLoader().load() instead. - """ - _deprecation_warning("load_all_cards()", "AllCardsLoader().load()") - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - return loader.load(force_reload=not use_cache) - - -def load_cards_by_name(name: str) -> Optional[pd.Series]: - """ - Load a single card by exact name match. - - Legacy function for backward compatibility. - - Args: - name: Card name to search for - - Returns: - Series containing card data, or None if not found - - Deprecated: - Use AllCardsLoader().get_by_name() instead. - """ - _deprecation_warning("load_cards_by_name()", "AllCardsLoader().get_by_name()") - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning None") - return None - - loader = _get_loader() - return loader.get_by_name(name) - - -def load_cards_by_names(names: list[str]) -> pd.DataFrame: - """ - Load multiple cards by exact name matches. - - Legacy function for backward compatibility. - - Args: - names: List of card names to search for - - Returns: - DataFrame containing matching cards - - Deprecated: - Use AllCardsLoader().get_by_names() instead. - """ - _deprecation_warning("load_cards_by_names()", "AllCardsLoader().get_by_names()") - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - return loader.get_by_names(names) - - -def load_cards_by_type(type_str: str) -> pd.DataFrame: - """ - Load cards by type line (partial match). - - Legacy function for backward compatibility. - - Args: - type_str: Type string to search for (e.g., "Creature", "Instant") - - Returns: - DataFrame containing cards matching the type - - Deprecated: - Use AllCardsLoader().filter_by_type() instead. - """ - _deprecation_warning("load_cards_by_type()", "AllCardsLoader().filter_by_type()") - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - return loader.filter_by_type(type_str) - - -def load_cards_with_tag(tag: str) -> pd.DataFrame: - """ - Load cards containing a specific theme tag. - - Legacy function for backward compatibility. - - Args: - tag: Theme tag to search for - - Returns: - DataFrame containing cards with the tag - - Deprecated: - Use AllCardsLoader().filter_by_themes() instead. - """ - _deprecation_warning("load_cards_with_tag()", "AllCardsLoader().filter_by_themes()") - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - return loader.filter_by_themes([tag], mode="any") - - -def load_cards_with_tags(tags: list[str], require_all: bool = False) -> pd.DataFrame: - """ - Load cards containing theme tags. - - Legacy function for backward compatibility. - - Args: - tags: List of theme tags to search for - require_all: If True, card must have all tags; if False, at least one tag - - Returns: - DataFrame containing cards matching the tag criteria - - Deprecated: - Use AllCardsLoader().filter_by_themes() instead. - """ - _deprecation_warning( - "load_cards_with_tags()", "AllCardsLoader().filter_by_themes()" - ) - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - mode = "all" if require_all else "any" - return loader.filter_by_themes(tags, mode=mode) - - -def load_cards_by_color_identity(colors: list[str]) -> pd.DataFrame: - """ - Load cards by color identity. - - Legacy function for backward compatibility. - - Args: - colors: List of color codes (e.g., ["W", "U"]) - - Returns: - DataFrame containing cards matching the color identity - - Deprecated: - Use AllCardsLoader().filter_by_color_identity() instead. - """ - _deprecation_warning( - "load_cards_by_color_identity()", "AllCardsLoader().filter_by_color_identity()" - ) - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - return loader.filter_by_color_identity(colors) - - -def search_cards(query: str, limit: int = 100) -> pd.DataFrame: - """ - Search cards by text query. - - Legacy function for backward compatibility. - - Args: - query: Search query string - limit: Maximum number of results - - Returns: - DataFrame containing matching cards - - Deprecated: - Use AllCardsLoader().search() instead. - """ - _deprecation_warning("search_cards()", "AllCardsLoader().search()") - - if not USE_ALL_CARDS_FILE: - logger.warning("USE_ALL_CARDS_FILE is disabled, returning empty DataFrame") - return pd.DataFrame() - - loader = _get_loader() - return loader.search(query, limit=limit) - - -def clear_card_cache() -> None: - """ - Clear the cached card data, forcing next load to read from disk. - - Legacy function for backward compatibility. - - Deprecated: - Use AllCardsLoader().clear_cache() instead. - """ - _deprecation_warning("clear_card_cache()", "AllCardsLoader().clear_cache()") - - global _shared_loader - if _shared_loader is not None: - _shared_loader.clear_cache() - _shared_loader = None diff --git a/code/settings.py b/code/settings.py index fb1caa9..101b4d5 100644 --- a/code/settings.py +++ b/code/settings.py @@ -89,34 +89,17 @@ COLUMN_ORDER = CARD_COLUMN_ORDER TAGGED_COLUMN_ORDER = CARD_COLUMN_ORDER REQUIRED_COLUMNS = REQUIRED_CARD_COLUMNS -# MAIN_MENU_ITEMS, SETUP_MENU_ITEMS, CSV_DIRECTORY already defined above (lines 67-70) +MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit'] -CARD_FILES_DIRECTORY: str = 'card_files' # Parquet files for consolidated card data +SETUP_MENU_ITEMS: List[str] = ['Initial Setup', 'Regenerate CSV', 'Main Menu'] -# ---------------------------------------------------------------------------------- -# PARQUET MIGRATION SETTINGS (v3.0.0+) -# ---------------------------------------------------------------------------------- +CSV_DIRECTORY: str = 'csv_files' -# Card files directory structure (Parquet-based) -# Override with environment variables for custom paths -CARD_FILES_DIR = os.getenv('CARD_FILES_DIR', 'card_files') -CARD_FILES_RAW_DIR = os.getenv('CARD_FILES_RAW_DIR', os.path.join(CARD_FILES_DIR, 'raw')) -CARD_FILES_PROCESSED_DIR = os.getenv('CARD_FILES_PROCESSED_DIR', os.path.join(CARD_FILES_DIR, 'processed')) - -# Legacy CSV compatibility mode (v3.0.0 only, removed in v3.1.0) -# Enable CSV fallback for testing or migration troubleshooting -# Set to '1' or 'true' to enable CSV fallback when Parquet loading fails -LEGACY_CSV_COMPAT = os.getenv('LEGACY_CSV_COMPAT', '0').lower() in ('1', 'true', 'on', 'enabled') - -# FILL_NA_COLUMNS already defined above (lines 75-78) - -# ---------------------------------------------------------------------------------- -# ALL CARDS CONSOLIDATION FEATURE FLAG -# ---------------------------------------------------------------------------------- - -# Enable use of consolidated all_cards.parquet file (default: True) -# Set to False to disable and fall back to individual CSV file loading -USE_ALL_CARDS_FILE = os.getenv('USE_ALL_CARDS_FILE', '1').lower() not in ('0', 'false', 'off', 'disabled') +# Configuration for handling null/NA values in DataFrame columns +FILL_NA_COLUMNS: Dict[str, Optional[str]] = { + 'colorIdentity': 'Colorless', # Default color identity for cards without one + 'faceName': None # Use card's name column value when face name is not available +} # ---------------------------------------------------------------------------------- # TAGGING REFINEMENT FEATURE FLAGS (M1-M5) @@ -132,28 +115,4 @@ TAG_PROTECTION_GRANTS = os.getenv('TAG_PROTECTION_GRANTS', '1').lower() not in ( TAG_METADATA_SPLIT = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled') # M5: Enable protection scope filtering in deck builder (completed - Phase 1-3, in progress Phase 4+) -TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled') - -# ---------------------------------------------------------------------------------- -# CARD BROWSER FEATURE FLAGS -# ---------------------------------------------------------------------------------- - -# Enable card detail pages (default: OFF) -# Set to '1' or 'true' to enable card detail pages in card browser -ENABLE_CARD_DETAILS = os.getenv('ENABLE_CARD_DETAILS', '0').lower() not in ('0', 'false', 'off', 'disabled') - -# Enable similarity/synergy features (default: OFF) -# Requires ENABLE_CARD_DETAILS=1 and manual cache build via Setup/Tag page -# Shows similar cards based on theme tag overlap using containment scoring -ENABLE_CARD_SIMILARITIES = os.getenv('ENABLE_CARD_SIMILARITIES', '0').lower() not in ('0', 'false', 'off', 'disabled') - -# Similarity cache configuration -SIMILARITY_CACHE_PATH = os.getenv('SIMILARITY_CACHE_PATH', 'card_files/similarity_cache.json') -SIMILARITY_CACHE_MAX_AGE_DAYS = int(os.getenv('SIMILARITY_CACHE_MAX_AGE_DAYS', '7')) - -# Allow downloading pre-built cache from GitHub (saves 15-20 min build time) -# Set to '0' to always build locally (useful for custom seeds or offline environments) -SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled') - -# Batch build feature flag (Build X and Compare) -ENABLE_BATCH_BUILD = os.getenv('ENABLE_BATCH_BUILD', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file +TAG_PROTECTION_SCOPE = os.getenv('TAG_PROTECTION_SCOPE', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file diff --git a/code/tagging/benchmark_tagging.py b/code/tagging/benchmark_tagging.py deleted file mode 100644 index a593d81..0000000 --- a/code/tagging/benchmark_tagging.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Benchmark tagging approaches: tag-centric vs card-centric. - -Compares performance of: -1. Tag-centric (current): Multiple passes, one per tag type -2. Card-centric (new): Single pass, all tags per card - -Usage: - python code/tagging/benchmark_tagging.py - -Or in Python: - from code.tagging.benchmark_tagging import run_benchmark - run_benchmark() -""" - -from __future__ import annotations - -import time - -import pandas as pd - -from file_setup.data_loader import DataLoader -from logging_util import get_logger -from path_util import get_processed_cards_path - -logger = get_logger(__name__) - - -def load_sample_data(sample_size: int = 1000) -> pd.DataFrame: - """Load a sample of cards for benchmarking. - - Args: - sample_size: Number of cards to sample (default: 1000) - - Returns: - DataFrame with sampled cards - """ - logger.info(f"Loading {sample_size} cards for benchmark") - - all_cards_path = get_processed_cards_path() - loader = DataLoader() - - df = loader.read_cards(all_cards_path, format="parquet") - - # Sample random cards (reproducible) - if len(df) > sample_size: - df = df.sample(n=sample_size, random_state=42) - - # Reset themeTags for fair comparison - df['themeTags'] = pd.Series([[] for _ in range(len(df))], index=df.index) - - logger.info(f"Loaded {len(df)} cards for benchmarking") - return df - - -def benchmark_tag_centric(df: pd.DataFrame, iterations: int = 3) -> dict: - """Benchmark the traditional tag-centric approach. - - Simulates the multi-pass approach where each tag function - iterates through all cards. - - Args: - df: DataFrame to tag - iterations: Number of times to run (for averaging) - - Returns: - Dict with timing stats - """ - import re - - times = [] - - for i in range(iterations): - test_df = df.copy() - - # Initialize themeTags - if 'themeTags' not in test_df.columns: - test_df['themeTags'] = pd.Series([[] for _ in range(len(test_df))], index=test_df.index) - - start = time.perf_counter() - - # PASS 1: Ramp tags - for idx in test_df.index: - text = str(test_df.at[idx, 'text']).lower() - if re.search(r'add.*mana|search.*land|ramp', text): - tags = test_df.at[idx, 'themeTags'] - if not isinstance(tags, list): - tags = [] - if 'Ramp' not in tags: - tags.append('Ramp') - test_df.at[idx, 'themeTags'] = tags - - # PASS 2: Card draw tags - for idx in test_df.index: - text = str(test_df.at[idx, 'text']).lower() - if re.search(r'draw.*card|card draw', text): - tags = test_df.at[idx, 'themeTags'] - if not isinstance(tags, list): - tags = [] - if 'Card Draw' not in tags: - tags.append('Card Draw') - test_df.at[idx, 'themeTags'] = tags - - # PASS 3: Removal tags - for idx in test_df.index: - text = str(test_df.at[idx, 'text']).lower() - if re.search(r'destroy|exile|counter|return.*hand', text): - tags = test_df.at[idx, 'themeTags'] - if not isinstance(tags, list): - tags = [] - for tag in ['Removal', 'Interaction']: - if tag not in tags: - tags.append(tag) - test_df.at[idx, 'themeTags'] = tags - - # PASS 4: Token tags - for idx in test_df.index: - text = str(test_df.at[idx, 'text']).lower() - if re.search(r'create.*token|token.*creature', text): - tags = test_df.at[idx, 'themeTags'] - if not isinstance(tags, list): - tags = [] - if 'Tokens' not in tags: - tags.append('Tokens') - test_df.at[idx, 'themeTags'] = tags - - # PASS 5: Card type tags - for idx in test_df.index: - type_line = str(test_df.at[idx, 'type']).lower() - tags = test_df.at[idx, 'themeTags'] - if not isinstance(tags, list): - tags = [] - if 'creature' in type_line and 'Creature' not in tags: - tags.append('Creature') - if 'artifact' in type_line and 'Artifact' not in tags: - tags.append('Artifact') - test_df.at[idx, 'themeTags'] = tags - - elapsed = time.perf_counter() - start - times.append(elapsed) - - logger.info(f"Tag-centric iteration {i+1}/{iterations}: {elapsed:.3f}s") - - return { - 'approach': 'tag-centric', - 'iterations': iterations, - 'times': times, - 'mean': sum(times) / len(times), - 'min': min(times), - 'max': max(times), - } - - -def benchmark_card_centric(df: pd.DataFrame, iterations: int = 3) -> dict: - """Benchmark the new card-centric approach. - - Args: - df: DataFrame to tag - iterations: Number of times to run (for averaging) - - Returns: - Dict with timing stats - """ - from tagging.tagger_card_centric import tag_all_cards_single_pass - - times = [] - - for i in range(iterations): - test_df = df.copy() - - start = time.perf_counter() - - tag_all_cards_single_pass(test_df) - - elapsed = time.perf_counter() - start - times.append(elapsed) - - logger.info(f"Card-centric iteration {i+1}/{iterations}: {elapsed:.3f}s") - - return { - 'approach': 'card-centric', - 'iterations': iterations, - 'times': times, - 'mean': sum(times) / len(times), - 'min': min(times), - 'max': max(times), - } - - -def run_benchmark(sample_sizes: list[int] = [100, 500, 1000, 5000]) -> None: - """Run comprehensive benchmark comparing both approaches. - - Args: - sample_sizes: List of dataset sizes to test - """ - print("\n" + "="*80) - print("TAGGING APPROACH BENCHMARK") - print("="*80) - print("\nComparing:") - print(" 1. Tag-centric (current): Multiple passes, one per tag type") - print(" 2. Card-centric (new): Single pass, all tags per card") - print() - - results = [] - - for size in sample_sizes: - print(f"\n{'─'*80}") - print(f"Testing with {size:,} cards...") - print(f"{'─'*80}") - - df = load_sample_data(sample_size=size) - - # Benchmark tag-centric - print("\n▶ Tag-centric approach:") - tag_centric_result = benchmark_tag_centric(df, iterations=3) - print(f" Mean: {tag_centric_result['mean']:.3f}s") - print(f" Range: {tag_centric_result['min']:.3f}s - {tag_centric_result['max']:.3f}s") - - # Benchmark card-centric - print("\n▶ Card-centric approach:") - card_centric_result = benchmark_card_centric(df, iterations=3) - print(f" Mean: {card_centric_result['mean']:.3f}s") - print(f" Range: {card_centric_result['min']:.3f}s - {card_centric_result['max']:.3f}s") - - # Compare - speedup = tag_centric_result['mean'] / card_centric_result['mean'] - winner = "Card-centric" if speedup > 1 else "Tag-centric" - - print(f"\n{'─'*40}") - if speedup > 1: - print(f"✓ {winner} is {speedup:.2f}x FASTER") - else: - print(f"✓ {winner} is {1/speedup:.2f}x FASTER") - print(f"{'─'*40}") - - results.append({ - 'size': size, - 'tag_centric_mean': tag_centric_result['mean'], - 'card_centric_mean': card_centric_result['mean'], - 'speedup': speedup, - 'winner': winner, - }) - - # Summary - print("\n" + "="*80) - print("SUMMARY") - print("="*80) - print(f"\n{'Size':<10} {'Tag-Centric':<15} {'Card-Centric':<15} {'Speedup':<10} {'Winner':<15}") - print("─" * 80) - - for r in results: - print(f"{r['size']:<10,} {r['tag_centric_mean']:<15.3f} {r['card_centric_mean']:<15.3f} {r['speedup']:<10.2f}x {r['winner']:<15}") - - # Overall recommendation - avg_speedup = sum(r['speedup'] for r in results) / len(results) - print("\n" + "="*80) - if avg_speedup > 1: - print(f"RECOMMENDATION: Use CARD-CENTRIC (avg {avg_speedup:.2f}x faster)") - else: - print(f"RECOMMENDATION: Use TAG-CENTRIC (avg {1/avg_speedup:.2f}x faster)") - print("="*80 + "\n") - - -if __name__ == "__main__": - run_benchmark() diff --git a/code/tagging/bracket_policy_applier.py b/code/tagging/bracket_policy_applier.py index 5265dd7..80c63b0 100644 --- a/code/tagging/bracket_policy_applier.py +++ b/code/tagging/bracket_policy_applier.py @@ -30,14 +30,14 @@ try: import logging_util except Exception: # Fallback for direct module loading - import importlib.util + import importlib.util # type: ignore root = Path(__file__).resolve().parents[1] lu_path = root / 'logging_util.py' spec = importlib.util.spec_from_file_location('logging_util', str(lu_path)) mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] assert spec and spec.loader - spec.loader.exec_module(mod) - logging_util = mod + spec.loader.exec_module(mod) # type: ignore[assignment] + logging_util = mod # type: ignore logger = logging_util.logging.getLogger(__name__) logger.setLevel(logging_util.LOG_LEVEL) diff --git a/code/tagging/colorless_filter_applier.py b/code/tagging/colorless_filter_applier.py deleted file mode 100644 index 9bea9dd..0000000 --- a/code/tagging/colorless_filter_applier.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Apply 'Useless in Colorless' metadata tags to cards that don't work in colorless identity decks. - -This module identifies and tags cards using regex patterns to match oracle text: -1. Cards referencing "your commander's color identity" -2. Cards that reduce costs of colored spells -3. Cards that trigger on casting colored spells - -Examples include: -- Arcane Signet, Command Tower (commander color identity) -- Pearl/Sapphire/Jet/Ruby/Emerald Medallion (colored cost reduction) -- Oketra's/Kefnet's/Bontu's/Hazoret's/Rhonas's Monument (colored creature cost reduction) -- Shrine of Loyal Legions, etc. (colored spell triggers) -""" -from __future__ import annotations -import logging -import pandas as pd - -logger = logging.getLogger(__name__) - -# Regex patterns for cards that don't work in colorless identity decks -COLORLESS_FILTER_PATTERNS = [ - # Cards referencing "your commander's color identity" - # BUT exclude Commander's Plate (protection from colors NOT in identity = amazing in colorless!) - # and Study Hall (still draws/scrys in colorless) - r"commander'?s?\s+color\s+identity", - - # Colored cost reduction - medallions and monuments - # Matches: "white spells you cast cost", "blue creature spells you cast cost", etc. - # Use non-capturing groups to avoid pandas UserWarning - r"(?:white|blue|black|red|green)\s+(?:creature\s+)?spells?\s+you\s+cast\s+cost.*less", - - # Colored spell triggers - shrines and similar - # Matches: "whenever you cast a white spell", etc. - # Use non-capturing groups to avoid pandas UserWarning - r"whenever\s+you\s+cast\s+a\s+(?:white|blue|black|red|green)\s+spell", -] - -# Cards that should NOT be filtered despite matching patterns -# These cards actually work great in colorless decks -COLORLESS_FILTER_EXCEPTIONS = [ - "Commander's Plate", # Protection from colors NOT in identity = protection from all colors in colorless! - "Study Hall", # Still provides colorless mana and scrys when casting commander -] - -USELESS_IN_COLORLESS_TAG = "Useless in Colorless" - - -def apply_colorless_filter_tags(df: pd.DataFrame) -> None: - """Apply 'Useless in Colorless' metadata tag to cards that don't work in colorless decks. - - Uses regex patterns to identify cards in oracle text that: - - Reference "your commander's color identity" - - Reduce costs of colored spells - - Trigger on casting colored spells - - Modifies the DataFrame in-place by adding tags to the 'themeTags' column. - These tags will later be moved to 'metadataTags' during the partition phase. - - Args: - df: DataFrame with 'name', 'text', and 'themeTags' columns - - Returns: - None (modifies DataFrame in-place) - """ - if 'name' not in df.columns: - logger.warning("No 'name' column found, skipping colorless filter tagging") - return - - if 'text' not in df.columns: - logger.warning("No 'text' column found, skipping colorless filter tagging") - return - - if 'themeTags' not in df.columns: - logger.warning("No 'themeTags' column found, skipping colorless filter tagging") - return - - # Combine all patterns with OR (use non-capturing groups to avoid pandas warning) - combined_pattern = "|".join(f"(?:{pattern})" for pattern in COLORLESS_FILTER_PATTERNS) - - # Find cards matching any pattern - df['text'] = df['text'].fillna('') - matches_pattern = df['text'].str.contains( - combined_pattern, - case=False, - regex=True, - na=False - ) - - # Exclude cards that work well in colorless despite matching patterns - is_exception = df['name'].isin(COLORLESS_FILTER_EXCEPTIONS) - matches_pattern = matches_pattern & ~is_exception - - tagged_count = 0 - - for idx in df[matches_pattern].index: - card_name = df.at[idx, 'name'] - tags = df.at[idx, 'themeTags'] - - # Ensure themeTags is a list - if not isinstance(tags, list): - tags = [] - - # Add tag if not already present - if USELESS_IN_COLORLESS_TAG not in tags: - tags.append(USELESS_IN_COLORLESS_TAG) - df.at[idx, 'themeTags'] = tags - tagged_count += 1 - logger.debug(f"Tagged '{card_name}' with '{USELESS_IN_COLORLESS_TAG}'") - - if tagged_count > 0: - logger.info(f"Applied '{USELESS_IN_COLORLESS_TAG}' tag to {tagged_count} cards") - else: - logger.info(f"No '{USELESS_IN_COLORLESS_TAG}' tags applied (no matches or already tagged)") - - -__all__ = [ - "apply_colorless_filter_tags", - "COLORLESS_FILTER_PATTERNS", - "COLORLESS_FILTER_EXCEPTIONS", - "USELESS_IN_COLORLESS_TAG", -] diff --git a/code/tagging/combo_tag_applier.py b/code/tagging/combo_tag_applier.py index de1461f..1e0ad68 100644 --- a/code/tagging/combo_tag_applier.py +++ b/code/tagging/combo_tag_applier.py @@ -11,6 +11,9 @@ from typing import DefaultDict, Dict, List, Set # Third-party imports import pandas as pd +# Local application imports +from settings import CSV_DIRECTORY, SETUP_COLORS + @dataclass(frozen=True) class ComboPair: @@ -92,73 +95,57 @@ def _safe_list_parse(s: object) -> List[str]: return [] -def apply_combo_tags( - df: pd.DataFrame | None = None, - combos_path: str | Path = "config/card_lists/combos.json" -) -> Dict[str, int]: - """Apply bidirectional comboTags to DataFrame based on combos.json. - - This function modifies the DataFrame in-place when called from the tagging pipeline. - It can also be called standalone without a DataFrame for legacy/CLI usage. +def apply_combo_tags(colors: List[str] | None = None, combos_path: str | Path = "config/card_lists/combos.json", csv_dir: str | Path | None = None) -> Dict[str, int]: + """Apply bidirectional comboTags to per-color CSVs based on combos.json. - Args: - df: DataFrame to modify in-place (from tagging pipeline), or None for standalone usage - combos_path: Path to combos.json file - - Returns: - Dict with 'total' key showing count of cards with combo tags + Returns a dict of color->updated_row_count for quick reporting. """ + colors = colors or list(SETUP_COLORS) combos_file = Path(combos_path) pairs = _load_pairs(combos_file) - - # If no DataFrame provided, load from Parquet (standalone mode) - standalone_mode = df is None - if standalone_mode: - parquet_path = "card_files/processed/all_cards.parquet" - parquet_file = Path(parquet_path) - if not parquet_file.exists(): - raise FileNotFoundError(f"Parquet file not found: {parquet_file}") - df = pd.read_parquet(parquet_file) - - _ensure_combo_cols(df) - before_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() - - # Build an index of canonicalized keys -> actual DF row names to update - name_index: DefaultDict[str, Set[str]] = defaultdict(set) - for nm in df["name"].astype(str).tolist(): - canon = _canonicalize(nm) - cf = canon.casefold() - name_index[cf].add(nm) - # If split/fused faces exist, map each face to the combined row name as well - if " // " in canon: - for part in canon.split(" // "): - p = part.strip().casefold() - if p: - name_index[p].add(nm) - - # Apply all combo pairs - for p in pairs: - a = _canonicalize(p.a) - b = _canonicalize(p.b) - a_key = a.casefold() - b_key = b.casefold() - # Apply A<->B bidirectionally to any matching DF rows - _apply_partner_to_names(df, name_index.get(a_key, set()), b) - _apply_partner_to_names(df, name_index.get(b_key, set()), a) - - after_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() - - # Calculate updated counts + updated_counts: Dict[str, int] = {} - if before_hash != after_hash: - updated_counts["total"] = int((df["comboTags"].apply(bool)).sum()) - else: - updated_counts["total"] = 0 - - # Only write back to Parquet in standalone mode - if standalone_mode and before_hash != after_hash: - df.to_parquet(parquet_file, index=False) - + base_dir = Path(csv_dir) if csv_dir is not None else Path(CSV_DIRECTORY) + for color in colors: + csv_path = base_dir / f"{color}_cards.csv" + if not csv_path.exists(): + continue + df = pd.read_csv(csv_path, converters={ + "themeTags": _safe_list_parse, + "creatureTypes": _safe_list_parse, + "comboTags": _safe_list_parse, + }) + + _ensure_combo_cols(df) + before_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() + + # Build an index of canonicalized keys -> actual DF row names to update. + name_index: DefaultDict[str, Set[str]] = defaultdict(set) + for nm in df["name"].astype(str).tolist(): + canon = _canonicalize(nm) + cf = canon.casefold() + name_index[cf].add(nm) + # If split/fused faces exist, map each face to the combined row name as well + if " // " in canon: + for part in canon.split(" // "): + p = part.strip().casefold() + if p: + name_index[p].add(nm) + + for p in pairs: + a = _canonicalize(p.a) + b = _canonicalize(p.b) + a_key = a.casefold() + b_key = b.casefold() + # Apply A<->B bidirectionally to any matching DF rows + _apply_partner_to_names(df, name_index.get(a_key, set()), b) + _apply_partner_to_names(df, name_index.get(b_key, set()), a) + + after_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() + if before_hash != after_hash: + df.to_csv(csv_path, index=False) + updated_counts[color] = int((df["comboTags"].apply(bool)).sum()) + return updated_counts diff --git a/code/tagging/multi_face_merger.py b/code/tagging/multi_face_merger.py index deb31ac..0dd2753 100644 --- a/code/tagging/multi_face_merger.py +++ b/code/tagging/multi_face_merger.py @@ -240,13 +240,6 @@ def merge_multi_face_rows( faces_payload = [_build_face_payload(row) for _, row in group_sorted.iterrows()] - # M9: Capture back face type for MDFC land detection - if len(group_sorted) >= 2 and "type" in group_sorted.columns: - back_face_row = group_sorted.iloc[1] - back_type = str(back_face_row.get("type", "") or "") - if back_type: - work_df.at[primary_idx, "backType"] = back_type - drop_indices.extend(group_sorted.index[1:]) merged_count += 1 diff --git a/code/tagging/old/combo_tag_applier.py b/code/tagging/old/combo_tag_applier.py deleted file mode 100644 index 1e0ad68..0000000 --- a/code/tagging/old/combo_tag_applier.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -# Standard library imports -import ast -import json -from collections import defaultdict -from dataclasses import dataclass -from pathlib import Path -from typing import DefaultDict, Dict, List, Set - -# Third-party imports -import pandas as pd - -# Local application imports -from settings import CSV_DIRECTORY, SETUP_COLORS - - -@dataclass(frozen=True) -class ComboPair: - a: str - b: str - cheap_early: bool = False - setup_dependent: bool = False - tags: List[str] | None = None - - -def _load_pairs(path: Path) -> List[ComboPair]: - data = json.loads(path.read_text(encoding="utf-8")) - pairs = [] - for entry in data.get("pairs", []): - pairs.append( - ComboPair( - a=entry["a"].strip(), - b=entry["b"].strip(), - cheap_early=bool(entry.get("cheap_early", False)), - setup_dependent=bool(entry.get("setup_dependent", False)), - tags=list(entry.get("tags", [])), - ) - ) - return pairs - - -def _canonicalize(name: str) -> str: - # Canonicalize for matching: trim, unify punctuation/quotes, collapse spaces, casefold later - if name is None: - return "" - s = str(name).strip() - # Normalize common unicode punctuation variants - s = s.replace("\u2019", "'") # curly apostrophe to straight - s = s.replace("\u2018", "'") - s = s.replace("\u201C", '"').replace("\u201D", '"') - s = s.replace("\u2013", "-").replace("\u2014", "-") # en/em dash -> hyphen - # Collapse multiple spaces - s = " ".join(s.split()) - return s - - -def _ensure_combo_cols(df: pd.DataFrame) -> None: - if "comboTags" not in df.columns: - df["comboTags"] = [[] for _ in range(len(df))] - - -def _apply_partner_to_names(df: pd.DataFrame, target_names: Set[str], partner: str) -> None: - if not target_names: - return - mask = df["name"].isin(target_names) - if not mask.any(): - return - current = df.loc[mask, "comboTags"] - df.loc[mask, "comboTags"] = current.apply( - lambda tags: sorted(list({*tags, partner})) if isinstance(tags, list) else [partner] - ) - - -def _safe_list_parse(s: object) -> List[str]: - if isinstance(s, list): - return s - if not isinstance(s, str) or not s.strip(): - return [] - txt = s.strip() - # Try JSON first - try: - v = json.loads(txt) - if isinstance(v, list): - return v - except Exception: - pass - # Fallback to Python literal - try: - v = ast.literal_eval(txt) - if isinstance(v, list): - return v - except Exception: - pass - return [] - - -def apply_combo_tags(colors: List[str] | None = None, combos_path: str | Path = "config/card_lists/combos.json", csv_dir: str | Path | None = None) -> Dict[str, int]: - """Apply bidirectional comboTags to per-color CSVs based on combos.json. - - Returns a dict of color->updated_row_count for quick reporting. - """ - colors = colors or list(SETUP_COLORS) - combos_file = Path(combos_path) - pairs = _load_pairs(combos_file) - - updated_counts: Dict[str, int] = {} - base_dir = Path(csv_dir) if csv_dir is not None else Path(CSV_DIRECTORY) - for color in colors: - csv_path = base_dir / f"{color}_cards.csv" - if not csv_path.exists(): - continue - df = pd.read_csv(csv_path, converters={ - "themeTags": _safe_list_parse, - "creatureTypes": _safe_list_parse, - "comboTags": _safe_list_parse, - }) - - _ensure_combo_cols(df) - before_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() - - # Build an index of canonicalized keys -> actual DF row names to update. - name_index: DefaultDict[str, Set[str]] = defaultdict(set) - for nm in df["name"].astype(str).tolist(): - canon = _canonicalize(nm) - cf = canon.casefold() - name_index[cf].add(nm) - # If split/fused faces exist, map each face to the combined row name as well - if " // " in canon: - for part in canon.split(" // "): - p = part.strip().casefold() - if p: - name_index[p].add(nm) - - for p in pairs: - a = _canonicalize(p.a) - b = _canonicalize(p.b) - a_key = a.casefold() - b_key = b.casefold() - # Apply A<->B bidirectionally to any matching DF rows - _apply_partner_to_names(df, name_index.get(a_key, set()), b) - _apply_partner_to_names(df, name_index.get(b_key, set()), a) - - after_hash = pd.util.hash_pandas_object(df[["name", "comboTags"]].astype(str)).sum() - if before_hash != after_hash: - df.to_csv(csv_path, index=False) - updated_counts[color] = int((df["comboTags"].apply(bool)).sum()) - - return updated_counts - - -if __name__ == "__main__": - counts = apply_combo_tags() - print("Updated comboTags counts:") - for k, v in counts.items(): - print(f" {k}: {v}") diff --git a/code/tagging/old/tagger.py b/code/tagging/old/tagger.py deleted file mode 100644 index db31b43..0000000 --- a/code/tagging/old/tagger.py +++ /dev/null @@ -1,6603 +0,0 @@ -from __future__ import annotations - -# Standard library imports -import json -import os -import re -from datetime import UTC, datetime -from pathlib import Path -from typing import Any, Dict, List, Union - -# Third-party imports -import pandas as pd - -# Local application imports -from . import regex_patterns as rgx -from . import tag_constants -from . import tag_utils -from .bracket_policy_applier import apply_bracket_policy_tags -from .colorless_filter_applier import apply_colorless_filter_tags -from .multi_face_merger import merge_multi_face_rows -import logging_util -from file_setup import setup -from file_setup.data_loader import DataLoader -from file_setup.setup_utils import enrich_commander_rows_with_tags -from settings import COLORS, CSV_DIRECTORY, MULTIPLE_COPY_CARDS -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - -# Create DataLoader instance for Parquet operations -_data_loader = DataLoader() - - -def _get_batch_id_for_color(color: str) -> int: - """Get unique batch ID for a color (for parallel-safe batch writes). - - Args: - color: Color name (e.g., 'white', 'blue', 'commander') - - Returns: - Unique integer batch ID based on COLORS index - """ - try: - return COLORS.index(color) - except ValueError: - # Fallback for unknown colors (shouldn't happen) - logger.warning(f"Unknown color '{color}', using hash-based batch ID") - return hash(color) % 1000 - - -_MERGE_FLAG_RAW = str(os.getenv("ENABLE_DFC_MERGE", "") or "").strip().lower() -if _MERGE_FLAG_RAW in {"0", "false", "off", "disabled"}: - logger.warning( - "ENABLE_DFC_MERGE=%s is deprecated and no longer disables the merge; multi-face merge is always enabled.", - _MERGE_FLAG_RAW, - ) -elif _MERGE_FLAG_RAW: - logger.info( - "ENABLE_DFC_MERGE=%s detected (deprecated); multi-face merge now runs unconditionally.", - _MERGE_FLAG_RAW, - ) - -_COMPAT_FLAG_RAW = os.getenv("DFC_COMPAT_SNAPSHOT") -if _COMPAT_FLAG_RAW is not None: - _COMPAT_FLAG_NORMALIZED = str(_COMPAT_FLAG_RAW or "").strip().lower() - DFC_COMPAT_SNAPSHOT = _COMPAT_FLAG_NORMALIZED not in {"0", "false", "off", "disabled"} -else: - DFC_COMPAT_SNAPSHOT = _MERGE_FLAG_RAW in {"compat", "dual", "both"} - -_DFC_COMPAT_DIR = Path(os.getenv("DFC_COMPAT_DIR", "csv_files/compat_faces")) - -_PER_FACE_SNAPSHOT_RAW = os.getenv("DFC_PER_FACE_SNAPSHOT") -if _PER_FACE_SNAPSHOT_RAW is not None: - _PER_FACE_SNAPSHOT_NORMALIZED = str(_PER_FACE_SNAPSHOT_RAW or "").strip().lower() - DFC_PER_FACE_SNAPSHOT = _PER_FACE_SNAPSHOT_NORMALIZED not in {"0", "false", "off", "disabled"} -else: - DFC_PER_FACE_SNAPSHOT = False - -_DFC_PER_FACE_SNAPSHOT_PATH = Path(os.getenv("DFC_PER_FACE_SNAPSHOT_PATH", "logs/dfc_per_face_snapshot.json")) -_PER_FACE_SNAPSHOT_BUFFER: Dict[str, List[Dict[str, Any]]] = {} - - -def _record_per_face_snapshot(color: str, payload: Dict[str, Any]) -> None: - if not DFC_PER_FACE_SNAPSHOT: - return - entries = payload.get("entries") - if not isinstance(entries, list): - return - bucket = _PER_FACE_SNAPSHOT_BUFFER.setdefault(color, []) - for entry in entries: - if not isinstance(entry, dict): - continue - faces_data = [] - raw_faces = entry.get("faces") - if isinstance(raw_faces, list): - for face in raw_faces: - if isinstance(face, dict): - faces_data.append({k: face.get(k) for k in ( - "face", - "side", - "layout", - "type", - "text", - "mana_cost", - "mana_value", - "produces_mana", - "is_land", - "themeTags", - "roleTags", - )}) - else: - faces_data.append(face) - primary_face = entry.get("primary_face") - if isinstance(primary_face, dict): - primary_face_copy = dict(primary_face) - else: - primary_face_copy = primary_face - removed_faces = entry.get("removed_faces") - if isinstance(removed_faces, list): - removed_faces_copy = [dict(face) if isinstance(face, dict) else face for face in removed_faces] - else: - removed_faces_copy = removed_faces - bucket.append( - { - "name": entry.get("name"), - "total_faces": entry.get("total_faces"), - "dropped_faces": entry.get("dropped_faces"), - "layouts": list(entry.get("layouts", [])) if isinstance(entry.get("layouts"), list) else entry.get("layouts"), - "primary_face": primary_face_copy, - "faces": faces_data, - "removed_faces": removed_faces_copy, - "theme_tags": entry.get("theme_tags"), - "role_tags": entry.get("role_tags"), - } - ) - - -def _flush_per_face_snapshot() -> None: - if not DFC_PER_FACE_SNAPSHOT: - _PER_FACE_SNAPSHOT_BUFFER.clear() - return - if not _PER_FACE_SNAPSHOT_BUFFER: - return - try: - colors_payload = {color: list(entries) for color, entries in _PER_FACE_SNAPSHOT_BUFFER.items()} - payload = { - "generated_at": datetime.now(UTC).isoformat(timespec="seconds"), - "mode": "always_on", - "compat_snapshot": bool(DFC_COMPAT_SNAPSHOT), - "colors": colors_payload, - } - _DFC_PER_FACE_SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True) - with _DFC_PER_FACE_SNAPSHOT_PATH.open("w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, sort_keys=True) - logger.info("Wrote per-face snapshot to %s", _DFC_PER_FACE_SNAPSHOT_PATH) - except Exception as exc: - logger.warning("Failed to write per-face snapshot: %s", exc) - finally: - _PER_FACE_SNAPSHOT_BUFFER.clear() - - -def _merge_summary_recorder(color: str): - def _recorder(payload: Dict[str, Any]) -> Dict[str, Any]: - enriched = dict(payload) - enriched["mode"] = "always_on" - enriched["compat_snapshot"] = bool(DFC_COMPAT_SNAPSHOT) - if DFC_PER_FACE_SNAPSHOT: - _record_per_face_snapshot(color, payload) - return enriched - - return _recorder - - -def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None: - try: - _DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True) - path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv" - df.to_csv(path, index=False) - logger.info("Wrote unmerged snapshot for %s to %s", color, path) - except Exception as exc: - logger.warning("Failed to write unmerged snapshot for %s: %s", color, exc) - - -def _classify_and_partition_tags( - tags: List[str], - metadata_counts: Dict[str, int], - theme_counts: Dict[str, int] -) -> tuple[List[str], List[str], int, int]: - """Classify tags as metadata or theme and update counters. - - Args: - tags: List of tags to classify - metadata_counts: Dict to track metadata tag counts - theme_counts: Dict to track theme tag counts - - Returns: - Tuple of (metadata_tags, theme_tags, metadata_moved, theme_kept) - """ - metadata_tags = [] - theme_tags = [] - metadata_moved = 0 - theme_kept = 0 - - for tag in tags: - classification = tag_utils.classify_tag(tag) - - if classification == "metadata": - metadata_tags.append(tag) - metadata_counts[tag] = metadata_counts.get(tag, 0) + 1 - metadata_moved += 1 - else: - theme_tags.append(tag) - theme_counts[tag] = theme_counts.get(tag, 0) + 1 - theme_kept += 1 - - return metadata_tags, theme_tags, metadata_moved, theme_kept - - -def _build_partition_diagnostics( - total_rows: int, - rows_with_tags: int, - total_metadata_moved: int, - total_theme_kept: int, - metadata_counts: Dict[str, int], - theme_counts: Dict[str, int] -) -> Dict[str, Any]: - """Build diagnostics dictionary for metadata partition operation. - - Args: - total_rows: Total rows processed - rows_with_tags: Rows that had any tags - total_metadata_moved: Total metadata tags moved - total_theme_kept: Total theme tags kept - metadata_counts: Count of each metadata tag - theme_counts: Count of each theme tag - - Returns: - Diagnostics dictionary - """ - most_common_metadata = sorted(metadata_counts.items(), key=lambda x: x[1], reverse=True)[:10] - most_common_themes = sorted(theme_counts.items(), key=lambda x: x[1], reverse=True)[:10] - - return { - "enabled": True, - "total_rows": total_rows, - "rows_with_tags": rows_with_tags, - "metadata_tags_moved": total_metadata_moved, - "theme_tags_kept": total_theme_kept, - "unique_metadata_tags": len(metadata_counts), - "unique_theme_tags": len(theme_counts), - "most_common_metadata": most_common_metadata, - "most_common_themes": most_common_themes - } - - -def _apply_metadata_partition(df: pd.DataFrame) -> tuple[pd.DataFrame, Dict[str, Any]]: - """Partition tags into themeTags and metadataTags columns. - - Metadata tags are diagnostic, bracket-related, or internal annotations that - should not appear in theme catalogs or player-facing lists. This function: - 1. Creates a new 'metadataTags' column - 2. Classifies each tag in 'themeTags' as metadata or theme - 3. Moves metadata tags to 'metadataTags' column - 4. Keeps theme tags in 'themeTags' column - 5. Returns summary diagnostics - - Args: - df: DataFrame with 'themeTags' column (list of tag strings) - - Returns: - Tuple of (modified DataFrame, diagnostics dict) - """ - tag_metadata_split = os.getenv('TAG_METADATA_SPLIT', '1').lower() not in ('0', 'false', 'off', 'disabled') - - if not tag_metadata_split: - logger.info("TAG_METADATA_SPLIT disabled, skipping metadata partition") - return df, { - "enabled": False, - "total_rows": len(df), - "message": "Feature disabled via TAG_METADATA_SPLIT=0" - } - - if 'themeTags' not in df.columns: - logger.warning("No 'themeTags' column found, skipping metadata partition") - return df, { - "enabled": True, - "error": "Missing themeTags column", - "total_rows": len(df) - } - df['metadataTags'] = pd.Series([[] for _ in range(len(df))], index=df.index) - metadata_counts: Dict[str, int] = {} - theme_counts: Dict[str, int] = {} - total_metadata_moved = 0 - total_theme_kept = 0 - rows_with_tags = 0 - for idx in df.index: - tags = df.at[idx, 'themeTags'] - - if not isinstance(tags, list) or not tags: - continue - - rows_with_tags += 1 - - # Classify and partition tags - metadata_tags, theme_tags, meta_moved, theme_kept = _classify_and_partition_tags( - tags, metadata_counts, theme_counts - ) - - total_metadata_moved += meta_moved - total_theme_kept += theme_kept - df.at[idx, 'themeTags'] = theme_tags - df.at[idx, 'metadataTags'] = metadata_tags - diagnostics = _build_partition_diagnostics( - len(df), rows_with_tags, total_metadata_moved, total_theme_kept, - metadata_counts, theme_counts - ) - logger.info( - f"Metadata partition complete: {total_metadata_moved} metadata tags moved, " - f"{total_theme_kept} theme tags kept across {rows_with_tags} rows" - ) - - if diagnostics["most_common_metadata"]: - top_5_metadata = ', '.join([f"{tag}({ct})" for tag, ct in diagnostics["most_common_metadata"][:5]]) - logger.info(f"Top metadata tags: {top_5_metadata}") - - return df, diagnostics - -### Setup -## Load the dataframe -def load_dataframe(color: str) -> None: - """ - Load and validate the card dataframe for a given color. - - Args: - color (str): The color of cards to load ('white', 'blue', etc) - - Raises: - FileNotFoundError: If CSV file doesn't exist and can't be regenerated - ValueError: If required columns are missing - """ - try: - filepath = f'{CSV_DIRECTORY}/{color}_cards.csv' - - # Check if file exists, regenerate if needed - if not os.path.exists(filepath): - logger.warning(f'{color}_cards.csv not found, regenerating it.') - setup.regenerate_csv_by_color(color) - if not os.path.exists(filepath): - raise FileNotFoundError(f"Failed to generate {filepath}") - - # Load initial dataframe for validation - check_df = pd.read_csv(filepath) - required_columns = ['creatureTypes', 'themeTags'] - missing_columns = [col for col in required_columns if col not in check_df.columns] - if missing_columns: - logger.warning(f"Missing columns: {missing_columns}") - if 'creatureTypes' not in check_df.columns: - kindred_tagging(check_df, color) - if 'themeTags' not in check_df.columns: - create_theme_tags(check_df, color) - - # Persist newly added columns before re-reading with converters - try: - check_df.to_csv(filepath, index=False) - except Exception as e: - logger.error(f'Failed to persist added columns to {filepath}: {e}') - raise - - # Verify columns were added successfully - check_df = pd.read_csv(filepath) - still_missing = [col for col in required_columns if col not in check_df.columns] - if still_missing: - raise ValueError(f"Failed to add required columns: {still_missing}") - - # Load final dataframe with proper converters - # M3: metadataTags is optional (may not exist in older CSVs) - converters = {'themeTags': pd.eval, 'creatureTypes': pd.eval} - if 'metadataTags' in check_df.columns: - converters['metadataTags'] = pd.eval - - df = pd.read_csv(filepath, converters=converters) - tag_by_color(df, color) - - except FileNotFoundError as e: - logger.error(f'Error: {e}') - raise - except pd.errors.ParserError as e: - logger.error(f'Error parsing the CSV file: {e}') - raise - except Exception as e: - logger.error(f'An unexpected error occurred: {e}') - raise - -def _tag_foundational_categories(df: pd.DataFrame, color: str) -> None: - """Apply foundational card categorization (creature types, card types, keywords). - - Args: - df: DataFrame containing card data - color: Color identifier for logging - """ - kindred_tagging(df, color) - print('\n====================\n') - create_theme_tags(df, color) - print('\n====================\n') - add_creatures_to_tags(df, color) - print('\n====================\n') - tag_for_card_types(df, color) - print('\n====================\n') - tag_for_keywords(df, color) - print('\n====================\n') - tag_for_partner_effects(df, color) - print('\n====================\n') - - -def _tag_mechanical_themes(df: pd.DataFrame, color: str) -> None: - """Apply mechanical theme tags (cost reduction, draw, artifacts, enchantments, etc.). - - Args: - df: DataFrame containing card data - color: Color identifier for logging - """ - tag_for_cost_reduction(df, color) - print('\n====================\n') - tag_for_freerunning(df, color) - print('\n====================\n') - tag_for_card_draw(df, color) - print('\n====================\n') - tag_for_discard_matters(df, color) - print('\n====================\n') - tag_for_explore_and_map(df, color) - print('\n====================\n') - tag_for_artifacts(df, color) - print('\n====================\n') - tag_for_enchantments(df, color) - print('\n====================\n') - tag_for_craft(df, color) - print('\n====================\n') - tag_for_exile_matters(df, color) - print('\n====================\n') - tag_for_bending(df, color) - print('\n====================\n') - tag_for_land_types(df, color) - print('\n====================\n') - tag_for_web_slinging(df, color) - print('\n====================\n') - tag_for_tokens(df, color) - print('\n====================\n') - tag_for_rad_counters(df, color) - print('\n====================\n') - tag_for_life_matters(df, color) - print('\n====================\n') - tag_for_counters(df, color) - print('\n====================\n') - - -def _tag_strategic_themes(df: pd.DataFrame, color: str) -> None: - """Apply strategic theme tags (voltron, lands, spellslinger, ramp). - - Args: - df: DataFrame containing card data - color: Color identifier for logging - """ - tag_for_voltron(df, color) - print('\n====================\n') - tag_for_lands_matter(df, color) - print('\n====================\n') - tag_for_spellslinger(df, color) - print('\n====================\n') - tag_for_spree(df, color) - print('\n====================\n') - tag_for_ramp(df, color) - print('\n====================\n') - tag_for_themes(df, color) - print('\n====================\n') - tag_for_interaction(df, color) - print('\n====================\n') - - -def _tag_archetype_themes(df: pd.DataFrame, color: str) -> None: - """Apply high-level archetype tags (midrange, toolbox, pillowfort, politics). - - Args: - df: DataFrame containing card data - color: Color identifier for logging - """ - tag_for_midrange_archetype(df, color) - print('\n====================\n') - tag_for_toolbox_archetype(df, color) - print('\n====================\n') - tag_for_pillowfort(df, color) - print('\n====================\n') - tag_for_politics(df, color) - print('\n====================\n') - - -## Tag cards on a color-by-color basis -def tag_by_color(df: pd.DataFrame, color: str) -> None: - """Orchestrate all tagging operations for a color's DataFrame. - - Applies tags in this order: - 1. Foundational categories (creature types, card types, keywords) - 2. Mechanical themes (cost reduction, draw, artifacts, tokens, etc.) - 3. Strategic themes (voltron, lands matter, spellslinger, ramp) - 4. High-level archetypes (midrange, toolbox, pillowfort, politics) - 5. Bracket policy tags - - Args: - df: DataFrame containing card data - color: Color identifier for logging - """ - _tag_foundational_categories(df, color) - _tag_mechanical_themes(df, color) - _tag_strategic_themes(df, color) - _tag_archetype_themes(df, color) - - # Apply bracket policy tags (from config/card_lists/*.json) - apply_bracket_policy_tags(df) - - # Apply colorless filter tags (M1: Useless in Colorless) - apply_colorless_filter_tags(df) - print('\n====================\n') - - # Merge multi-face entries before final ordering (feature-flagged) - if DFC_COMPAT_SNAPSHOT: - try: - _write_compat_snapshot(df.copy(deep=True), color) - except Exception: - pass - - df = merge_multi_face_rows(df, color, logger=logger, recorder=_merge_summary_recorder(color)) - - if color == 'commander': - df = enrich_commander_rows_with_tags(df, CSV_DIRECTORY) - - # Sort all theme tags for easier reading and reorder columns - df = sort_theme_tags(df, color) - - # M3: Partition metadata tags from theme tags - df, partition_diagnostics = _apply_metadata_partition(df) - if partition_diagnostics.get("enabled"): - logger.info(f"Metadata partition for {color}: {partition_diagnostics['metadata_tags_moved']} metadata, " - f"{partition_diagnostics['theme_tags_kept']} theme tags") - - df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False) - #print(df) - print('\n====================\n') - logger.info(f'Tags are done being set on {color}_cards.csv') - #keyboard.wait('esc') - -## Determine any non-creature cards that have creature types mentioned -def kindred_tagging(df: pd.DataFrame, color: str) -> None: - """Tag cards with creature types and related types. - - Args: - df: DataFrame containing card data - color: Color identifier for logging - """ - start_time = pd.Timestamp.now() - logger.info(f'Setting creature type tags on {color}_cards.csv') - - try: - df['creatureTypes'] = pd.Series([[] for _ in range(len(df))], index=df.index) - - # Detect creature types using vectorized split/filter - creature_mask = tag_utils.create_type_mask(df, 'Creature') - if creature_mask.any(): - df.loc[creature_mask, 'creatureTypes'] = ( - df.loc[creature_mask, 'type'] - .fillna('') - .str.split() - .apply(lambda ts: [ - t for t in ts - if t in tag_constants.CREATURE_TYPES and t not in tag_constants.NON_CREATURE_TYPES - ]) - ) - - creature_time = pd.Timestamp.now() - logger.info(f'Creature type detection completed in {(creature_time - start_time).total_seconds():.2f}s') - print('\n==========\n') - - logger.info(f'Setting Outlaw creature type tags on {color}_cards.csv') - outlaws = tag_constants.OUTLAW_TYPES - df['creatureTypes'] = df.apply( - lambda row: tag_utils.add_outlaw_type(row['creatureTypes'], outlaws) - if isinstance(row['creatureTypes'], list) else row['creatureTypes'], - axis=1 - ) - - outlaw_time = pd.Timestamp.now() - logger.info(f'Outlaw type processing completed in {(outlaw_time - creature_time).total_seconds():.2f}s') - - # Find creature types in text - logger.info('Checking for creature types in card text') - # Check for creature types in text (i.e. how 'Voja, Jaws of the Conclave' cares about Elves) - logger.info(f'Checking for and setting creature types found in the text of cards in {color}_cards.csv') - ignore_list = [ - 'Elite Inquisitor', 'Breaker of Armies', - 'Cleopatra, Exiled Pharaoh', 'Nath\'s Buffoon' - ] - - # Compute text-based types using vectorized apply over rows - text_types_series = df.apply( - lambda r: tag_utils.find_types_in_text(r['text'], r['name'], tag_constants.CREATURE_TYPES) - if r['name'] not in ignore_list else [], axis=1 - ) - has_text_types = text_types_series.apply(bool) - if has_text_types.any(): - df.loc[has_text_types, 'creatureTypes'] = df.loc[has_text_types].apply( - lambda r: sorted(list(set((r['creatureTypes'] if isinstance(r['creatureTypes'], list) else []) + text_types_series.at[r.name]))), - axis=1 - ) - - text_time = pd.Timestamp.now() - logger.info(f'Text-based type detection completed in {(text_time - outlaw_time).total_seconds():.2f}s') - - # Skip intermediate disk writes; final save happens at end of tag_by_color - total_time = pd.Timestamp.now() - start_time - logger.info(f'Creature type tagging completed in {total_time.total_seconds():.2f}s') - - # Overwrite file with creature type tags - except Exception as e: - logger.error(f'Error in kindred_tagging: {e}') - raise - -def create_theme_tags(df: pd.DataFrame, color: str) -> None: - """Initialize and configure theme tags for a card DataFrame. - - This function initializes the themeTags column, validates the DataFrame structure, - and reorganizes columns in an efficient manner. It uses vectorized operations - for better performance. - - Args: - df: DataFrame containing card data to process - color: Color identifier for logging purposes (e.g. 'white', 'blue') - - Returns: - The processed DataFrame with initialized theme tags and reorganized columns - - Raises: - ValueError: If required columns are missing or color is invalid - TypeError: If inputs are not of correct type - """ - logger.info('Initializing theme tags for %s cards', color) - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - if color not in COLORS: - raise ValueError(f"Invalid color: {color}") - - try: - df['themeTags'] = pd.Series([[] for _ in range(len(df))], index=df.index) - - # Define expected columns - required_columns = { - 'name', 'text', 'type', 'keywords', - 'creatureTypes', 'power', 'toughness' - } - missing = required_columns - set(df.columns) - if missing: - raise ValueError(f"Missing required columns: {missing}") - - # Define column order - columns_to_keep = tag_constants.REQUIRED_COLUMNS - - # Reorder columns efficiently - available_cols = [col for col in columns_to_keep if col in df.columns] - df = df.reindex(columns=available_cols) - - # Skip intermediate disk writes; final save happens at end of tag_by_color - logger.info('Theme tags initialized for %s', color) - - except Exception as e: - logger.error('Error initializing theme tags: %s', str(e)) - raise - -def tag_for_card_types(df: pd.DataFrame, color: str) -> None: - """Tag cards based on their types using vectorized operations. - - This function efficiently applies tags based on card types using vectorized operations. - It handles special cases for different card types and maintains compatibility with - the existing tagging system. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required columns are missing - """ - try: - required_cols = {'type', 'themeTags'} - if not required_cols.issubset(df.columns): - raise ValueError(f"Missing required columns: {required_cols - set(df.columns)}") - - # Define type-to-tag mapping - type_tag_map = tag_constants.TYPE_TAG_MAPPING - rules = [ - { 'mask': tag_utils.create_type_mask(df, card_type), 'tags': tags } - for card_type, tags in type_tag_map.items() - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'card type tags', color=color, logger=logger - ) - - except Exception as e: - logger.error('Error in tag_for_card_types: %s', str(e)) - raise - -## Add creature types to the theme tags -def add_creatures_to_tags(df: pd.DataFrame, color: str) -> None: - """Add kindred tags to theme tags based on creature types using vectorized operations. - - This function efficiently processes creature types and adds corresponding kindred tags - using pandas vectorized operations instead of row-by-row iteration. - - Args: - df: DataFrame containing card data with creatureTypes and themeTags columns - color: Color identifier for logging purposes - - Raises: - ValueError: If required columns are missing - TypeError: If inputs are not of correct type - """ - logger.info(f'Adding creature types to theme tags in {color}_cards.csv') - - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'creatureTypes', 'themeTags'} - missing = required_cols - set(df.columns) - if missing: - raise ValueError(f"Missing required columns: {missing}") - has_creatures_mask = df['creatureTypes'].apply(lambda x: bool(x) if isinstance(x, list) else False) - - if has_creatures_mask.any(): - creature_rows = df[has_creatures_mask] - - # Generate kindred tags vectorized - def add_kindred_tags(row): - current_tags = row['themeTags'] - kindred_tags = [f"{ct} Kindred" for ct in row['creatureTypes']] - return sorted(list(set(current_tags + kindred_tags))) - df.loc[has_creatures_mask, 'themeTags'] = creature_rows.apply(add_kindred_tags, axis=1) - - logger.info(f'Added kindred tags to {has_creatures_mask.sum()} cards') - - else: - logger.info('No cards with creature types found') - - except Exception as e: - logger.error(f'Error in add_creatures_to_tags: {str(e)}') - raise - - logger.info(f'Creature types added to theme tags in {color}_cards.csv') - -## Add keywords to theme tags -def tag_for_keywords(df: pd.DataFrame, color: str) -> None: - """Tag cards based on their keywords using vectorized operations. - - When TAG_NORMALIZE_KEYWORDS is enabled, applies normalization: - - Canonical mapping (e.g., "Commander Ninjutsu" -> "Ninjutsu") - - Singleton pruning (unless allowlisted) - - Case normalization - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - logger.info('Tagging cards with keywords in %s_cards.csv', color) - start_time = pd.Timestamp.now() - - try: - from settings import TAG_NORMALIZE_KEYWORDS - - # Load frequency map if normalization is enabled - frequency_map: dict[str, int] = {} - if TAG_NORMALIZE_KEYWORDS: - freq_map_path = Path(__file__).parent / 'keyword_frequency_map.json' - if freq_map_path.exists(): - with open(freq_map_path, 'r', encoding='utf-8') as f: - frequency_map = json.load(f) - logger.info('Loaded keyword frequency map with %d entries', len(frequency_map)) - else: - logger.warning('Keyword frequency map not found, normalization disabled for this run') - TAG_NORMALIZE_KEYWORDS = False - has_keywords = pd.notna(df['keywords']) - - if has_keywords.any(): - # Vectorized split and merge into themeTags - keywords_df = df.loc[has_keywords, ['themeTags', 'keywords']].copy() - exclusion_keywords = {'partner'} - - def _merge_keywords(row: pd.Series) -> list[str]: - base_tags = row['themeTags'] if isinstance(row['themeTags'], list) else [] - keywords_raw = row['keywords'] - - if isinstance(keywords_raw, str): - keywords_iterable = [part.strip() for part in keywords_raw.split(',')] - elif isinstance(keywords_raw, (list, tuple, set)): - keywords_iterable = [str(part).strip() for part in keywords_raw] - else: - keywords_iterable = [] - - # Apply normalization if enabled - if TAG_NORMALIZE_KEYWORDS and frequency_map: - normalized_keywords = tag_utils.normalize_keywords( - keywords_iterable, - tag_constants.KEYWORD_ALLOWLIST, - frequency_map - ) - return sorted(list(set(base_tags + normalized_keywords))) - else: - # Legacy behavior: simple exclusion filter - filtered_keywords = [ - kw for kw in keywords_iterable - if kw and kw.lower() not in exclusion_keywords - ] - return sorted(list(set(base_tags + filtered_keywords))) - - df.loc[has_keywords, 'themeTags'] = keywords_df.apply(_merge_keywords, axis=1) - - duration = (pd.Timestamp.now() - start_time).total_seconds() - logger.info('Tagged %d cards with keywords in %.2f seconds', has_keywords.sum(), duration) - - if TAG_NORMALIZE_KEYWORDS: - logger.info('Keyword normalization enabled for %s', color) - - except Exception as e: - logger.error('Error tagging keywords: %s', str(e)) - raise - -## Sort any set tags -def sort_theme_tags(df, color): - logger.info(f'Alphabetically sorting theme tags in {color}_cards.csv.') - - # Sort the list of tags in-place per row - df['themeTags'] = df['themeTags'].apply(tag_utils.sort_list) - - # Reorder columns for final CSV output; return a reindexed copy - columns_to_keep = ['name', 'faceName','edhrecRank', 'colorIdentity', 'colors', 'manaCost', 'manaValue', 'type', 'creatureTypes', 'text', 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'] - available = [c for c in columns_to_keep if c in df.columns] - logger.info(f'Theme tags alphabetically sorted in {color}_cards.csv.') - return df.reindex(columns=available) - -### Partner Mechanics -def tag_for_partner_effects(df: pd.DataFrame, color: str) -> None: - """Tag cards for partner-related keywords. - - Looks for 'partner', 'partner with', and permutations in rules text and - applies tags accordingly. - """ - try: - rules = [ - {'mask': tag_utils.create_text_mask(df, r"\bpartner\b(?!\s*(?:with|[-—–]))"), 'tags': ['Partner']}, - {'mask': tag_utils.create_text_mask(df, 'partner with'), 'tags': ['Partner with']}, - {'mask': tag_utils.create_text_mask(df, r"Partner\s*[-—–]\s*Survivors"), 'tags': ['Partner - Survivors']}, - {'mask': tag_utils.create_text_mask(df, r"Partner\s*[-—–]\s*Father\s*&\s*Son"), 'tags': ['Partner - Father & Son']}, - {'mask': tag_utils.create_text_mask(df, 'Friends forever'), 'tags': ['Friends Forever']}, - {'mask': tag_utils.create_text_mask(df, "Doctor's companion"), 'tags': ["Doctor's Companion"]}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'partner effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging partner keywords: {str(e)}') - raise - -### Cost reductions -def tag_for_cost_reduction(df: pd.DataFrame, color: str) -> None: - """Tag cards that reduce spell costs using vectorized operations. - - This function identifies cards that reduce casting costs through various means including: - - General cost reduction effects - - Artifact cost reduction - - Enchantment cost reduction - - Affinity and similar mechanics - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - cost_mask = tag_utils.create_text_mask(df, tag_constants.PATTERN_GROUPS['cost_reduction']) - - # Add specific named cards - named_cards = [ - 'Ancient Cellarspawn', 'Beluna Grandsquall', 'Cheering Fanatic', - 'Cloud Key', 'Conduit of Ruin', 'Eluge, the Shoreless Sea', - 'Goblin Anarchomancer', 'Goreclaw, Terror of Qal Sisma', - 'Helm of Awakening', 'Hymn of the Wilds', 'It that Heralds the End', - 'K\'rrik, Son of Yawgmoth', 'Killian, Ink Duelist', 'Krosan Drover', - 'Memory Crystal', 'Myth Unbound', 'Mistform Warchief', - 'Ranar the Ever-Watchful', 'Rowan, Scion of War', 'Semblence Anvil', - 'Spectacle Mage', 'Spellwild Ouphe', 'Strong Back', - 'Thryx, the Sudden Storm', 'Urza\'s Filter', 'Will, Scion of Peace', - 'Will Kenrith' - ] - named_mask = tag_utils.create_name_mask(df, named_cards) - final_mask = cost_mask | named_mask - spell_mask = final_mask & tag_utils.create_text_mask(df, r"Sorcery|Instant|noncreature") - tag_utils.tag_with_rules_and_logging(df, [ - { 'mask': final_mask, 'tags': ['Cost Reduction'] }, - { 'mask': spell_mask, 'tags': ['Spellslinger', 'Spells Matter'] }, - ], 'cost reduction cards', color=color, logger=logger) - - except Exception as e: - logger.error('Error tagging cost reduction cards: %s', str(e)) - raise - -### Card draw/advantage -## General card draw/advantage -def tag_for_card_draw(df: pd.DataFrame, color: str) -> None: - """Tag cards that have card draw effects or care about drawing cards. - - This function identifies and tags cards with various types of card draw effects including: - - Conditional draw (triggered/activated abilities) - - Looting effects (draw + discard) - - Cost-based draw (pay life/sacrifice) - - Replacement draw effects - - Wheel effects - - Unconditional draw - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Card Draw', 'Spellslinger', etc. - - Args: - df: DataFrame containing card data to process - color: Color identifier for logging purposes (e.g. 'white', 'blue') - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting card draw effect tagging for {color}_cards.csv') - - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of draw effect - tag_for_conditional_draw(df, color) - logger.info('Completed conditional draw tagging') - print('\n==========\n') - - tag_for_loot_effects(df, color) - logger.info('Completed loot effects tagging') - print('\n==========\n') - - tag_for_cost_draw(df, color) - logger.info('Completed cost-based draw tagging') - print('\n==========\n') - - tag_for_replacement_draw(df, color) - logger.info('Completed replacement draw tagging') - print('\n==========\n') - - tag_for_wheels(df, color) - logger.info('Completed wheel effects tagging') - print('\n==========\n') - - tag_for_unconditional_draw(df, color) - logger.info('Completed unconditional draw tagging') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all card draw tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_card_draw: {str(e)}') - raise - -## Conditional card draw (i.e. Rhystic Study or Trouble In Pairs) -def create_unconditional_draw_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with unconditional draw effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have unconditional draw effects - """ - draw_mask = tag_utils.create_numbered_phrase_mask(df, 'draw', 'card') - excluded_tags = tag_constants.DRAW_RELATED_TAGS - tag_mask = tag_utils.create_tag_mask(df, excluded_tags) - text_patterns = tag_constants.DRAW_EXCLUSION_PATTERNS - text_mask = tag_utils.create_text_mask(df, text_patterns) - - return draw_mask & ~(tag_mask | text_mask) - -def tag_for_unconditional_draw(df: pd.DataFrame, color: str) -> None: - """Tag cards that have unconditional draw effects using vectorized operations. - - This function identifies and tags cards that draw cards without conditions or - additional costs. It excludes cards that already have conditional draw tags - or specific keywords. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - draw_mask = create_unconditional_draw_mask(df) - tag_utils.tag_with_logging(df, draw_mask, ['Unconditional Draw', 'Card Draw'], 'unconditional draw effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging unconditional draw effects: {str(e)}') - raise - -## Conditional card draw (i.e. Rhystic Study or Trouble In Pairs) -def create_conditional_draw_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from conditional draw effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - excluded_tags = tag_constants.DRAW_RELATED_TAGS - tag_mask = tag_utils.create_tag_mask(df, excluded_tags) - text_patterns = tag_constants.DRAW_EXCLUSION_PATTERNS + ['whenever you draw a card'] - text_mask = tag_utils.create_text_mask(df, text_patterns) - excluded_names = ['relic vial', 'vexing bauble'] - name_mask = tag_utils.create_name_mask(df, excluded_names) - - return tag_mask | text_mask | name_mask - -def create_conditional_draw_trigger_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with conditional draw triggers. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have trigger patterns - """ - subjects = [ - 'a permanent', - 'a creature', - 'a player', - 'an opponent', - 'another creature', - 'enchanted player', - 'one or more creatures', - 'one or more other creatures', - 'you', - ] - trigger_mask = tag_utils.create_trigger_mask(df, subjects, include_attacks=True) - - # Add other trigger patterns - other_patterns = ['created a token', 'draw a card for each'] - other_mask = tag_utils.create_text_mask(df, other_patterns) - - return trigger_mask | other_mask - -def create_conditional_draw_effect_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with draw effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have draw effects - """ - # Create draw patterns using helper plus extras - base_mask = tag_utils.create_numbered_phrase_mask(df, 'draw', 'card') - extra_mask = tag_utils.create_text_mask(df, ['created a token.*draw', 'draw a card for each']) - return base_mask | extra_mask - -def tag_for_conditional_draw(df: pd.DataFrame, color: str) -> None: - """Tag cards that have conditional draw effects using vectorized operations. - - This function identifies and tags cards that draw cards based on triggers or conditions. - It handles various patterns including: - - Permanent/creature triggers - - Player-based triggers - - Token creation triggers - - 'Draw for each' effects - - The function excludes cards that: - - Already have certain tags (Cycling, Imprint, etc.) - - Contain specific text patterns (annihilator, ravenous) - - Have specific names (relic vial, vexing bauble) - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Build masks - exclusion_mask = create_conditional_draw_exclusion_mask(df) - trigger_mask = create_conditional_draw_trigger_mask(df) - - # Create draw effect mask with extra patterns - draw_mask = tag_utils.create_numbered_phrase_mask(df, 'draw', 'card') - draw_mask = draw_mask | tag_utils.create_text_mask(df, ['created a token.*draw', 'draw a card for each']) - - # Combine: trigger & draw & ~exclusion - final_mask = trigger_mask & draw_mask & ~exclusion_mask - tag_utils.tag_with_logging(df, final_mask, ['Conditional Draw', 'Card Draw'], 'conditional draw effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging conditional draw effects: {str(e)}') - raise - -## Loot effects, I.E. draw a card, discard a card. Or discard a card, draw a card -def create_loot_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with standard loot effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have loot effects - """ - # Exclude cards that already have other loot-like effects - has_other_loot = tag_utils.create_tag_mask(df, ['Cycling', 'Connive']) | df['text'].str.contains('blood token', case=False, na=False) - - # Match draw + discard patterns - discard_patterns = [ - 'discard the rest', - 'for each card drawn this way, discard', - 'if you do, discard', - 'then discard' - ] - - has_draw = tag_utils.create_numbered_phrase_mask(df, 'draw', 'card') - has_discard = tag_utils.create_text_mask(df, discard_patterns) - - return ~has_other_loot & has_draw & has_discard - -def create_connive_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with connive effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have connive effects - """ - has_keyword = tag_utils.create_keyword_mask(df, 'Connive') - has_text = tag_utils.create_text_mask(df, 'connives?') - return has_keyword | has_text - -def create_cycling_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with cycling effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have cycling effects - """ - has_keyword = tag_utils.create_keyword_mask(df, 'Cycling') - has_text = tag_utils.create_text_mask(df, 'cycling') - return has_keyword | has_text - -def create_blood_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with blood token effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have blood token effects - """ - return tag_utils.create_text_mask(df, 'blood token') - -def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None: - """Tag cards with loot-like effects using vectorized operations. - - This function handles tagging of all loot-like effects including: - - Standard loot (draw + discard) - - Connive - - Cycling - - Blood tokens - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - loot_mask = create_loot_mask(df) - connive_mask = create_connive_mask(df) - cycling_mask = create_cycling_mask(df) - blood_mask = create_blood_mask(df) - rules = [ - {'mask': loot_mask, 'tags': ['Loot', 'Card Draw', 'Discard Matters']}, - {'mask': connive_mask, 'tags': ['Connive', 'Loot', 'Card Draw', 'Discard Matters']}, - {'mask': cycling_mask, 'tags': ['Cycling', 'Loot', 'Card Draw', 'Discard Matters']}, - {'mask': blood_mask, 'tags': ['Blood Token', 'Loot', 'Card Draw', 'Discard Matters']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'loot-like effects', color=color, logger=logger) - -## Sacrifice or pay life to draw effects -def tag_for_cost_draw(df: pd.DataFrame, color: str) -> None: - """Tag cards that draw cards by paying life or sacrificing permanents. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - life_mask = df['text'].str.contains('life: draw', case=False, na=False) - - # Use compiled patterns from regex_patterns module - sac_mask = ( - df['text'].str.contains(rgx.SACRIFICE_DRAW.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.SACRIFICE_COLON_DRAW.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.SACRIFICED_COMMA_DRAW.pattern, case=False, na=False, regex=True) - ) - rules = [ - {'mask': life_mask, 'tags': ['Life to Draw', 'Card Draw']}, - {'mask': sac_mask, 'tags': ['Sacrifice to Draw', 'Card Draw']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'cost-based draw effects', color=color, logger=logger) - -## Replacement effects, that might have you draw more cards -def create_replacement_draw_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with replacement draw effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have replacement draw effects - """ - # Create trigger patterns - trigger_patterns = [] - for trigger in tag_constants.TRIGGERS: - trigger_patterns.extend([ - f'{trigger} a player.*instead.*draw', - f'{trigger} an opponent.*instead.*draw', - f'{trigger} the beginning of your draw step.*instead.*draw', - f'{trigger} you.*instead.*draw' - ]) - - # Create other replacement patterns - replacement_patterns = [ - 'if a player would.*instead.*draw', - 'if an opponent would.*instead.*draw', - 'if you would.*instead.*draw' - ] - all_patterns = '|'.join(trigger_patterns + replacement_patterns) - base_mask = tag_utils.create_text_mask(df, all_patterns) - - # Add mask for specific card numbers - number_mask = tag_utils.create_numbered_phrase_mask(df, 'draw', 'card') - - # Add mask for non-specific numbers - nonspecific_mask = tag_utils.create_text_mask(df, 'draw that many plus|draws that many plus') # df['text'].str.contains('draw that many plus|draws that many plus', case=False, na=False) - - return base_mask & (number_mask | nonspecific_mask) - -def create_replacement_draw_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from replacement draw effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - excluded_tags = tag_constants.DRAW_RELATED_TAGS - tag_mask = tag_utils.create_tag_mask(df, excluded_tags) - text_patterns = tag_constants.DRAW_EXCLUSION_PATTERNS + ['skips that turn instead'] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - return tag_mask | text_mask - -def tag_for_replacement_draw(df: pd.DataFrame, color: str) -> None: - """Tag cards that have replacement draw effects using vectorized operations. - - This function identifies and tags cards that modify or replace card draw effects, - such as drawing additional cards or replacing normal draw effects with other effects. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Example patterns tagged: - - Trigger-based replacement effects ("whenever you draw...instead") - - Conditional replacement effects ("if you would draw...instead") - - Specific card number replacements - - Non-specific card number replacements ("draw that many plus") - """ - try: - # Build masks - replacement_mask = create_replacement_draw_mask(df) - exclusion_mask = create_replacement_draw_exclusion_mask(df) - specific_cards_mask = tag_utils.create_name_mask(df, 'sylvan library') - - # Combine: (replacement & ~exclusion) OR specific cards - final_mask = (replacement_mask & ~exclusion_mask) | specific_cards_mask - tag_utils.tag_with_logging(df, final_mask, ['Replacement Draw', 'Card Draw'], 'replacement draw effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging replacement draw effects: {str(e)}') - raise - -## Wheels -def tag_for_wheels(df: pd.DataFrame, color: str) -> None: - """Tag cards that have wheel effects or care about drawing/discarding cards. - - This function identifies and tags cards that: - - Force excess draw and discard - - Have payoffs for drawing/discarding - - Care about wheel effects - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Build text and name masks - wheel_patterns = [ - 'an opponent draws a card', 'cards you\'ve drawn', 'draw your second card', 'draw that many cards', - 'draws an additional card', 'draws a card', 'draws cards', 'draws half that many cards', - 'draws their first second card', 'draws their second second card', 'draw two cards instead', - 'draws two additional cards', 'discards that card', 'discards their hand, then draws', - 'each card your opponents have drawn', 'each draw a card', 'each opponent draws a card', - 'each player draws', 'has no cards in hand', 'have no cards in hand', 'may draw a card', - 'maximum hand size', 'no cards in it, you win the game instead', 'opponent discards', - 'you draw a card', 'whenever you draw a card' - ] - wheel_cards = [ - 'arcane denial', 'bloodchief ascension', 'dark deal', 'elenda and azor', 'elixir of immortality', - 'forced fruition', 'glunch, the bestower', 'kiora the rising tide', 'kynaios and tiro of meletis', - 'library of leng', 'loran of the third path', 'mr. foxglove', 'raffine, scheming seer', - 'sauron, the dark lord', 'seizan, perverter of truth', 'triskaidekaphile', 'twenty-toed toad', - 'waste not', 'wedding ring', 'whispering madness' - ] - - text_mask = tag_utils.create_text_mask(df, wheel_patterns) - name_mask = tag_utils.create_name_mask(df, wheel_cards) - final_mask = text_mask | name_mask - - # Build trigger submask for Draw Triggers tag - trigger_pattern = '|'.join(tag_constants.TRIGGERS) - trigger_mask = final_mask & df['text'].str.contains(trigger_pattern, case=False, na=False) - rules = [ - {'mask': final_mask, 'tags': ['Card Draw', 'Wheels']}, - {'mask': trigger_mask, 'tags': ['Draw Triggers']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'wheel effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging "Wheel" effects: {str(e)}') - raise - -### Artifacts -def tag_for_artifacts(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about Artifacts or are specific kinds of Artifacts - (i.e. Equipment or Vehicles). - - This function identifies and tags cards with Artifact-related effects including: - - Creating Artifact tokens - - Casting Artifact spells - - Equipment - - Vehicles - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Card Draw', 'Spellslinger', etc. - - Args: - df: DataFrame containing card data to process - color: Color identifier for logging purposes (e.g. 'white', 'blue') - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting "Artifact" and "Artifacts Matter" tagging for {color}_cards.csv') - print('\n==========\n') - - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of artifact effect - tag_for_artifact_tokens(df, color) - logger.info('Completed Artifact token tagging') - print('\n==========\n') - - tag_for_artifact_triggers(df, color) - logger.info('Completed Artifact trigger tagging') - print('\n==========\n') - - tag_equipment(df, color) - logger.info('Completed Equipment tagging') - print('\n==========\n') - - tag_vehicles(df, color) - logger.info('Completed Vehicle tagging') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all "Artifact" and "Artifacts Matter" tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_enchantments: {str(e)}') - raise - -## Artifact Tokens -def tag_for_artifact_tokens(df: pd.DataFrame, color: str) -> None: - """Tag cards that create or care about artifact tokens using vectorized operations. - - This function handles tagging of: - - Generic artifact token creation - - Predefined artifact token types (Treasure, Food, etc) - - Fabricate keyword - - The function applies both generic artifact token tags and specific token type tags - (e.g., 'Treasure Token', 'Food Token') based on the tokens created. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - generic_mask = create_generic_artifact_mask(df) - predefined_mask, token_map = create_predefined_artifact_mask(df) - fabricate_mask = create_fabricate_mask(df) - - # Apply base artifact token tags via rules engine - rules = [ - {'mask': generic_mask, 'tags': ['Artifact Tokens', 'Artifacts Matter', 'Token Creation', 'Tokens Matter']}, - {'mask': predefined_mask, 'tags': ['Artifact Tokens', 'Artifacts Matter', 'Token Creation', 'Tokens Matter']}, - {'mask': fabricate_mask, 'tags': ['Artifact Tokens', 'Artifacts Matter', 'Token Creation', 'Tokens Matter']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'artifact tokens', color=color, logger=logger) - - # Apply specific token type tags (special handling for predefined tokens) - if predefined_mask.any(): - token_to_indices: dict[str, list[int]] = {} - for idx, token_type in token_map.items(): - token_to_indices.setdefault(token_type, []).append(idx) - - for token_type, indices in token_to_indices.items(): - mask = pd.Series(False, index=df.index) - mask.loc[indices] = True - tag_utils.apply_tag_vectorized(df, mask, [f'{token_type} Token']) - - # Log token type breakdown - logger.info('Predefined artifact token breakdown:') - for token_type, indices in token_to_indices.items(): - logger.info(' - %s: %d cards', token_type, len(indices)) - - except Exception as e: - logger.error('Error in tag_for_artifact_tokens: %s', str(e)) - raise - -# Generic Artifact tokens, such as karnstructs, or artifact soldiers -def create_generic_artifact_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that create non-predefined artifact tokens. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards create generic artifact tokens - """ - # Exclude specific cards - excluded_cards = [ - 'diabolical salvation', - 'lifecraft awakening', - 'sandsteppe war riders', - 'transmutation font' - ] - name_exclusions = tag_utils.create_name_mask(df, excluded_cards) - - # Create text pattern matches - has_create = tag_utils.create_text_mask(df, tag_constants.CREATE_ACTION_PATTERN) - - token_patterns = [ - 'artifact creature token', - 'artifact token', - 'construct artifact', - 'copy of enchanted artifact', - 'copy of target artifact', - 'copy of that artifact' - ] - has_token = tag_utils.create_text_mask(df, token_patterns) - - # Named cards that create artifact tokens - named_cards = [ - 'bloodforged battle-axe', 'court of vantress', 'elmar, ulvenwald informant', - 'faerie artisans', 'feldon of the third path', 'lenoardo da vinci', - 'march of progress', 'nexus of becoming', 'osgir, the reconstructor', - 'prototype portal', 'red sun\'s twilight', 'saheeli, the sun\'s brilliance', - 'season of weaving', 'shaun, father of synths', 'sophia, dogged detective', - 'vaultborn tyrant', 'wedding ring' - ] - named_matches = tag_utils.create_name_mask(df, named_cards) - - # Exclude fabricate cards - has_fabricate = tag_utils.create_text_mask(df, 'fabricate') - - return (has_create & has_token & ~name_exclusions & ~has_fabricate) | named_matches - -def create_predefined_artifact_mask(df: pd.DataFrame) -> tuple[pd.Series, dict[int, str]]: - """Create a boolean mask for cards that create predefined artifact tokens and track token types. - - Args: - df: DataFrame to search - - Returns: - Tuple containing: - - Boolean Series indicating which cards create predefined artifact tokens - - Dictionary mapping row indices to their matched token types - """ - has_create = tag_utils.create_text_mask(df, tag_constants.CREATE_ACTION_PATTERN) - - # Initialize token mapping dictionary - token_map = {} - token_masks = [] - - for token in tag_constants.ARTIFACT_TOKENS: - token_mask = tag_utils.create_text_mask(df, token.lower()) - - # Handle exclusions - if token == 'Blood': - token_mask &= df['name'] != 'Bloodroot Apothecary' - elif token == 'Gold': - token_mask &= ~df['name'].isin(['Goldspan Dragon', 'The Golden-Gear Colossus']) - elif token == 'Junk': - token_mask &= df['name'] != 'Junkyard Genius' - - # Store token type for matching rows - matching_indices = df[token_mask].index - for idx in matching_indices: - if idx not in token_map: # Only store first match - token_map[idx] = token - - token_masks.append(token_mask) - final_mask = has_create & pd.concat(token_masks, axis=1).any(axis=1) - - return final_mask, token_map -def create_fabricate_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with fabricate keyword. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have fabricate - """ - return tag_utils.create_text_mask(df, 'fabricate') - -## Artifact Triggers -def create_artifact_triggers_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that care about artifacts. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards care about artifacts - """ - # Define artifact-related patterns - ability_patterns = [ - 'abilities of artifact', 'ability of artifact' - ] - - artifact_state_patterns = [ - 'are artifacts in addition', 'artifact enters', 'number of artifacts', - 'number of other artifacts', 'number of tapped artifacts', - 'number of artifact' - ] - - artifact_type_patterns = [ - 'all artifact', 'another artifact', 'another target artifact', - 'artifact card', 'artifact creature you control', - 'artifact creatures you control', 'artifact you control', - 'artifacts you control', 'each artifact', 'target artifact' - ] - - casting_patterns = [ - 'affinity for artifacts', 'artifact spells as though they had flash', - 'artifact spells you cast', 'cast an artifact', 'choose an artifact', - 'whenever you cast a noncreature', 'whenever you cast an artifact' - ] - - counting_patterns = [ - 'mana cost among artifact', 'mana value among artifact', - 'artifact with the highest mana value', - ] - - search_patterns = [ - 'search your library for an artifact' - ] - - trigger_patterns = [ - 'whenever a nontoken artifact', 'whenever an artifact', - 'whenever another nontoken artifact', 'whenever one or more artifact' - ] - all_patterns = ( - ability_patterns + artifact_state_patterns + artifact_type_patterns + - casting_patterns + counting_patterns + search_patterns + trigger_patterns + - ['metalcraft', 'prowess', 'copy of any artifact'] - ) - pattern = '|'.join(all_patterns) - - # Create mask - return df['text'].str.contains(pattern, case=False, na=False, regex=True) - -def tag_for_artifact_triggers(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about artifacts using vectorized operations. - - This function identifies and tags cards that: - - Have abilities that trigger off artifacts - - Care about artifact states or counts - - Interact with artifact spells or permanents - - Have metalcraft or similar mechanics - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Create artifact triggers mask - triggers_mask = create_artifact_triggers_mask(df) - tag_utils.tag_with_logging( - df, triggers_mask, ['Artifacts Matter'], - 'cards that care about artifacts', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error tagging artifact triggers: {str(e)}') - raise - -## Equipment -def create_equipment_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that are Equipment - - This function identifies cards that: - - Have the Equipment subtype - - Args: - df: DataFrame containing card data - - Returns: - Boolean Series indicating which cards are Equipment - """ - # Create type-based mask - type_mask = tag_utils.create_type_mask(df, 'Equipment') - - return type_mask - -def create_equipment_cares_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that care about Equipment. - - This function identifies cards that: - - Have abilities that trigger off Equipment - - Care about equipped creatures - - Modify Equipment or equipped creatures - - Have Equipment-related keywords - - Args: - df: DataFrame containing card data - - Returns: - Boolean Series indicating which cards care about Equipment - """ - # Create text pattern mask - text_patterns = [ - 'equipment you control', - 'equipped creature', - 'attach', - 'equip', - 'equipment spells', - 'equipment abilities', - 'modified', - 'reconfigure' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - # Create keyword mask - keyword_patterns = ['Modified', 'Equip', 'Reconfigure'] - keyword_mask = tag_utils.create_keyword_mask(df, keyword_patterns) - - # Create specific cards mask - specific_cards = tag_constants.EQUIPMENT_SPECIFIC_CARDS - name_mask = tag_utils.create_name_mask(df, specific_cards) - - return text_mask | keyword_mask | name_mask - -def tag_equipment(df: pd.DataFrame, color: str) -> None: - """Tag cards that are Equipment or care about Equipment using vectorized operations. - - This function identifies and tags: - - Equipment cards - - Cards that care about Equipment - - Cards with Equipment-related abilities - - Cards that modify Equipment or equipped creatures - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - # Apply tagging rules with enhanced utilities - rules = [ - { 'mask': create_equipment_mask(df), 'tags': ['Equipment', 'Equipment Matters', 'Voltron'] }, - { 'mask': create_equipment_cares_mask(df), 'tags': ['Artifacts Matter', 'Equipment Matters', 'Voltron'] } - ] - - tag_utils.tag_with_rules_and_logging( - df, rules, 'Equipment cards and cards that care about Equipment', color=color, logger=logger - ) - - except Exception as e: - logger.error('Error tagging Equipment cards: %s', str(e)) - raise - -## Vehicles -def create_vehicle_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that are Vehicles or care about Vehicles. - - This function identifies cards that: - - Have the Vehicle subtype - - Have crew abilities - - Care about Vehicles or Pilots - - Args: - df: DataFrame containing card data - - Returns: - Boolean Series indicating which cards are Vehicles or care about them - """ - return tag_utils.build_combined_mask( - df, - type_patterns=['Vehicle', 'Pilot'], - text_patterns=['vehicle', 'crew', 'pilot'] - ) - -def tag_vehicles(df: pd.DataFrame, color: str) -> None: - """Tag cards that are Vehicles or care about Vehicles using vectorized operations. - - This function identifies and tags: - - Vehicle cards - - Pilot cards - - Cards that care about Vehicles - - Cards with crew abilities - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - # Use enhanced tagging utility - tag_utils.tag_with_logging( - df, - create_vehicle_mask(df), - ['Artifacts Matter', 'Vehicles'], - 'Vehicle-related cards', - color=color, - logger=logger - ) - - except Exception as e: - logger.error('Error tagging Vehicle cards: %s', str(e)) - raise - -### Enchantments -def tag_for_enchantments(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about Enchantments or are specific kinds of Enchantments - (i.e. Equipment or Vehicles). - - This function identifies and tags cards with Enchantment-related effects including: - - Creating Enchantment tokens - - Casting Enchantment spells - - Auras - - Constellation - - Cases - - Rooms - - Classes - - Backrounds - - Shrines - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Card Draw', 'Spellslinger', etc. - - Args: - df: DataFrame containing card data to process - color: Color identifier for logging purposes (e.g. 'white', 'blue') - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting "Enchantment" and "Enchantments Matter" tagging for {color}_cards.csv') - print('\n==========\n') - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of enchantment effect - tag_for_enchantment_tokens(df, color) - logger.info('Completed Enchantment token tagging') - print('\n==========\n') - - tag_for_enchantments_matter(df, color) - logger.info('Completed "Enchantments Matter" tagging') - print('\n==========\n') - - tag_auras(df, color) - logger.info('Completed Aura tagging') - print('\n==========\n') - - tag_constellation(df, color) - logger.info('Completed Constellation tagging') - print('\n==========\n') - - tag_sagas(df, color) - logger.info('Completed Saga tagging') - print('\n==========\n') - - tag_cases(df, color) - logger.info('Completed Case tagging') - print('\n==========\n') - - tag_rooms(df, color) - logger.info('Completed Room tagging') - print('\n==========\n') - - tag_backgrounds(df, color) - logger.info('Completed Background tagging') - print('\n==========\n') - - tag_shrines(df, color) - logger.info('Completed Shrine tagging') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all "Enchantment" and "Enchantments Matter" tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_artifacts: {str(e)}') - raise - -## Enchantment tokens -def tag_for_enchantment_tokens(df: pd.DataFrame, color: str) -> None: - """Tag cards that create or care about enchantment tokens using vectorized operations. - - This function handles tagging of: - - Generic enchantmeny token creation - - Predefined enchantment token types (Roles, Shards, etc) - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - generic_mask = create_generic_enchantment_mask(df) - predefined_mask = create_predefined_enchantment_mask(df) - rules = [ - {'mask': generic_mask, 'tags': ['Enchantment Tokens', 'Enchantments Matter', 'Token Creation', 'Tokens Matter']}, - {'mask': predefined_mask, 'tags': ['Enchantment Tokens', 'Enchantments Matter', 'Token Creation', 'Tokens Matter']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'enchantment tokens', color=color, logger=logger) - - except Exception as e: - logger.error('Error in tag_for_enchantment_tokens: %s', str(e)) - raise - -def create_generic_enchantment_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that create predefined enchantment tokens. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards create predefined enchantment tokens - """ - # Create text pattern matches - has_create = tag_utils.create_text_mask(df, tag_constants.CREATE_ACTION_PATTERN) - - token_patterns = [ - 'copy of enchanted enchantment', - 'copy of target enchantment', - 'copy of that enchantment', - 'enchantment creature token', - 'enchantment token' - ] - has_token = tag_utils.create_text_mask(df, token_patterns) - - # Named cards that create enchantment tokens - named_cards = [ - 'court of vantress', - 'fellhide spiritbinder', - 'hammer of purphoros' - ] - named_matches = tag_utils.create_name_mask(df, named_cards) - - return (has_create & has_token) | named_matches - -def create_predefined_enchantment_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that create non-predefined enchantment tokens. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards create generic enchantmnet tokens - """ - # Create text pattern matches - has_create = tag_utils.create_text_mask(df, tag_constants.CREATE_ACTION_PATTERN) - token_masks = [] - for token in tag_constants.ENCHANTMENT_TOKENS: - token_mask = tag_utils.create_text_mask(df, token.lower()) - - token_masks.append(token_mask) - - return has_create & pd.concat(token_masks, axis=1).any(axis=1) - -## General enchantments matter -def tag_for_enchantments_matter(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about enchantments using vectorized operations. - - This function identifies and tags cards that: - - Have abilities that trigger off enchantments - - Care about enchantment states or counts - - Interact with enchantment spells or permanents - - Have constellation or similar mechanics - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Define enchantment-related patterns - ability_patterns = [ - 'abilities of enchantment', 'ability of enchantment' - ] - - state_patterns = [ - 'are enchantments in addition', 'enchantment enters' - ] - - type_patterns = [ - 'all enchantment', 'another enchantment', 'enchantment card', - 'enchantment creature you control', 'enchantment creatures you control', - 'enchantment you control', 'enchantments you control' - ] - - casting_patterns = [ - 'cast an enchantment', 'enchantment spells as though they had flash', - 'enchantment spells you cast' - ] - - counting_patterns = [ - 'mana value among enchantment', 'number of enchantment' - ] - - search_patterns = [ - 'search your library for an enchantment' - ] - - trigger_patterns = [ - 'whenever a nontoken enchantment', 'whenever an enchantment', - 'whenever another nontoken enchantment', 'whenever one or more enchantment' - ] - all_patterns = ( - ability_patterns + state_patterns + type_patterns + - casting_patterns + counting_patterns + search_patterns + trigger_patterns - ) - triggers_mask = tag_utils.create_text_mask(df, all_patterns) - - # Exclusions - exclusion_mask = tag_utils.create_name_mask(df, 'luxa river shrine') - - # Final mask - final_mask = triggers_mask & ~exclusion_mask - - # Apply tag - tag_utils.tag_with_logging( - df, final_mask, ['Enchantments Matter'], - 'cards that care about enchantments', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error tagging enchantment triggers: {str(e)}') - raise - - logger.info(f'Completed tagging cards that care about enchantments in {color}_cards.csv') - -## Aura -def tag_auras(df: pd.DataFrame, color: str) -> None: - """Tag cards that are Auras or care about Auras using vectorized operations. - - This function identifies cards that: - - Have abilities that trigger off Auras - - Care about enchanted permanents - - Modify Auras or enchanted permanents - - Have Aura-related keywords - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - aura_mask = tag_utils.create_type_mask(df, 'Aura') - cares_mask = tag_utils.build_combined_mask( - df, - text_patterns=['aura', 'aura enters', 'aura you control enters', 'enchanted'], - name_list=tag_constants.AURA_SPECIFIC_CARDS - ) - - rules = [ - {'mask': aura_mask, 'tags': ['Auras', 'Enchantments Matter', 'Voltron']}, - {'mask': cares_mask, 'tags': ['Auras', 'Enchantments Matter', 'Voltron']} - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Aura cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Aura cards: %s', str(e)) - raise - -## Constellation -def tag_constellation(df: pd.DataFrame, color: str) -> None: - """Tag cards with Constellation using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - constellation_mask = tag_utils.create_keyword_mask(df, 'Constellation') - tag_utils.tag_with_logging( - df, constellation_mask, ['Constellation', 'Enchantments Matter'], 'Constellation cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Constellation cards: {str(e)}') - raise - -## Sagas -def tag_sagas(df: pd.DataFrame, color: str) -> None: - """Tag cards with the Saga type using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFramecolumns are missing - """ - try: - saga_mask = tag_utils.create_type_mask(df, 'Saga') - cares_mask = tag_utils.create_text_mask(df, ['saga', 'put a saga', 'final chapter', 'lore counter']) - - rules = [ - {'mask': saga_mask, 'tags': ['Enchantments Matter', 'Sagas Matter']}, - {'mask': cares_mask, 'tags': ['Enchantments Matter', 'Sagas Matter']} - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Saga cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Saga cards: {str(e)}') - raise - -## Cases -def tag_cases(df: pd.DataFrame, color: str) -> None: - """Tag cards with the Case subtype using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFramecolumns are missing - """ - try: - case_mask = tag_utils.create_type_mask(df, 'Case') - cares_mask = tag_utils.create_text_mask(df, 'solve a case') - - rules = [ - {'mask': case_mask, 'tags': ['Enchantments Matter', 'Cases Matter']}, - {'mask': cares_mask, 'tags': ['Enchantments Matter', 'Cases Matter']} - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Case cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Case cards: {str(e)}') - raise - -## Rooms -def tag_rooms(df: pd.DataFrame, color: str) -> None: - """Tag cards with the room subtype using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFramecolumns are missing - """ - try: - room_mask = tag_utils.create_type_mask(df, 'Room') - keyword_mask = tag_utils.create_keyword_mask(df, 'Eerie') - cares_mask = tag_utils.create_text_mask(df, 'target room') - - rules = [ - {'mask': room_mask, 'tags': ['Enchantments Matter', 'Rooms Matter']}, - {'mask': keyword_mask, 'tags': ['Enchantments Matter', 'Rooms Matter']}, - {'mask': cares_mask, 'tags': ['Enchantments Matter', 'Rooms Matter']} - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Room cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Room cards: {str(e)}') - raise - -## Classes -def tag_classes(df: pd.DataFrame, color: str) -> None: - """Tag cards with the Class subtype using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFramecolumns are missing - """ - try: - class_mask = tag_utils.create_type_mask(df, 'Class') - tag_utils.tag_with_logging( - df, class_mask, ['Enchantments Matter', 'Classes Matter'], 'Class cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Class cards: {str(e)}') - raise - -## Background -def tag_backgrounds(df: pd.DataFrame, color: str) -> None: - """Tag cards with the Background subtype or which let you choose a background using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFramecolumns are missing - """ - try: - class_mask = tag_utils.create_type_mask(df, 'Background') - cares_mask = tag_utils.create_text_mask(df, 'Background') - - rules = [ - {'mask': class_mask, 'tags': ['Enchantments Matter', 'Backgrounds Matter']}, - {'mask': cares_mask, 'tags': ['Enchantments Matter', 'Backgrounds Matter']} - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Background cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Background cards: {str(e)}') - raise - -## Shrines -def tag_shrines(df: pd.DataFrame, color: str) -> None: - """Tag cards with the Shrine subtype using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFramecolumns are missing - """ - try: - class_mask = tag_utils.create_type_mask(df, 'Shrine') - tag_utils.tag_with_logging( - df, class_mask, ['Enchantments Matter', 'Shrines Matter'], 'Shrine cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Shrine cards: {str(e)}') - raise - -### Exile Matters -## Exile Matter effects, such as Impulse draw, foretell, etc... -def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about exiling cards and casting them from exile. - - This function identifies and tags cards with cast-from exile effects such as: - - Cascade - - Discover - - Foretell - - Imprint - - Impulse - - Plot - - Suspend - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Card Draw', 'Spellslinger', etc. - - Args: - df: DataFrame containing card data to process - color: Color identifier for logging purposes (e.g. 'white', 'blue') - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting "Exile Matters" tagging for {color}_cards.csv') - print('\n==========\n') - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of Exile matters effect - tag_for_general_exile_matters(df, color) - logger.info('Completed general Exile Matters tagging') - print('\n==========\n') - - tag_for_cascade(df, color) - logger.info('Completed Cascade tagging') - print('\n==========\n') - - tag_for_discover(df, color) - logger.info('Completed Discover tagging') - print('\n==========\n') - - tag_for_foretell(df, color) - logger.info('Completed Foretell tagging') - print('\n==========\n') - - tag_for_imprint(df, color) - logger.info('Completed Imprint tagging') - print('\n==========\n') - - tag_for_impulse(df, color) - logger.info('Completed Impulse tagging') - print('\n==========\n') - - tag_for_plot(df, color) - logger.info('Completed Plot tagging') - print('\n==========\n') - - tag_for_suspend(df, color) - logger.info('Completed Suspend tagging') - print('\n==========\n') - - tag_for_warp(df, color) - logger.info('Completed Warp tagging') - print('\n==========\n') - - # New: Time counters and Time Travel support - tag_for_time_counters(df, color) - logger.info('Completed Time Counters tagging') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_exile_matters: {str(e)}') - raise - -def tag_for_general_exile_matters(df: pd.DataFrame, color: str) -> None: - """Tag cards that have a general care about casting from Exile theme. - - This function identifies cards that: - - Trigger off casting a card from exile - - Trigger off playing a land from exile - - Putting cards into exile to later play - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: if required DataFrame columns are missing - """ - try: - # Create exile mask - text_patterns = [ - 'cards in exile', - 'cast a spell from exile', - 'cast but don\'t own', - 'cast from exile', - 'casts a spell from exile', - 'control but don\'t own', - 'exiled with', - 'from anywhere but their hand', - 'from anywhere but your hand', - 'from exile', - 'own in exile', - 'play a card from exile', - 'plays a card from exile', - 'play a land from exile', - 'plays a land from exile', - 'put into exile', - 'remains exiled' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - tag_utils.tag_with_logging( - df, text_mask, ['Exile Matters'], 'General Exile Matters cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Exile Matters cards: %s', str(e)) - raise - -## Cascade cards -def tag_for_cascade(df: pd.DataFrame, color: str) -> None: - """Tag cards that have or otherwise give the Cascade ability - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - text_patterns = ['gain cascade', 'has cascade', 'have cascade', 'have "cascade', 'with cascade'] - text_mask = tag_utils.create_text_mask(df, text_patterns) - keyword_mask = tag_utils.create_keyword_mask(df, 'Cascade') - - rules = [ - {'mask': text_mask, 'tags': ['Cascade', 'Exile Matters']}, - {'mask': keyword_mask, 'tags': ['Cascade', 'Exile Matters']} - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Cascade cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Cascade cards: %s', str(e)) - raise - -## Discover cards -def tag_for_discover(df: pd.DataFrame, color: str) -> None: - """Tag cards with Discover using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - keyword_mask = tag_utils.create_keyword_mask(df, 'Discover') - tag_utils.tag_with_logging( - df, keyword_mask, ['Discover', 'Exile Matters'], 'Discover cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Discover cards: {str(e)}') - raise - -## Foretell cards, and cards that care about foretell -def tag_for_foretell(df: pd.DataFrame, color: str) -> None: - """Tag cards with Foretell using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - final_mask = tag_utils.build_combined_mask( - df, keyword_patterns='Foretell', text_patterns='Foretell' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Foretell', 'Exile Matters'], 'Foretell cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Foretell cards: {str(e)}') - raise - -## Cards that have or care about imprint -def tag_for_imprint(df: pd.DataFrame, color: str) -> None: - """Tag cards with Imprint using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - final_mask = tag_utils.build_combined_mask( - df, keyword_patterns='Imprint', text_patterns='Imprint' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Imprint', 'Exile Matters'], 'Imprint cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Imprint cards: {str(e)}') - raise - -## Cards that have or care about impulse -def create_impulse_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with impulse-like effects. - - This function identifies cards that exile cards from the top of libraries - and allow playing them for a limited time, including: - - Exile top card(s) with may cast/play effects - - Named cards with similar effects - - Junk token creation - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have Impulse effects - """ - # Define text patterns - exile_patterns = [ - 'exile the top', - 'exiles the top' - ] - - play_patterns = [ - 'may cast', - 'may play' - ] - - # Named cards with Impulse effects - impulse_cards = [ - 'daxos of meletis', 'bloodsoaked insight', 'florian, voldaren scion', - 'possibility storm', 'ragava, nimble pilferer', 'rakdos, the muscle', - 'stolen strategy', 'urabrask, heretic praetor', 'valakut exploration', - 'wild wasteland' - ] - - # Create exclusion patterns - exclusion_patterns = [ - 'damage to each', 'damage to target', 'deals combat damage', - 'raid', 'target opponent\'s hand', - ] - secondary_exclusion_patterns = [ - 'each opponent', 'morph', 'opponent\'s library', - 'skip your draw', 'target opponent', 'that player\'s', - 'you may look at the top card' - ] - - # Create masks - tag_mask = tag_utils.create_tag_mask(df, 'Imprint') - exile_mask = tag_utils.create_text_mask(df, exile_patterns) - play_mask = tag_utils.create_text_mask(df, play_patterns) - named_mask = tag_utils.create_name_mask(df, impulse_cards) - junk_mask = tag_utils.create_text_mask(df, 'junk token') - first_exclusion_mask = tag_utils.create_text_mask(df, exclusion_patterns) - planeswalker_mask = df['type'].str.contains('Planeswalker', case=False, na=False) - second_exclusion_mask = tag_utils.create_text_mask(df, secondary_exclusion_patterns) - exclusion_mask = (~first_exclusion_mask & ~planeswalker_mask) & second_exclusion_mask - impulse_mask = ((exile_mask & play_mask & ~exclusion_mask & ~tag_mask) | - named_mask | junk_mask) - - return impulse_mask - -def tag_for_impulse(df: pd.DataFrame, color: str) -> None: - """Tag cards that have impulse-like effects using vectorized operations. - - This function identifies and tags cards that exile cards from library tops - and allow playing them for a limited time, including: - - Exile top card(s) with may cast/play effects - - Named cards with similar effects - - Junk token creation - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Build masks - impulse_mask = create_impulse_mask(df) - junk_mask = tag_utils.create_text_mask(df, 'junk token') - rules = [ - {'mask': impulse_mask, 'tags': ['Exile Matters', 'Impulse']}, - {'mask': (impulse_mask & junk_mask), 'tags': ['Junk Tokens']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'impulse effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging Impulse effects: {str(e)}') - raise - -## Cards that have or care about plotting -def tag_for_plot(df: pd.DataFrame, color: str) -> None: - """Tag cards with Plot using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - final_mask = tag_utils.build_combined_mask( - df, keyword_patterns='Plot', text_patterns='Plot' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Plot', 'Exile Matters'], 'Plot cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Plot cards: {str(e)}') - raise - -## Cards that have or care about suspend -def tag_for_suspend(df: pd.DataFrame, color: str) -> None: - """Tag cards with Suspend using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - final_mask = tag_utils.build_combined_mask( - df, keyword_patterns='Suspend', text_patterns='Suspend' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Suspend', 'Exile Matters'], 'Suspend cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Suspend cards: {str(e)}') - raise - -## Cards that have or care about Warp -def tag_for_warp(df: pd.DataFrame, color: str) -> None: - """Tag cards with Warp using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - final_mask = tag_utils.build_combined_mask( - df, keyword_patterns='Warp', text_patterns='Warp' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Warp', 'Exile Matters'], 'Warp cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Warp cards: {str(e)}') - raise - -def create_time_counters_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that mention time counters or Time Travel. - - This captures interactions commonly associated with Suspend without - requiring the Suspend keyword (e.g., Time Travel effects, adding/removing - time counters, or Vanishing). - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards interact with time counters - """ - # Text patterns around time counters and time travel - text_patterns = [ - 'time counter', - 'time counters', - 'remove a time counter', - 'add a time counter', - 'time travel' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - # Keyword-based patterns that imply time counters - keyword_mask = tag_utils.create_keyword_mask(df, ['Vanishing']) - - return text_mask | keyword_mask - -def tag_for_time_counters(df: pd.DataFrame, color: str) -> None: - """Tag cards that interact with time counters or Time Travel. - - Applies a base 'Time Counters' tag. Adds 'Exile Matters' when the card also - mentions exile or Suspend, since those imply interaction with suspended - cards in exile. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - time_mask = create_time_counters_mask(df) - - # Conditionally add Exile Matters if the card references exile or suspend - exile_mask = tag_utils.create_text_mask(df, tag_constants.PATTERN_GROUPS['exile']) - suspend_mask = tag_utils.create_keyword_mask(df, 'Suspend') | tag_utils.create_text_mask(df, 'Suspend') - time_exile_mask = time_mask & (exile_mask | suspend_mask) - - rules = [ - { 'mask': time_mask, 'tags': ['Time Counters'] }, - { 'mask': time_exile_mask, 'tags': ['Exile Matters'] } - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Time Counters cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Time Counters interactions: {str(e)}') - raise - -### Tokens -def create_creature_token_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that create creature tokens. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards create creature tokens - """ - has_create = tag_utils.create_text_mask(df, tag_constants.CREATE_ACTION_PATTERN) - token_patterns = [ - 'artifact creature token', - 'creature token', - 'enchantment creature token' - ] - has_token = tag_utils.create_text_mask(df, token_patterns) - - # Create exclusion mask - exclusion_patterns = ['fabricate', 'modular'] - exclusion_mask = tag_utils.create_text_mask(df, exclusion_patterns) - - # Create name exclusion mask - excluded_cards = ['agatha\'s soul cauldron'] - name_exclusions = tag_utils.create_name_mask(df, excluded_cards) - - return has_create & has_token & ~exclusion_mask & ~name_exclusions - -def create_token_modifier_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that modify token creation. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards modify token creation - """ - modifier_patterns = [ - 'create one or more', - 'one or more creature', - 'one or more tokens would be created', - 'one or more tokens would be put', - 'one or more tokens would enter', - 'one or more tokens you control', - 'put one or more' - ] - has_modifier = tag_utils.create_text_mask(df, modifier_patterns) - effect_patterns = ['instead', 'plus'] - has_effect = tag_utils.create_text_mask(df, effect_patterns) - - # Create name exclusion mask - excluded_cards = [ - 'cloakwood swarmkeeper', - 'neyali, sun\'s vanguard', - 'staff of the storyteller' - ] - name_exclusions = tag_utils.create_name_mask(df, excluded_cards) - - return has_modifier & has_effect & ~name_exclusions - -def create_tokens_matter_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that care about tokens. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards care about tokens - """ - text_patterns = [ - 'tokens.*you.*control', - 'that\'s a token', - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - return text_mask - -def tag_for_tokens(df: pd.DataFrame, color: str) -> None: - """Tag cards that create or modify tokens using vectorized operations. - - This function identifies and tags: - - Cards that create creature tokens - - Cards that modify token creation (doublers, replacement effects) - - Cards that care about tokens - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - print('\n==========\n') - - try: - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Build masks - creature_mask = create_creature_token_mask(df) - modifier_mask = create_token_modifier_mask(df) - matters_mask = create_tokens_matter_mask(df) - - # Eldrazi Spawn/Scion special case - spawn_patterns = [ - 'eldrazi spawn creature token', - 'eldrazi scion creature token', - 'spawn creature token with "sacrifice', - 'scion creature token with "sacrifice' - ] - spawn_scion_mask = tag_utils.create_text_mask(df, spawn_patterns) - rules = [ - {'mask': creature_mask, 'tags': ['Creature Tokens', 'Token Creation', 'Tokens Matter']}, - {'mask': modifier_mask, 'tags': ['Token Modification', 'Token Creation', 'Tokens Matter']}, - {'mask': matters_mask, 'tags': ['Tokens Matter']}, - {'mask': spawn_scion_mask, 'tags': ['Aristocrats', 'Ramp']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'token-related cards', color=color, logger=logger) - - except Exception as e: - logger.error('Error tagging token cards: %s', str(e)) - raise - -### Freerunning (cost reduction variant) -def tag_for_freerunning(df: pd.DataFrame, color: str) -> None: - """Tag cards that reference the Freerunning mechanic. - - Adds Cost Reduction to ensure consistency, and a specific Freerunning tag for filtering. - """ - try: - required = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required) - mask = tag_utils.build_combined_mask( - df, keyword_patterns='Freerunning', text_patterns=['freerunning', 'free running'] - ) - tag_utils.tag_with_logging( - df, mask, ['Cost Reduction', 'Freerunning'], 'Freerunning cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Freerunning: %s', str(e)) - raise - -### Craft (transform mechanic with exile/graveyard/artifact hooks) -def tag_for_craft(df: pd.DataFrame, color: str) -> None: - """Tag cards with Craft. Adds Transform; conditionally adds Artifacts Matter, Exile Matters, and Graveyard Matters.""" - try: - craft_mask = tag_utils.create_keyword_mask(df, 'Craft') | tag_utils.create_text_mask(df, ['craft with', 'craft —', ' craft ']) - - # Conditionals - artifact_cond = craft_mask & tag_utils.create_text_mask(df, ['artifact', 'artifacts']) - exile_cond = craft_mask & tag_utils.create_text_mask(df, ['exile']) - gy_cond = craft_mask & tag_utils.create_text_mask(df, ['graveyard']) - - rules = [ - { 'mask': craft_mask, 'tags': ['Transform'] }, - { 'mask': artifact_cond, 'tags': ['Artifacts Matter'] }, - { 'mask': exile_cond, 'tags': ['Exile Matters'] }, - { 'mask': gy_cond, 'tags': ['Graveyard Matters'] } - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Craft cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Craft: %s', str(e)) - raise - -def tag_for_spree(df: pd.DataFrame, color: str) -> None: - """Tag Spree spells with Modal and Cost Scaling.""" - try: - mask = tag_utils.build_combined_mask( - df, keyword_patterns='Spree', text_patterns='spree' - ) - tag_utils.tag_with_logging( - df, mask, ['Modal', 'Cost Scaling'], 'Spree cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Spree: %s', str(e)) - raise - -def tag_for_explore_and_map(df: pd.DataFrame, color: str) -> None: - """Tag Explore and Map token interactions. - - - Explore: add Card Selection; if it places +1/+1 counters, add +1/+1 Counters - - Map Tokens: add Card Selection and Tokens Matter - """ - try: - explore_mask = tag_utils.create_keyword_mask(df, 'Explore') | tag_utils.create_text_mask(df, ['explores', 'explore.']) - map_mask = tag_utils.create_text_mask(df, ['map token', 'map tokens']) - explore_counters = explore_mask & tag_utils.create_text_mask(df, ['+1/+1 counter'], regex=False) - rules = [ - { 'mask': explore_mask, 'tags': ['Card Selection'] }, - { 'mask': explore_counters, 'tags': ['+1/+1 Counters'] }, - { 'mask': map_mask, 'tags': ['Card Selection', 'Tokens Matter'] } - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Explore/Map cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Explore/Map: %s', str(e)) - raise - -### Rad counters -def tag_for_rad_counters(df: pd.DataFrame, color: str) -> None: - """Tag Rad counter interactions as a dedicated theme.""" - try: - required = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required) - rad_mask = tag_utils.create_text_mask(df, ['rad counter', 'rad counters']) - tag_utils.tag_with_logging( - df, rad_mask, ['Rad Counters'], 'Rad counter cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Rad counters: %s', str(e)) - raise - -### Discard Matters -def tag_for_discard_matters(df: pd.DataFrame, color: str) -> None: - """Tag cards that discard or care about discarding. - - Adds Discard Matters for: - - Text that makes you discard a card (costs or effects) - - Triggers on discarding - Also adds Loot where applicable is handled elsewhere; this focuses on the theme surface. - """ - try: - # Events where YOU discard (as part of a cost or effect). Keep generic 'discard a card' but filter out opponent/each-player cases. - discard_action_patterns = [ - r'you discard (?:a|one|two|three|x) card', - r'discard (?:a|one|two|three|x) card', - r'discard your hand', - r'as an additional cost to (?:cast this spell|activate this ability),? discard (?:a|one) card', - r'as an additional cost,? discard (?:a|one) card' - ] - action_mask = tag_utils.create_text_mask(df, discard_action_patterns) - exclude_opponent_patterns = [ - r'target player discards', - r'target opponent discards', - r'each player discards', - r'each opponent discards', - r'that player discards' - ] - exclude_mask = tag_utils.create_text_mask(df, exclude_opponent_patterns) - - # Triggers/conditions that care when you discard - discard_trigger_patterns = [ - r'whenever you discard', - r'if you discarded', - r'for each card you discarded', - r'when you discard' - ] - trigger_mask = tag_utils.create_text_mask(df, discard_trigger_patterns) - - # Blood tokens enable rummage (discard), and Madness explicitly cares about discarding - blood_patterns = [r'create (?:a|one|two|three|x|\d+) blood token'] - blood_mask = tag_utils.create_text_mask(df, blood_patterns) - madness_mask = tag_utils.create_text_mask(df, [r'\bmadness\b']) - - final_mask = ((action_mask & ~exclude_mask) | trigger_mask | blood_mask | madness_mask) - tag_utils.tag_with_logging( - df, final_mask, ['Discard Matters'], 'Discard Matters cards', color=color, logger=logger - ) - except Exception as e: - logger.error('Error tagging Discard Matters: %s', str(e)) - raise - -### Life Matters -def tag_for_life_matters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about life totals, life gain/loss, and related effects using vectorized operations. - - This function coordinates multiple subfunctions to handle different life-related aspects: - - Lifegain effects and triggers - - Lifelink and lifelink-like abilities - - Life loss triggers and effects - - Food token creation and effects - - Life-related kindred synergies - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting "Life Matters" tagging for {color}_cards.csv') - print('\n==========\n') - - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'type', 'creatureTypes'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of life effect - tag_for_lifegain(df, color) - logger.info('Completed lifegain tagging') - print('\n==========\n') - - tag_for_lifelink(df, color) - logger.info('Completed lifelink tagging') - print('\n==========\n') - - tag_for_life_loss(df, color) - logger.info('Completed life loss tagging') - print('\n==========\n') - - tag_for_food(df, color) - logger.info('Completed food token tagging') - print('\n==========\n') - - tag_for_life_kindred(df, color) - logger.info('Completed life kindred tagging') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all "Life Matters" tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_life_matters: {str(e)}') - raise - -def tag_for_lifegain(df: pd.DataFrame, color: str) -> None: - """Tag cards with lifegain effects using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - gain_mask = ( - tag_utils.create_numbered_phrase_mask(df, ['gain', 'gains'], 'life') - | tag_utils.create_text_mask(df, ['gain life', 'gains life']) - ) - - # Exclude replacement effects - replacement_mask = tag_utils.create_text_mask(df, ['if you would gain life', 'whenever you gain life']) - - # Compute masks - final_mask = gain_mask & ~replacement_mask - trigger_mask = tag_utils.create_text_mask(df, ['if you would gain life', 'whenever you gain life']) - - rules = [ - { 'mask': final_mask, 'tags': ['Lifegain', 'Life Matters'] }, - { 'mask': trigger_mask, 'tags': ['Lifegain', 'Lifegain Triggers', 'Life Matters'] }, - ] - tag_utils.tag_with_rules_and_logging( - df, rules, 'Lifegain cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging lifegain effects: {str(e)}') - raise - -def tag_for_lifelink(df: pd.DataFrame, color: str) -> None: - """Tag cards with lifelink and lifelink-like effects using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - lifelink_mask = tag_utils.create_text_mask(df, 'lifelink') - lifelike_mask = tag_utils.create_text_mask(df, [ - 'deals damage, you gain that much life', - 'loses life.*gain that much life' - ]) - - # Exclude combat damage references for life loss conversion - damage_mask = tag_utils.create_text_mask(df, 'deals damage') - life_loss_mask = lifelike_mask & ~damage_mask - final_mask = lifelink_mask | lifelike_mask | life_loss_mask - - tag_utils.tag_with_logging( - df, final_mask, ['Lifelink', 'Lifegain', 'Life Matters'], - 'Lifelink cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging lifelink effects: {str(e)}') - raise - -def tag_for_life_loss(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about life loss using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - text_patterns = [ - 'you lost life', - 'you gained and lost life', - 'you gained or lost life', - 'you would lose life', - 'you\'ve gained and lost life this turn', - 'you\'ve lost life', - 'whenever you gain or lose life', - 'whenever you lose life' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - tag_utils.tag_with_logging( - df, text_mask, ['Lifeloss', 'Lifeloss Triggers', 'Life Matters'], - 'Life loss cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging life loss effects: {str(e)}') - raise - -def tag_for_food(df: pd.DataFrame, color: str) -> None: - """Tag cards that create or care about Food using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - final_mask = tag_utils.build_combined_mask( - df, text_patterns='food', type_patterns='food' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Food', 'Lifegain', 'Life Matters'], 'Food cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Food effects: {str(e)}') - raise - -def tag_for_life_kindred(df: pd.DataFrame, color: str) -> None: - """Tag cards with life-related kindred synergies using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - life_tribes = ['Angel', 'Bat', 'Cleric', 'Vampire'] - kindred_mask = df['creatureTypes'].apply(lambda x: any(tribe in x for tribe in life_tribes)) - - tag_utils.tag_with_logging( - df, kindred_mask, ['Lifegain', 'Life Matters'], 'life-related kindred cards', - color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging life kindred effects: {str(e)}') - raise - -### Counters -def tag_for_counters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about or interact with counters using vectorized operations. - - This function identifies and tags cards that: - - Add or remove counters (+1/+1, -1/-1, special counters) - - Care about counters being placed or removed - - Have counter-based abilities (proliferate, undying, etc) - - Create or modify counters - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Counters Matter', '+1/+1 Counters', etc. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting counter-related tagging for {color}_cards.csv') - print('\n==========\n') - - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'name', 'creatureTypes'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of counter effect - tag_for_general_counters(df, color) - logger.info('Completed general counter tagging') - print('\n==========\n') - - tag_for_plus_counters(df, color) - logger.info('Completed +1/+1 counter tagging') - print('\n==========\n') - - tag_for_minus_counters(df, color) - logger.info('Completed -1/-1 counter tagging') - print('\n==========\n') - - tag_for_special_counters(df, color) - logger.info('Completed special counter tagging') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all counter-related tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_counters: {str(e)}') - raise - -def tag_for_general_counters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about counters in general using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - text_patterns = [ - 'choose a kind of counter', - 'if it had counters', - 'move a counter', - 'one or more counters', - 'proliferate', - 'remove a counter', - 'with counters on them' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - specific_cards = [ - 'banner of kinship', - 'damning verdict', - 'ozolith' - ] - name_mask = tag_utils.create_name_mask(df, specific_cards) - final_mask = text_mask | name_mask - - tag_utils.tag_with_logging( - df, final_mask, ['Counters Matter'], 'General counter cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging general counter effects: {str(e)}') - raise - -def tag_for_plus_counters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about +1/+1 counters using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Create text pattern mask using compiled patterns - text_mask = ( - df['text'].str.contains(rgx.PLUS_ONE_COUNTER.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.IF_HAD_COUNTERS.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.ONE_OR_MORE_COUNTERS.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.ONE_OR_MORE_PLUS_ONE_COUNTERS.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.PROLIFERATE.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.UNDYING.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.WITH_COUNTERS_ON_THEM.pattern, case=False, na=False, regex=True) - ) - # Create creature type mask - type_mask = df['creatureTypes'].apply(lambda x: 'Hydra' in x if isinstance(x, list) else False) - final_mask = text_mask | type_mask - - tag_utils.tag_with_logging( - df, final_mask, ['+1/+1 Counters', 'Counters Matter', 'Voltron'], - '+1/+1 counter cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging +1/+1 counter effects: {str(e)}') - raise - -def tag_for_minus_counters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about -1/-1 counters using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - # Create text pattern mask - text_patterns = [ - '-1/-1 counter', - 'if it had counters', - 'infect', - 'one or more counter', - 'one or more -1/-1 counter', - 'persist', - 'proliferate', - 'wither' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - tag_utils.tag_with_logging( - df, text_mask, ['-1/-1 Counters', 'Counters Matter'], - '-1/-1 counter cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging -1/-1 counter effects: {str(e)}') - raise - -def tag_for_special_counters(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about special counters using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - """ - try: - rules = [] - for counter_type in tag_constants.COUNTER_TYPES: - pattern = f'{counter_type} counter' - mask = tag_utils.create_text_mask(df, pattern) - tags = [f'{counter_type} Counters', 'Counters Matter'] - rules.append({ 'mask': mask, 'tags': tags }) - - tag_utils.tag_with_rules_and_logging( - df, rules, 'Special counter cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging special counter effects: {str(e)}') - raise - -### Voltron -def create_voltron_commander_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that are Voltron commanders. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are Voltron commanders - """ - return tag_utils.create_name_mask(df, tag_constants.VOLTRON_COMMANDER_CARDS) - -def create_voltron_support_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that support Voltron strategies. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards support Voltron strategies - """ - return tag_utils.create_text_mask(df, tag_constants.VOLTRON_PATTERNS) - -def create_voltron_equipment_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for Equipment-based Voltron cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are Equipment-based Voltron cards - """ - return tag_utils.create_type_mask(df, 'Equipment') - -def create_voltron_aura_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for Aura-based Voltron cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are Aura-based Voltron cards - """ - return tag_utils.create_type_mask(df, 'Aura') - -def tag_for_voltron(df: pd.DataFrame, color: str) -> None: - """Tag cards that fit the Voltron strategy. - - This function identifies and tags cards that support the Voltron strategy including: - - Voltron commanders - - Equipment and Auras - - Cards that care about equipped/enchanted creatures - - Cards that enhance single creatures - - The function uses vectorized operations for performance and follows patterns - established in other tagging functions. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'type', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - commander_mask = create_voltron_commander_mask(df) - support_mask = create_voltron_support_mask(df) - equipment_mask = create_voltron_equipment_mask(df) - aura_mask = create_voltron_aura_mask(df) - final_mask = commander_mask | support_mask | equipment_mask | aura_mask - tag_utils.tag_with_logging( - df, final_mask, ['Voltron'], - 'Voltron strategy cards', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_voltron: {str(e)}') - raise - -### Lands matter -def create_lands_matter_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that care about lands in general. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have lands matter effects - """ - name_mask = tag_utils.create_name_mask(df, tag_constants.LANDS_MATTER_SPECIFIC_CARDS) - - # Create text pattern masks - play_mask = tag_utils.create_text_mask(df, tag_constants.LANDS_MATTER_PATTERNS['land_play']) - search_mask = tag_utils.create_text_mask(df, tag_constants.LANDS_MATTER_PATTERNS['land_search']) - state_mask = tag_utils.create_text_mask(df, tag_constants.LANDS_MATTER_PATTERNS['land_state']) - return name_mask | play_mask | search_mask | state_mask - -def create_domain_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with domain effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have domain effects - """ - keyword_mask = tag_utils.create_keyword_mask(df, tag_constants.DOMAIN_PATTERNS['keyword']) - text_mask = tag_utils.create_text_mask(df, tag_constants.DOMAIN_PATTERNS['text']) - return keyword_mask | text_mask - -def create_landfall_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with landfall triggers. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have landfall effects - """ - keyword_mask = tag_utils.create_keyword_mask(df, tag_constants.LANDFALL_PATTERNS['keyword']) - trigger_mask = tag_utils.create_text_mask(df, tag_constants.LANDFALL_PATTERNS['triggers']) - return keyword_mask | trigger_mask - -def create_landwalk_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with landwalk abilities. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have landwalk abilities - """ - basic_mask = tag_utils.create_text_mask(df, tag_constants.LANDWALK_PATTERNS['basic']) - nonbasic_mask = tag_utils.create_text_mask(df, tag_constants.LANDWALK_PATTERNS['nonbasic']) - return basic_mask | nonbasic_mask - -def create_land_types_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that care about specific land types. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards care about specific land types - """ - # Create type-based mask - type_mask = tag_utils.create_type_mask(df, tag_constants.LAND_TYPES) - text_masks = [] - for land_type in tag_constants.LAND_TYPES: - patterns = [ - f'search your library for a {land_type.lower()}', - f'search your library for up to two {land_type.lower()}', - f'{land_type} you control' - ] - text_masks.append(tag_utils.create_text_mask(df, patterns)) - return type_mask | pd.concat(text_masks, axis=1).any(axis=1) - -def tag_for_lands_matter(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about lands using vectorized operations. - - This function identifies and tags cards with land-related effects including: - - General lands matter effects (searching, playing additional lands, etc) - - Domain effects - - Landfall triggers - - Landwalk abilities - - Specific land type matters - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - print('\n==========\n') - - try: - required_cols = {'text', 'themeTags', 'type', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - lands_mask = create_lands_matter_mask(df) - domain_mask = create_domain_mask(df) - landfall_mask = create_landfall_mask(df) - landwalk_mask = create_landwalk_mask(df) - types_mask = create_land_types_mask(df) - rules = [ - {'mask': lands_mask, 'tags': ['Lands Matter']}, - {'mask': domain_mask, 'tags': ['Domain', 'Lands Matter']}, - {'mask': landfall_mask, 'tags': ['Landfall', 'Lands Matter']}, - {'mask': landwalk_mask, 'tags': ['Landwalk', 'Lands Matter']}, - {'mask': types_mask, 'tags': ['Land Types Matter', 'Lands Matter']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'lands matter effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error in tag_for_lands_matter: {str(e)}') - raise - -### Spells Matter -def create_spellslinger_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with spellslinger text patterns. - - This function identifies cards that care about casting spells through text patterns like: - - Casting modal spells - - Casting spells from anywhere - - Casting instant/sorcery spells - - Casting noncreature spells - - First/next spell cast triggers - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have spellslinger text patterns - """ - text_patterns = [ - 'cast a modal', - 'cast a spell from anywhere', - 'cast an instant', - 'cast a noncreature', - 'casts an instant', - 'casts a noncreature', - 'first instant', - 'first spell', - 'next cast an instant', - 'next instant', - 'next spell', - 'second instant', - 'second spell', - 'you cast an instant', - 'you cast a spell' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_spellslinger_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with spellslinger-related keywords. - - This function identifies cards with keywords that indicate they care about casting spells: - - Magecraft - - Storm - - Prowess - - Surge - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have spellslinger keywords - """ - keyword_patterns = [ - 'Magecraft', - 'Storm', - 'Prowess', - 'Surge' - ] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_spellslinger_type_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for instant/sorcery type cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are instants or sorceries - """ - return tag_utils.create_type_mask(df, ['Instant', 'Sorcery']) - -def create_spellslinger_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from spellslinger tagging. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - # Add specific exclusion patterns here if needed - excluded_names = [ - 'Possibility Storm', - 'Wild-Magic Sorcerer' - ] - return tag_utils.create_name_mask(df, excluded_names) - -def tag_for_spellslinger(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about casting spells using vectorized operations. - - This function identifies and tags cards that care about spellcasting including: - - Cards that trigger off casting spells - - Instant and sorcery spells - - Cards with spellslinger-related keywords - - Cards that care about noncreature spells - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Spellslinger', 'Spells Matter', etc. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - logger.info(f'Starting Spellslinger tagging for {color}_cards.csv') - print('\n==========\n') - - try: - required_cols = {'text', 'themeTags', 'type', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_spellslinger_text_mask(df) - keyword_mask = create_spellslinger_keyword_mask(df) - type_mask = create_spellslinger_type_mask(df) - exclusion_mask = create_spellslinger_exclusion_mask(df) - final_mask = (text_mask | keyword_mask | type_mask) & ~exclusion_mask - tag_utils.tag_with_logging( - df, final_mask, ['Spellslinger', 'Spells Matter'], - 'general Spellslinger cards', color=color, logger=logger - ) - - # Run non-generalized tags - tag_for_storm(df, color) - tag_for_magecraft(df, color) - tag_for_cantrips(df, color) - tag_for_spell_copy(df, color) - - except Exception as e: - logger.error(f'Error in tag_for_spellslinger: {str(e)}') - raise - -def create_storm_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with storm effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have storm effects - """ - # Create keyword mask - keyword_mask = tag_utils.create_keyword_mask(df, 'Storm') - - # Create text mask - text_patterns = [ - 'gain storm', - 'has storm', - 'have storm' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - return keyword_mask | text_mask - -def tag_for_storm(df: pd.DataFrame, color: str) -> None: - """Tag cards with storm effects using vectorized operations. - - This function identifies and tags cards that: - - Have the storm keyword - - Grant or care about storm - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - storm_mask = create_storm_mask(df) - tag_utils.tag_with_logging( - df, storm_mask, ['Storm', 'Spellslinger', 'Spells Matter'], - 'Storm cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Storm effects: {str(e)}') - raise - -## Tag for Cantrips -def tag_for_cantrips(df: pd.DataFrame, color: str) -> None: - """Tag cards in the DataFrame as cantrips based on specific criteria. - - Cantrips are defined as low-cost spells (mana value <= 2) that draw cards. - The function excludes certain card types, keywords, and specific named cards - from being tagged as cantrips. - - Args: - df: The DataFrame containing card data - color: The color identifier for logging purposes - """ - try: - # Convert mana value to numeric - df['manaValue'] = pd.to_numeric(df['manaValue'], errors='coerce') - - # Create exclusion masks - excluded_types = tag_utils.create_type_mask(df, 'Land|Equipment') - excluded_keywords = tag_utils.create_keyword_mask(df, ['Channel', 'Cycling', 'Connive', 'Learn', 'Ravenous']) - has_loot = df['themeTags'].apply(lambda x: 'Loot' in x) - - # Define name exclusions - EXCLUDED_NAMES = { - 'Archivist of Oghma', 'Argothian Enchantress', 'Audacity', 'Betrayal', 'Bequeathal', 'Blood Scrivener', 'Brigon, Soldier of Meletis', - 'Compost', 'Concealing curtains // Revealing Eye', 'Cryptbreaker', 'Curiosity', 'Cuse of Vengeance', 'Cryptek', 'Dakra Mystic', - 'Dawn of a New Age', 'Dockside Chef', 'Dreamcatcher', 'Edgewall Innkeeper', 'Eidolon of Philosophy', 'Evolved Sleeper', - 'Femeref Enchantress', 'Finneas, Ace Archer', 'Flumph', 'Folk Hero', 'Frodo, Adventurous Hobbit', 'Goblin Artisans', - 'Goldberry, River-Daughter', 'Gollum, Scheming Guide', 'Hatching Plans', 'Ideas Unbound', 'Ingenius Prodigy', 'Ior Ruin Expedition', - "Jace's Erasure", 'Keeper of the Mind', 'Kor Spiritdancer', 'Lodestone Bauble', 'Puresteel Paladin', 'Jeweled Bird', 'Mindblade Render', - "Multani's Presence", "Nahiri's Lithoforming", 'Ordeal of Thassa', 'Pollywog Prodigy', 'Priest of Forgotten Gods', 'Ravenous Squirrel', - 'Read the Runes', 'Red Death, Shipwrecker', 'Roil Cartographer', 'Sage of Lat-Name', 'Saprazzan Heir', 'Scion of Halaster', 'See Beyond', - 'Selhoff Entomber', 'Shielded Aether Theif', 'Shore Keeper', 'silverquill Silencer', 'Soldevi Sage', 'Soldevi Sentry', 'Spiritual Focus', - 'Sram, Senior Edificer', 'Staff of the Storyteller', 'Stirge', 'Sylvan Echoes', "Sythis Harvest's Hand", 'Sygg, River Cutthroat', - 'Tenuous Truce', 'Test of Talents', 'Thalakos seer', "Tribute to Horobi // Echo of Deaths Wail", 'Vampire Gourmand', 'Vampiric Rites', - 'Vampirism', 'Vessel of Paramnesia', "Witch's Caultron", 'Wall of Mulch', 'Waste Not', 'Well Rested' - # Add other excluded names here - } - excluded_names = df['name'].isin(EXCLUDED_NAMES) - - # Create cantrip condition masks - has_draw = tag_utils.create_text_mask(df, tag_constants.PATTERN_GROUPS['draw']) - low_cost = df['manaValue'].fillna(float('inf')) <= 2 - - # Combine conditions - cantrip_mask = ( - ~excluded_types & - ~excluded_keywords & - ~has_loot & - ~excluded_names & - has_draw & - low_cost - ) - tag_utils.apply_rules(df, [ - { 'mask': cantrip_mask, 'tags': tag_constants.TAG_GROUPS['Cantrips'] }, - ]) - - # Log results - cantrip_count = cantrip_mask.sum() - logger.info(f'Tagged {cantrip_count} Cantrip cards') - - except Exception as e: - logger.error('Error tagging Cantrips in %s_cards.csv: %s', color, str(e)) - raise - -## Magecraft -def create_magecraft_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with magecraft effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have magecraft effects - """ - return tag_utils.create_keyword_mask(df, 'Magecraft') - -def tag_for_magecraft(df: pd.DataFrame, color: str) -> None: - """Tag cards with magecraft using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - magecraft_mask = create_magecraft_mask(df) - tag_utils.tag_with_logging( - df, magecraft_mask, ['Magecraft', 'Spellslinger', 'Spells Matter'], - 'Magecraft cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error tagging Magecraft effects: {str(e)}') - raise - -## Spell Copy -def create_spell_copy_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with spell copy text patterns. - - This function identifies cards that copy spells through text patterns like: - - Copy target spell - - Copy that spell - - Copy the next spell - - Create copies of spells - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have spell copy text patterns - """ - text_patterns = [ - 'copy a spell', - 'copy it', - 'copy that spell', - 'copy target', - 'copy the next', - 'create a copy', - 'creates a copy' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_spell_copy_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with spell copy related keywords. - - This function identifies cards with keywords that indicate they copy spells: - - Casualty - - Conspire - - Replicate - - Storm - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have spell copy keywords - """ - keyword_patterns = [ - 'Casualty', - 'Conspire', - 'Replicate', - 'Storm' - ] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def tag_for_spell_copy(df: pd.DataFrame, color: str) -> None: - """Tag cards that copy spells using vectorized operations. - - This function identifies and tags cards that copy spells including: - - Cards that directly copy spells - - Cards with copy-related keywords - - Cards that create copies of spells - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_spell_copy_text_mask(df) - keyword_mask = create_spell_copy_keyword_mask(df) - final_mask = text_mask | keyword_mask - tag_utils.apply_rules(df, [ - { 'mask': final_mask, 'tags': ['Spell Copy', 'Spellslinger', 'Spells Matter'] }, - ]) - - # Log results - spellcopy_count = final_mask.sum() - logger.info(f'Tagged {spellcopy_count} spell copy cards') - - except Exception as e: - logger.error(f'Error in tag_for_spell_copy: {str(e)}') - raise - -### Ramp -def create_mana_dork_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for creatures that produce mana. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are mana dorks - """ - # Create base creature mask - creature_mask = tag_utils.create_type_mask(df, 'Creature') - - # Create text pattern masks - tap_mask = tag_utils.create_text_mask(df, ['{T}: Add', '{T}: Untap']) - sac_mask = tag_utils.create_text_mask(df, ['creature: add', 'control: add']) - - # Create mana symbol mask - mana_patterns = [f'add {{{c}}}' for c in ['C', 'W', 'U', 'B', 'R', 'G']] - mana_mask = tag_utils.create_text_mask(df, mana_patterns) - - # Create specific cards mask - specific_cards = ['Awaken the Woods', 'Forest Dryad'] - name_mask = tag_utils.create_name_mask(df, specific_cards) - - return creature_mask & (tap_mask | sac_mask | mana_mask) | name_mask - -def create_mana_rock_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for artifacts that produce mana. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are mana rocks - """ - # Create base artifact mask - artifact_mask = tag_utils.create_type_mask(df, 'Artifact') - - # Create text pattern masks - tap_mask = tag_utils.create_text_mask(df, ['{T}: Add', '{T}: Untap']) - sac_mask = tag_utils.create_text_mask(df, ['creature: add', 'control: add']) - - # Create mana symbol mask - mana_patterns = [f'add {{{c}}}' for c in ['C', 'W', 'U', 'B', 'R', 'G']] - mana_mask = tag_utils.create_text_mask(df, mana_patterns) - - # Create token mask - token_mask = tag_utils.create_tag_mask(df, ['Powerstone Tokens', 'Treasure Tokens', 'Gold Tokens']) | \ - tag_utils.create_text_mask(df, 'token named meteorite') - - return (artifact_mask & (tap_mask | sac_mask | mana_mask)) | token_mask - -def create_extra_lands_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that allow playing additional lands. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards allow playing extra lands - """ - text_patterns = [ - 'additional land', - 'play an additional land', - 'play two additional lands', - 'put a land', - 'put all land', - 'put those land', - 'return all land', - 'return target land' - ] - - return tag_utils.create_text_mask(df, text_patterns) - -def create_land_search_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that search for lands. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards search for lands - """ - # Create basic search patterns - search_patterns = [ - 'search your library for a basic', - 'search your library for a land', - 'search your library for up to', - 'each player searches', - 'put those land' - ] - - # Create land type specific patterns - land_types = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes'] - for land_type in land_types: - search_patterns.extend([ - f'search your library for a basic {land_type.lower()}', - f'search your library for a {land_type.lower()}', - f'search your library for an {land_type.lower()}' - ]) - - return tag_utils.create_text_mask(df, search_patterns) - -def tag_for_ramp(df: pd.DataFrame, color: str) -> None: - """Tag cards that provide mana acceleration using vectorized operations. - - This function identifies and tags cards that provide mana acceleration through: - - Mana dorks (creatures that produce mana) - - Mana rocks (artifacts that produce mana) - - Extra land effects - - Land search effects - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - print('\n==========\n') - - try: - dork_mask = create_mana_dork_mask(df) - rock_mask = create_mana_rock_mask(df) - lands_mask = create_extra_lands_mask(df) - search_mask = create_land_search_mask(df) - rules = [ - {'mask': dork_mask, 'tags': ['Mana Dork', 'Ramp']}, - {'mask': rock_mask, 'tags': ['Mana Rock', 'Ramp']}, - {'mask': lands_mask, 'tags': ['Lands Matter', 'Ramp']}, - {'mask': search_mask, 'tags': ['Lands Matter', 'Ramp']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'ramp effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error in tag_for_ramp: {str(e)}') - raise - -### Other Misc Themes -def tag_for_themes(df: pd.DataFrame, color: str) -> None: - """Tag cards that fit other themes that haven't been done so far. - - This function will call on functions to tag for: - - Aggo - - Aristocrats - - Big Mana - - Blink - - Burn - - Clones - - Control - - Energy - - Infect - - Legends Matter - - Little Creatures - - Mill - - Monarch - - Multiple Copy Cards (i.e. Hare Apparent or Dragon's Approach) - - Superfriends - - Reanimate - - Stax - - Theft - - Toughess Matters - - Topdeck - - X Spells - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting tagging for remaining themes in {color}_cards.csv') - print('\n===============\n') - tag_for_aggro(df, color) - print('\n==========\n') - tag_for_aristocrats(df, color) - print('\n==========\n') - tag_for_big_mana(df, color) - print('\n==========\n') - tag_for_blink(df, color) - print('\n==========\n') - tag_for_burn(df, color) - print('\n==========\n') - tag_for_clones(df, color) - print('\n==========\n') - tag_for_control(df, color) - print('\n==========\n') - tag_for_energy(df, color) - print('\n==========\n') - tag_for_infect(df, color) - print('\n==========\n') - tag_for_legends_matter(df, color) - print('\n==========\n') - tag_for_little_guys(df, color) - print('\n==========\n') - tag_for_mill(df, color) - print('\n==========\n') - tag_for_monarch(df, color) - print('\n==========\n') - tag_for_multiple_copies(df, color) - print('\n==========\n') - tag_for_planeswalkers(df, color) - print('\n==========\n') - tag_for_reanimate(df, color) - print('\n==========\n') - tag_for_stax(df, color) - print('\n==========\n') - tag_for_theft(df, color) - print('\n==========\n') - tag_for_toughness(df, color) - print('\n==========\n') - tag_for_topdeck(df, color) - print('\n==========\n') - tag_for_x_spells(df, color) - print('\n==========\n') - - duration = (pd.Timestamp.now() - start_time).total_seconds() - logger.info(f'Completed theme tagging in {duration:.2f}s') - -## Aggro -def create_aggro_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with aggro-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have aggro text patterns - """ - text_patterns = [ - 'a creature attacking', - 'deal combat damage', - 'deals combat damage', - 'have riot', - 'this creature attacks', - 'whenever you attack', - 'whenever .* attack', - 'whenever .* deals combat', - 'you control attack', - 'you control deals combat', - 'untap all attacking creatures' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_aggro_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with aggro-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have aggro keywords - """ - keyword_patterns = [ - 'Blitz', - 'Deathtouch', - 'Double Strike', - 'First Strike', - 'Fear', - 'Haste', - 'Menace', - 'Myriad', - 'Prowl', - 'Raid', - 'Shadow', - 'Spectacle', - 'Trample' - ] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_aggro_theme_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with aggro-related themes. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have aggro themes - """ - return tag_utils.create_tag_mask(df, ['Voltron']) - -def tag_for_aggro(df: pd.DataFrame, color: str) -> None: - """Tag cards that fit the Aggro theme using vectorized operations. - - This function identifies and tags cards that support aggressive strategies including: - - Cards that care about attacking - - Cards with combat-related keywords - - Cards that deal combat damage - - Cards that support Voltron strategies - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_aggro_text_mask(df) - keyword_mask = create_aggro_keyword_mask(df) - theme_mask = create_aggro_theme_mask(df) - final_mask = text_mask | keyword_mask | theme_mask - tag_utils.tag_with_logging( - df, final_mask, ['Aggro', 'Combat Matters'], - 'Aggro strategy cards', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_aggro: {str(e)}') - raise - - -## Aristocrats -def create_aristocrat_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with aristocrat-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have aristocrat text patterns - """ - return tag_utils.create_text_mask(df, tag_constants.ARISTOCRAT_TEXT_PATTERNS) - -def create_aristocrat_name_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for specific aristocrat-related cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are specific aristocrat cards - """ - return tag_utils.create_name_mask(df, tag_constants.ARISTOCRAT_SPECIFIC_CARDS) - -def create_aristocrat_self_sacrifice_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for creatures with self-sacrifice effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which creatures have self-sacrifice effects - """ - # Create base creature mask - creature_mask = tag_utils.create_type_mask(df, 'Creature') - - # Create name-based patterns - def check_self_sacrifice(row): - if pd.isna(row['text']) or pd.isna(row['name']): - return False - name = row['name'].lower() - text = row['text'].lower() - return f'sacrifice {name}' in text or f'when {name} dies' in text - - # Apply patterns to creature cards - return creature_mask & df.apply(check_self_sacrifice, axis=1) - -def create_aristocrat_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with aristocrat-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have aristocrat keywords - """ - return tag_utils.create_keyword_mask(df, 'Blitz') - -def create_aristocrat_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from aristocrat effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - return tag_utils.create_text_mask(df, tag_constants.ARISTOCRAT_EXCLUSION_PATTERNS) - -def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None: - """Tag cards that fit the Aristocrats or Sacrifice Matters themes using vectorized operations. - - This function identifies and tags cards that care about sacrificing permanents or creatures dying, including: - - Cards with sacrifice abilities or triggers - - Cards that care about creatures dying - - Cards with self-sacrifice effects - - Cards with Blitz or similar mechanics - - The function uses efficient vectorized operations and separate mask creation functions - for different aspects of the aristocrats theme. It handles: - - Text-based patterns for sacrifice and death triggers - - Specific named cards known for aristocrats strategies - - Self-sacrifice effects on creatures - - Relevant keywords like Blitz - - Proper exclusions to avoid false positives - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'name', 'type', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_aristocrat_text_mask(df) - name_mask = create_aristocrat_name_mask(df) - self_sacrifice_mask = create_aristocrat_self_sacrifice_mask(df) - keyword_mask = create_aristocrat_keyword_mask(df) - exclusion_mask = create_aristocrat_exclusion_mask(df) - final_mask = (text_mask | name_mask | self_sacrifice_mask | keyword_mask) & ~exclusion_mask - tag_utils.tag_with_logging( - df, final_mask, ['Aristocrats', 'Sacrifice Matters'], - 'aristocrats effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_aristocrats: {str(e)}') - raise - -### Bending -def tag_for_bending(df: pd.DataFrame, color: str) -> None: - """Tag cards for bending-related keywords. - - Looks for 'airbend', 'waterbend', 'firebend', 'earthbend' in rules text and - applies tags accordingly. - """ - try: - air_mask = tag_utils.create_text_mask(df, 'airbend') - water_mask = tag_utils.create_text_mask(df, 'waterbend') - fire_mask = tag_utils.create_text_mask(df, 'firebend') - earth_mask = tag_utils.create_text_mask(df, 'earthbend') - bending_mask = air_mask | water_mask | fire_mask | earth_mask - rules = [ - {'mask': air_mask, 'tags': ['Airbending', 'Exile Matters', 'Leave the Battlefield']}, - {'mask': water_mask, 'tags': ['Waterbending', 'Cost Reduction', 'Big Mana']}, - {'mask': fire_mask, 'tags': ['Aggro', 'Combat Matters', 'Firebending', 'Mana Dork', 'Ramp', 'X Spells']}, - {'mask': earth_mask, 'tags': ['Earthbending', 'Lands Matter', 'Landfall']}, - {'mask': bending_mask, 'tags': ['Bending']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'bending effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging Bending keywords: {str(e)}') - raise - -### Web-Slinging -def tag_for_web_slinging(df: pd.DataFrame, color: str) -> None: - """Tag cards for web-slinging related keywords. - - Looks for 'web-slinging' in rules text and applies tags accordingly. - """ - try: - webslinging_mask = tag_utils.create_text_mask(df, 'web-slinging') - rules = [ - {'mask': webslinging_mask, 'tags': ['Web-slinging']}, - ] - tag_utils.tag_with_rules_and_logging(df, rules, 'web-slinging effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging Web-Slinging keywords: {str(e)}') - raise - -### Tag for land types -def tag_for_land_types(df: pd.DataFrame, color: str) -> None: - """Tag card for specific non-basic land types. - - Looks for 'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Sphere', 'Urza's' in rules text and applies tags accordingly. - """ - try: - cave_mask = ( - (tag_utils.create_text_mask(df, 'Cave') & ~tag_utils.create_text_mask(df, 'scavenge')) | - tag_utils.create_type_mask(df, 'Cave') - ) - desert_mask = ( - tag_utils.create_text_mask(df, 'Desert') | - tag_utils.create_type_mask(df, 'Desert') - ) - gate_mask = ( - ( - tag_utils.create_text_mask(df, 'Gate') & - ~tag_utils.create_text_mask(df, 'Agate') & - ~tag_utils.create_text_mask(df, 'Legate') & - ~tag_utils.create_text_mask(df, 'Throw widethe Gates') & - ~tag_utils.create_text_mask(df, 'Eternity Gate') & - ~tag_utils.create_text_mask(df, 'Investigates') - ) | - tag_utils.create_text_mask(df, 'Gate card') | - tag_utils.create_type_mask(df, 'Gate') - ) - lair_mask = (tag_utils.create_type_mask(df, 'Lair')) - locus_mask = (tag_utils.create_type_mask(df, 'Locus')) - sphere_mask = ( - (tag_utils.create_text_mask(df, 'Sphere') & ~tag_utils.create_text_mask(df, 'Detention Sphere')) | - tag_utils.create_type_mask(df, 'Sphere')) - urzas_mask = (tag_utils.create_type_mask(df, "Urza's")) - rules = [ - {'mask': cave_mask, 'tags': ['Caves Matter', 'Lands Matter']}, - {'mask': desert_mask, 'tags': ['Deserts Matter', 'Lands Matter']}, - {'mask': gate_mask, 'tags': ['Gates Matter', 'Lands Matter']}, - {'mask': lair_mask, 'tags': ['Lairs Matter', 'Lands Matter']}, - {'mask': locus_mask, 'tags': ['Locus Matter', 'Lands Matter']}, - {'mask': sphere_mask, 'tags': ['Spheres Matter', 'Lands Matter']}, - {'mask': urzas_mask, 'tags': ["Urza's Lands Matter", 'Lands Matter']}, - ] - - tag_utils.tag_with_rules_and_logging(df, rules, 'non-basic land types', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging non-basic land types: {str(e)}') - raise - -## Big Mana -def create_big_mana_cost_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with high mana costs or X costs. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have high/X mana costs - """ - # High mana value mask - high_cost = df['manaValue'].fillna(0).astype(float) >= 5 - - # X cost mask - x_cost = df['manaCost'].fillna('').str.contains('{X}', case=False, regex=False) - - return high_cost | x_cost - -def tag_for_big_mana(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about or generate large amounts of mana using vectorized operations. - - This function identifies and tags cards that: - - Have high mana costs (5 or greater) - - Care about high mana values or power - - Generate large amounts of mana - - Have X costs - - Have keywords related to mana generation - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'manaValue', 'manaCost', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = tag_utils.create_text_mask(df, tag_constants.BIG_MANA_TEXT_PATTERNS) - keyword_mask = tag_utils.create_keyword_mask(df, tag_constants.BIG_MANA_KEYWORDS) - cost_mask = create_big_mana_cost_mask(df) - specific_mask = tag_utils.create_name_mask(df, tag_constants.BIG_MANA_SPECIFIC_CARDS) - tag_mask = tag_utils.create_tag_mask(df, 'Cost Reduction') - final_mask = text_mask | keyword_mask | cost_mask | specific_mask | tag_mask - tag_utils.tag_with_logging( - df, final_mask, ['Big Mana'], - 'big mana effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_big_mana: {str(e)}') - raise - -## Blink -def create_etb_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with enter-the-battlefield effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have ETB effects - """ - text_patterns = [ - 'creature entering causes', - 'permanent entering the battlefield', - 'permanent you control enters', - 'whenever another creature enters', - 'whenever another nontoken creature enters', - 'when this creature enters', - 'whenever this creature enters' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_ltb_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with leave-the-battlefield effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have LTB effects - """ - text_patterns = [ - 'when this creature leaves', - 'whenever this creature leaves' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_blink_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with blink/flicker text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have blink/flicker effects - """ - text_patterns = [ - 'exile any number of other', - 'exile one or more cards from your hand', - 'permanent you control, then return', - 'permanents you control, then return', - 'triggered ability of a permanent' - ] - # Include centralized return-to-battlefield phrasing - return_mask = tag_utils.create_text_mask(df, tag_constants.PHRASE_GROUPS['blink_return']) - base_mask = tag_utils.create_text_mask(df, text_patterns) - return return_mask | base_mask - -def tag_for_blink(df: pd.DataFrame, color: str) -> None: - """Tag cards that have blink/flicker effects using vectorized operations. - - This function identifies and tags cards with blink/flicker effects including: - - Enter-the-battlefield (ETB) triggers - - Leave-the-battlefield (LTB) triggers - - Exile and return effects - - Permanent flicker effects - - The function maintains proper tag hierarchy and ensures consistent application - of related tags like 'Blink', 'Enter the Battlefield', and 'Leave the Battlefield'. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - etb_mask = create_etb_mask(df) - ltb_mask = create_ltb_mask(df) - blink_mask = create_blink_text_mask(df) - - # Create name-based masks - name_patterns = df.apply( - lambda row: re.compile( - f'when {row["name"]} enters|whenever {row["name"]} enters|when {row["name"]} leaves|whenever {row["name"]} leaves', - re.IGNORECASE - ), - axis=1 - ) - name_mask = df.apply( - lambda row: bool(name_patterns[row.name].search(row['text'])) if pd.notna(row['text']) else False, - axis=1 - ) - final_mask = etb_mask | ltb_mask | blink_mask | name_mask - tag_utils.tag_with_logging( - df, final_mask, ['Blink', 'Enter the Battlefield', 'Leave the Battlefield'], - 'blink/flicker effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_blink: {str(e)}') - raise - -## Burn -def create_burn_damage_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with damage-dealing effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have damage effects - """ - # Match any numeric or X damage in a single regex for performance - damage_pattern = r'deals\s+(?:[0-9]+|x)\s+damage' - damage_mask = tag_utils.create_text_mask(df, damage_pattern) - - # Create general damage trigger patterns - trigger_patterns = [ - 'deals damage', - 'deals noncombat damage', - 'deals that much damage', - 'excess damage', - 'excess noncombat damage', - 'would deal an amount of noncombat damage', - 'would deal damage', - 'would deal noncombat damage' - ] - trigger_mask = tag_utils.create_text_mask(df, trigger_patterns) - - # Create pinger patterns using compiled patterns - pinger_mask = ( - df['text'].str.contains(rgx.DEALS_ONE_DAMAGE.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.EXACTLY_ONE_DAMAGE.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.LOSES_ONE_LIFE.pattern, case=False, na=False, regex=True) - ) - - return damage_mask | trigger_mask | pinger_mask - -def create_burn_life_loss_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with life loss effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have life loss effects - """ - # Create life loss patterns using a single numbered phrase mask - life_mask = tag_utils.create_numbered_phrase_mask(df, verb=['lose', 'loses'], noun='life') - - # Create general life loss trigger patterns - trigger_patterns = [ - 'each 1 life', - 'loses that much life', - 'opponent lost life', - 'opponent loses life', - 'player loses life', - 'unspent mana causes that player to lose that much life', - 'would lose life' - ] - trigger_mask = tag_utils.create_text_mask(df, trigger_patterns) - - return life_mask | trigger_mask - -def create_burn_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with burn-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have burn keywords - """ - keyword_patterns = ['Bloodthirst', 'Spectacle'] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_burn_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from burn effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - # Add specific exclusion patterns here if needed - return pd.Series(False, index=df.index) - -def tag_for_burn(df: pd.DataFrame, color: str) -> None: - """Tag cards that deal damage or cause life loss using vectorized operations. - - This function identifies and tags cards with burn effects including: - - Direct damage dealing - - Life loss effects - - Burn-related keywords (Bloodthirst, Spectacle) - - Pinger effects (1 damage) - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - damage_mask = create_burn_damage_mask(df) - life_mask = create_burn_life_loss_mask(df) - keyword_mask = create_burn_keyword_mask(df) - exclusion_mask = create_burn_exclusion_mask(df) - burn_mask = (damage_mask | life_mask | keyword_mask) & ~exclusion_mask - - # Pinger mask using compiled patterns (eliminates duplication) - pinger_mask = ( - df['text'].str.contains(rgx.DEALS_ONE_DAMAGE.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.EXACTLY_ONE_DAMAGE.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.LOSES_ONE_LIFE.pattern, case=False, na=False, regex=True) - ) - tag_utils.tag_with_rules_and_logging(df, [ - {'mask': burn_mask, 'tags': ['Burn']}, - {'mask': pinger_mask & ~exclusion_mask, 'tags': ['Pingers']}, - ], 'burn effects', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error in tag_for_burn: {str(e)}') - raise - -## Clones -def create_clone_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with clone-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have clone text patterns - """ - text_patterns = [ - 'a copy of a creature', - 'a copy of an aura', - 'a copy of a permanent', - 'a token that\'s a copy of', - 'as a copy of', - 'becomes a copy of', - '"legend rule" doesn\'t apply', - 'twice that many of those tokens' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_clone_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with clone-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have clone keywords - """ - return tag_utils.create_keyword_mask(df, 'Myriad') - -def create_clone_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from clone effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - # Add specific exclusion patterns here if needed - return pd.Series(False, index=df.index) - -def tag_for_clones(df: pd.DataFrame, color: str) -> None: - """Tag cards that create copies or have clone effects using vectorized operations. - - This function identifies and tags cards that: - - Create copies of creatures or permanents - - Have copy-related keywords like Myriad - - Ignore the legend rule - - Double token creation - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_clone_text_mask(df) - keyword_mask = create_clone_keyword_mask(df) - exclusion_mask = create_clone_exclusion_mask(df) - final_mask = (text_mask | keyword_mask) & ~exclusion_mask - tag_utils.tag_with_logging( - df, final_mask, ['Clones'], - 'clone effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_clones: {str(e)}') - raise - -## Control -def create_control_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with control-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have control text patterns - """ - text_patterns = [ - 'a player casts', - 'can\'t attack you', - 'cast your first spell during each opponent\'s turn', - 'choose new target', - 'choose target opponent', - 'counter target', - 'of an opponent\'s choice', - 'opponent cast', - 'return target', - 'tap an untapped creature', - 'your opponents cast' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_control_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with control-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have control keywords - """ - keyword_patterns = ['Council\'s dilemma'] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_control_specific_cards_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for specific control-related cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are specific control cards - """ - specific_cards = [ - 'Azor\'s Elocutors', - 'Baral, Chief of Compliance', - 'Dragonlord Ojutai', - 'Grand Arbiter Augustin IV', - 'Lavinia, Azorius Renegade', - 'Talrand, Sky Summoner' - ] - return tag_utils.create_name_mask(df, specific_cards) - -def tag_for_control(df: pd.DataFrame, color: str) -> None: - """Tag cards that fit the Control theme using vectorized operations. - - This function identifies and tags cards that control the game through: - - Counter magic - - Bounce effects - - Tap effects - - Opponent restrictions - - Council's dilemma effects - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_control_text_mask(df) - keyword_mask = create_control_keyword_mask(df) - specific_mask = create_control_specific_cards_mask(df) - final_mask = text_mask | keyword_mask | specific_mask - tag_utils.tag_with_logging( - df, final_mask, ['Control'], - 'control effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_control: {str(e)}') - raise - -## Energy -def tag_for_energy(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about energy counters using vectorized operations. - - This function identifies and tags cards that: - - Use energy counters ({E}) - - Care about energy counters - - Generate or spend energy - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - energy_mask = tag_utils.create_text_mask(df, [r'\{e\}', 'energy counter', 'energy counters']) - tag_utils.tag_with_logging( - df, energy_mask, ['Energy', 'Resource Engine'], 'energy cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_energy: {str(e)}') - raise - -## Infect -def create_infect_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with infect-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have infect text patterns - """ - # Use compiled patterns for regex, plain strings for simple searches - return ( - df['text'].str.contains('one or more counter', case=False, na=False) | - df['text'].str.contains('poison counter', case=False, na=False) | - df['text'].str.contains(rgx.TOXIC.pattern, case=False, na=False, regex=True) - ) - -def create_infect_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with infect-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have infect keywords - """ - keyword_patterns = [ - 'Infect', - 'Proliferate', - 'Toxic', - ] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_infect_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from infect effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - # Add specific exclusion patterns here if needed - return pd.Series(False, index=df.index) - -def tag_for_infect(df: pd.DataFrame, color: str) -> None: - """Tag cards that have infect-related effects using vectorized operations. - - This function identifies and tags cards with infect effects including: - - Infect keyword ability - - Toxic keyword ability - - Proliferate mechanic - - Poison counter effects - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - text_mask = create_infect_text_mask(df) - keyword_mask = create_infect_keyword_mask(df) - exclusion_mask = create_infect_exclusion_mask(df) - final_mask = (text_mask | keyword_mask) & ~exclusion_mask - - tag_utils.tag_with_logging( - df, final_mask, ['Infect'], 'infect cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_infect: {str(e)}') - raise - -## Legends Matter -def create_legends_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with legendary/historic text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have legendary/historic text patterns - """ - text_patterns = [ - 'a legendary creature', - 'another legendary', - 'cast a historic', - 'cast a legendary', - 'cast legendary', - 'equip legendary', - 'historic cards', - 'historic creature', - 'historic permanent', - 'historic spells', - 'legendary creature you control', - 'legendary creatures you control', - 'legendary permanents', - 'legendary spells you', - 'number of legendary', - 'other legendary', - 'play a historic', - 'play a legendary', - 'target legendary', - 'the "legend rule" doesn\'t' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_legends_type_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with Legendary in their type line. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are Legendary - """ - return tag_utils.create_type_mask(df, 'Legendary') - -def tag_for_legends_matter(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about legendary permanents using vectorized operations. - - This function identifies and tags cards that: - - Are legendary permanents - - Care about legendary permanents - - Care about historic spells/permanents - - Modify the legend rule - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'type'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_legends_text_mask(df) - type_mask = create_legends_type_mask(df) - final_mask = text_mask | type_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Historics Matter', 'Legends Matter'], - 'legendary/historic effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_legends_matter: {str(e)}') - raise - -## Little Fellas -def create_little_guys_power_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for creatures with power 2 or less. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have power 2 or less - """ - valid_power = pd.to_numeric(df['power'], errors='coerce') - return (valid_power <= 2) & pd.notna(valid_power) - -def tag_for_little_guys(df: pd.DataFrame, color: str) -> None: - """Tag cards that are or care about low-power creatures using vectorized operations. - - This function identifies and tags: - - Creatures with power 2 or less - - Cards that care about creatures with low power - - Cards that reference power thresholds of 2 or less - - The function handles edge cases like '*' in power values and maintains proper - tag hierarchy. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'power', 'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - power_mask = create_little_guys_power_mask(df) - text_mask = tag_utils.create_text_mask(df, 'power 2 or less') - final_mask = power_mask | text_mask - tag_utils.tag_with_logging( - df, final_mask, ['Little Fellas'], - 'low-power creatures', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_little_guys: {str(e)}') - raise - -## Mill -def create_mill_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with mill-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have mill text patterns - """ - # Create text pattern masks - text_patterns = [ - 'descended', - 'from a graveyard', - 'from your graveyard', - 'in your graveyard', - 'into his or her graveyard', - 'into their graveyard', - 'into your graveyard', - 'mills that many cards', - 'opponent\'s graveyard', - 'put into a graveyard', - 'put into an opponent\'s graveyard', - 'put into your graveyard', - 'rad counter', - 'surveil', - 'would mill' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - # Create mill number patterns using a numbered phrase mask - number_mask_cards = tag_utils.create_numbered_phrase_mask(df, ['mill', 'mills'], noun='cards') - number_mask_plain = tag_utils.create_numbered_phrase_mask(df, ['mill', 'mills']) - - return text_mask | number_mask_cards | number_mask_plain - -def create_mill_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with mill-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have mill keywords - """ - keyword_patterns = ['Descend', 'Mill', 'Surveil'] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def tag_for_mill(df: pd.DataFrame, color: str) -> None: - """Tag cards that mill cards or care about milling using vectorized operations. - - This function identifies and tags cards with mill effects including: - - Direct mill effects (putting cards from library to graveyard) - - Mill-related keywords (Descend, Mill, Surveil) - - Cards that care about graveyards - - Cards that track milled cards - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_mill_text_mask(df) - keyword_mask = create_mill_keyword_mask(df) - final_mask = text_mask | keyword_mask - tag_utils.tag_with_logging( - df, final_mask, ['Mill'], - 'mill effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_mill: {str(e)}') - raise - -def tag_for_monarch(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about the monarch mechanic using vectorized operations. - - This function identifies and tags cards that interact with the monarch mechanic, including: - - Cards that make you become the monarch - - Cards that prevent becoming the monarch - - Cards with monarch-related triggers - - Cards with the monarch keyword - - The function uses vectorized operations for performance and follows patterns - established in other tagging functions. - - Args: - df: DataFrame containing card data with text and keyword columns - color: Color identifier for logging purposes (e.g. 'white', 'blue') - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Combine text and keyword masks - final_mask = tag_utils.build_combined_mask( - df, text_patterns=tag_constants.PHRASE_GROUPS['monarch'], keyword_patterns='Monarch' - ) - tag_utils.tag_with_logging( - df, final_mask, ['Monarch'], 'monarch cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_monarch: {str(e)}') - raise - -## Multi-copy cards -def tag_for_multiple_copies(df: pd.DataFrame, color: str) -> None: - """Tag cards that allow having multiple copies in a deck using vectorized operations. - - This function identifies and tags cards that can have more than 4 copies in a deck, - like Seven Dwarves or Persistent Petitioners. It uses the multiple_copy_cards list - from settings to identify these cards. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'name', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - multiple_copies_mask = tag_utils.create_name_mask(df, MULTIPLE_COPY_CARDS) - if multiple_copies_mask.any(): - matching_cards = df[multiple_copies_mask]['name'].unique() - rules = [{'mask': multiple_copies_mask, 'tags': ['Multiple Copies']}] - # Add per-card rules for individual name tags - rules.extend({'mask': (df['name'] == card_name), 'tags': [card_name]} for card_name in matching_cards) - tag_utils.apply_rules(df, rules=rules) - logger.info(f'Tagged {multiple_copies_mask.sum()} cards with multiple copies effects for {color}') - - except Exception as e: - logger.error(f'Error in tag_for_multiple_copies: {str(e)}') - raise - -## Planeswalkers -def create_planeswalker_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with planeswalker-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have planeswalker text patterns - """ - text_patterns = [ - 'a planeswalker', - 'affinity for planeswalker', - 'enchant planeswalker', - 'historic permanent', - 'legendary permanent', - 'loyalty ability', - 'one or more counter', - 'planeswalker spells', - 'planeswalker type' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_planeswalker_type_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with Planeswalker type. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are Planeswalkers - """ - return tag_utils.create_type_mask(df, 'Planeswalker') - -def create_planeswalker_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with planeswalker-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have planeswalker keywords - """ - return tag_utils.create_keyword_mask(df, 'Proliferate') - -def tag_for_planeswalkers(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about planeswalkers using vectorized operations. - - This function identifies and tags cards that: - - Are planeswalker cards - - Care about planeswalkers - - Have planeswalker-related keywords like Proliferate - - Interact with loyalty abilities - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'type', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_planeswalker_text_mask(df) - type_mask = create_planeswalker_type_mask(df) - keyword_mask = create_planeswalker_keyword_mask(df) - final_mask = text_mask | type_mask | keyword_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Planeswalkers', 'Superfriends'], - 'planeswalker effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_planeswalkers: {str(e)}') - raise - -## Reanimator -def create_reanimator_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with reanimator-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have reanimator text patterns - """ - text_patterns = [ - 'descended', - 'discard your hand', - 'from a graveyard', - 'in a graveyard', - 'into a graveyard', - 'leave a graveyard', - 'in your graveyard', - 'into your graveyard', - 'leave your graveyard' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_reanimator_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with reanimator-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have reanimator keywords - """ - keyword_patterns = [ - 'Blitz', - 'Connive', - 'Descend', - 'Escape', - 'Flashback', - 'Mill' - ] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_reanimator_type_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with reanimator-related creature types. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have reanimator creature types - """ - return df['creatureTypes'].apply(lambda x: 'Zombie' in x if isinstance(x, list) else False) - -def tag_for_reanimate(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about graveyard recursion using vectorized operations. - - This function identifies and tags cards with reanimator effects including: - - Cards that interact with graveyards - - Cards with reanimator-related keywords (Blitz, Connive, etc) - - Cards that loot or mill - - Zombie tribal synergies - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords', 'creatureTypes'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_reanimator_text_mask(df) - keyword_mask = create_reanimator_keyword_mask(df) - type_mask = create_reanimator_type_mask(df) - final_mask = text_mask | keyword_mask | type_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Reanimate'], - 'reanimator effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_reanimate: {str(e)}') - raise - -## Stax -def create_stax_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with stax-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have stax text patterns - """ - return tag_utils.create_text_mask(df, tag_constants.STAX_TEXT_PATTERNS) - -def create_stax_name_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards used in stax strategies. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have stax text patterns - """ - return tag_utils.create_name_mask(df, tag_constants.STAX_SPECIFIC_CARDS) - -def create_stax_tag_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with stax-related tags. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have stax tags - """ - return tag_utils.create_tag_mask(df, 'Control') - -def create_stax_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from stax effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - # Add specific exclusion patterns here if needed - return tag_utils.create_text_mask(df, tag_constants.STAX_EXCLUSION_PATTERNS) - -def tag_for_stax(df: pd.DataFrame, color: str) -> None: - """Tag cards that fit the Stax theme using vectorized operations. - - This function identifies and tags cards that restrict or tax opponents including: - - Cards that prevent actions (can't attack, can't cast, etc) - - Cards that tax actions (spells cost more) - - Cards that control opponents' resources - - Cards that create asymmetric effects - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_stax_text_mask(df) - name_mask = create_stax_name_mask(df) - tag_mask = create_stax_tag_mask(df) - exclusion_mask = create_stax_exclusion_mask(df) - final_mask = (text_mask | tag_mask | name_mask) & ~exclusion_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Stax'], - 'stax effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_stax: {str(e)}') - raise - -## Pillowfort -def tag_for_pillowfort(df: pd.DataFrame, color: str) -> None: - """Tag classic deterrent / taxation defensive permanents as Pillowfort. - - Heuristic: any card that either (a) appears in the specific card list or (b) contains a - deterrent combat pattern in its rules text. Excludes cards already tagged as Stax where - Stax intent is broader; we still allow overlap but do not require it. - """ - try: - required_cols = {'text','themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - final_mask = tag_utils.build_combined_mask( - df, text_patterns=tag_constants.PILLOWFORT_TEXT_PATTERNS, - name_list=tag_constants.PILLOWFORT_SPECIFIC_CARDS - ) - tag_utils.tag_with_logging( - df, final_mask, ['Pillowfort'], 'Pillowfort cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_pillowfort: {e}') - raise - -## Politics -def tag_for_politics(df: pd.DataFrame, color: str) -> None: - """Tag cards that promote table negotiation, shared resources, votes, or gifting. - - Heuristic: match text patterns (vote, each player draws/gains, tempt offers, gifting target opponent, etc.) - plus a curated list of high-signal political commanders / engines. - """ - try: - required_cols = {'text','themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - final_mask = tag_utils.build_combined_mask( - df, text_patterns=tag_constants.POLITICS_TEXT_PATTERNS, - name_list=tag_constants.POLITICS_SPECIFIC_CARDS - ) - tag_utils.tag_with_logging( - df, final_mask, ['Politics'], 'Politics cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_politics: {e}') - raise - -## Control Archetype -## (Control archetype functions removed to avoid duplication; existing tag_for_control covers it) - -## Midrange Archetype -def tag_for_midrange_archetype(df: pd.DataFrame, color: str) -> None: - """Tag resilient, incremental value permanents for Midrange identity.""" - try: - required_cols = {'text','themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - mask = tag_utils.build_combined_mask( - df, text_patterns=tag_constants.MIDRANGE_TEXT_PATTERNS, - name_list=tag_constants.MIDRANGE_SPECIFIC_CARDS - ) - tag_utils.tag_with_logging( - df, mask, ['Midrange'], 'Midrange archetype cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_midrange_archetype: {e}') - raise - -## Toolbox Archetype -def tag_for_toolbox_archetype(df: pd.DataFrame, color: str) -> None: - """Tag tutor / search engine pieces that enable a toolbox plan.""" - try: - required_cols = {'text','themeTags'} - tag_utils.validate_dataframe_columns(df, required_cols) - mask = tag_utils.build_combined_mask( - df, text_patterns=tag_constants.TOOLBOX_TEXT_PATTERNS, - name_list=tag_constants.TOOLBOX_SPECIFIC_CARDS - ) - tag_utils.tag_with_logging( - df, mask, ['Toolbox'], 'Toolbox archetype cards', color=color, logger=logger - ) - except Exception as e: - logger.error(f'Error in tag_for_toolbox_archetype: {e}') - raise - -## Theft -def create_theft_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with theft-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have theft text patterns - """ - return tag_utils.create_text_mask(df, tag_constants.THEFT_TEXT_PATTERNS) - -def create_theft_name_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for specific theft-related cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are specific theft cards - """ - return tag_utils.create_name_mask(df, tag_constants.THEFT_SPECIFIC_CARDS) - -def tag_for_theft(df: pd.DataFrame, color: str) -> None: - """Tag cards that steal or use opponents' resources using vectorized operations. - - This function identifies and tags cards that: - - Cast spells owned by other players - - Take control of permanents - - Use opponents' libraries - - Create theft-related effects - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_theft_text_mask(df) - name_mask = create_theft_name_mask(df) - final_mask = text_mask | name_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Theft'], - 'theft effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_theft: {str(e)}') - raise - -## Toughness Matters -def create_toughness_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with toughness-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have toughness text patterns - """ - text_patterns = [ - 'card\'s toughness', - 'creature\'s toughness', - 'damage equal to its toughness', - 'lesser toughness', - 'total toughness', - 'toughness greater', - 'with defender' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_toughness_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with toughness-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have toughness keywords - """ - return tag_utils.create_keyword_mask(df, 'Defender') - -def _is_valid_numeric_comparison(power: Union[int, str, None], toughness: Union[int, str, None]) -> bool: - """Check if power and toughness values allow valid numeric comparison. - - Args: - power: Power value to check - toughness: Toughness value to check - - Returns: - True if values can be compared numerically, False otherwise - """ - try: - if power is None or toughness is None: - return False - return True - except (ValueError, TypeError): - return False - -def create_power_toughness_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards where toughness exceeds power. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have toughness > power - """ - valid_comparison = df.apply( - lambda row: _is_valid_numeric_comparison(row['power'], row['toughness']), - axis=1 - ) - numeric_mask = valid_comparison & (pd.to_numeric(df['toughness'], errors='coerce') > - pd.to_numeric(df['power'], errors='coerce')) - return numeric_mask - -def tag_for_toughness(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about toughness using vectorized operations. - - This function identifies and tags cards that: - - Reference toughness in their text - - Have the Defender keyword - - Have toughness greater than power - - Care about high toughness values - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords', 'power', 'toughness'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_toughness_text_mask(df) - keyword_mask = create_toughness_keyword_mask(df) - power_toughness_mask = create_power_toughness_mask(df) - final_mask = text_mask | keyword_mask | power_toughness_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Toughness Matters'], - 'toughness effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_toughness: {str(e)}') - raise - -## Topdeck -def create_topdeck_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with topdeck-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have topdeck text patterns - """ - return tag_utils.create_text_mask(df, tag_constants.TOPDECK_TEXT_PATTERNS) - -def create_topdeck_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with topdeck-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have topdeck keywords - """ - return tag_utils.create_keyword_mask(df, tag_constants.TOPDECK_KEYWORDS) - -def create_topdeck_specific_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for specific topdeck-related cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are specific topdeck cards - """ - return tag_utils.create_name_mask(df, tag_constants.TOPDECK_SPECIFIC_CARDS) - -def create_topdeck_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from topdeck effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - return tag_utils.create_text_mask(df, tag_constants.TOPDECK_EXCLUSION_PATTERNS) - -def tag_for_topdeck(df: pd.DataFrame, color: str) -> None: - """Tag cards that manipulate the top of library using vectorized operations. - - This function identifies and tags cards that interact with the top of the library including: - - Cards that look at or reveal top cards - - Cards with scry or surveil effects - - Cards with miracle or similar mechanics - - Cards that care about the order of the library - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_topdeck_text_mask(df) - keyword_mask = create_topdeck_keyword_mask(df) - specific_mask = create_topdeck_specific_mask(df) - exclusion_mask = create_topdeck_exclusion_mask(df) - final_mask = (text_mask | keyword_mask | specific_mask) & ~exclusion_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Topdeck'], - 'topdeck effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_topdeck: {str(e)}') - raise - -## X Spells -def create_x_spells_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with X spell-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have X spell text patterns - """ - # Use compiled patterns for regex, plain strings for simple searches - return ( - df['text'].str.contains(rgx.COST_LESS.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(r"don\'t lose (?:this|unspent|unused)", case=False, na=False, regex=True) | - df['text'].str.contains('unused mana would empty', case=False, na=False) | - df['text'].str.contains(rgx.WITH_X_IN_COST.pattern, case=False, na=False, regex=True) | - df['text'].str.contains(rgx.SPELLS_YOU_CAST_COST.pattern, case=False, na=False, regex=True) - ) - -def create_x_spells_mana_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with X in their mana cost. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have X in mana cost - """ - return df['manaCost'].fillna('').str.contains('{X}', case=True, regex=False) - -def tag_for_x_spells(df: pd.DataFrame, color: str) -> None: - """Tag cards that care about X spells using vectorized operations. - - This function identifies and tags cards that: - - Have X in their mana cost - - Care about X spells or mana values - - Have cost reduction effects for X spells - - Preserve unspent mana - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'manaCost'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_x_spells_text_mask(df) - mana_mask = create_x_spells_mana_mask(df) - final_mask = text_mask | mana_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['X Spells'], - 'X spell effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_x_spells: {str(e)}') - raise - -### Interaction -## Overall tag for interaction group -def tag_for_interaction(df: pd.DataFrame, color: str) -> None: - """Tag cards that interact with the board state or stack. - - This function coordinates tagging of different interaction types including: - - Counterspells - - Board wipes - - Combat tricks - - Protection effects - - Spot removal - - The function maintains proper tag hierarchy and ensures consistent application - of interaction-related tags. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - start_time = pd.Timestamp.now() - logger.info(f'Starting interaction effect tagging for {color}_cards.csv') - print('\n==========\n') - - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'name', 'type', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Process each type of interaction - sub_start = pd.Timestamp.now() - tag_for_counterspells(df, color) - logger.info(f'Completed counterspell tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') - print('\n==========\n') - - sub_start = pd.Timestamp.now() - tag_for_board_wipes(df, color) - logger.info(f'Completed board wipe tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') - print('\n==========\n') - - sub_start = pd.Timestamp.now() - tag_for_combat_tricks(df, color) - logger.info(f'Completed combat trick tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') - print('\n==========\n') - - sub_start = pd.Timestamp.now() - tag_for_protection(df, color) - logger.info(f'Completed protection tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') - print('\n==========\n') - - sub_start = pd.Timestamp.now() - tag_for_phasing(df, color) - logger.info(f'Completed phasing tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') - print('\n==========\n') - - sub_start = pd.Timestamp.now() - tag_for_removal(df, color) - logger.info(f'Completed removal tagging in {(pd.Timestamp.now() - sub_start).total_seconds():.2f}s') - print('\n==========\n') - duration = pd.Timestamp.now() - start_time - logger.info(f'Completed all interaction tagging in {duration.total_seconds():.2f}s') - - except Exception as e: - logger.error(f'Error in tag_for_interaction: {str(e)}') - raise - -## Counterspells -def create_counterspell_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with counterspell text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have counterspell text patterns - """ - return tag_utils.create_text_mask(df, tag_constants.COUNTERSPELL_TEXT_PATTERNS) - -def create_counterspell_specific_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for specific counterspell cards. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are specific counterspell cards - """ - return tag_utils.create_name_mask(df, tag_constants.COUNTERSPELL_SPECIFIC_CARDS) - -def create_counterspell_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from counterspell effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - return tag_utils.create_text_mask(df, tag_constants.COUNTERSPELL_EXCLUSION_PATTERNS) - -def tag_for_counterspells(df: pd.DataFrame, color: str) -> None: - """Tag cards that counter spells using vectorized operations. - - This function identifies and tags cards that: - - Counter spells directly - - Return spells to hand/library - - Exile spells from the stack - - Care about countering spells - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - """ - try: - required_cols = {'text', 'themeTags', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_counterspell_text_mask(df) - specific_mask = create_counterspell_specific_mask(df) - exclusion_mask = create_counterspell_exclusion_mask(df) - final_mask = (text_mask | specific_mask) & ~exclusion_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Counterspells', 'Interaction', 'Spellslinger', 'Spells Matter'], - 'counterspell effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_counterspells: {str(e)}') - raise - -## Board Wipes -def tag_for_board_wipes(df: pd.DataFrame, color: str) -> None: - """Tag cards that have board wipe effects using vectorized operations. - - This function identifies and tags cards with board wipe effects including: - - Mass destruction effects (destroy all/each) - - Mass exile effects (exile all/each) - - Mass bounce effects (return all/each) - - Mass sacrifice effects (sacrifice all/each) - - Mass damage effects (damage to all/each) - - The function uses helper functions to identify different types of board wipes - and applies tags consistently using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'name'} - tag_utils.validate_dataframe_columns(df, required_cols) - destroy_mask = tag_utils.create_mass_effect_mask(df, 'mass_destruction') - exile_mask = tag_utils.create_mass_effect_mask(df, 'mass_exile') - bounce_mask = tag_utils.create_mass_effect_mask(df, 'mass_bounce') - sacrifice_mask = tag_utils.create_mass_effect_mask(df, 'mass_sacrifice') - damage_mask = tag_utils.create_mass_damage_mask(df) - - # Create exclusion mask - exclusion_mask = tag_utils.create_text_mask(df, tag_constants.BOARD_WIPE_EXCLUSION_PATTERNS) - - # Create specific cards mask - specific_mask = tag_utils.create_name_mask(df, tag_constants.BOARD_WIPE_SPECIFIC_CARDS) - final_mask = ( - destroy_mask | exile_mask | bounce_mask | - sacrifice_mask | damage_mask | specific_mask - ) & ~exclusion_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Board Wipes', 'Interaction'], - 'board wipe effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_board_wipes: {str(e)}') - raise - - logger.info(f'Completed board wipe tagging for {color}_cards.csv') - -## Combat Tricks -def create_combat_tricks_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with combat trick text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have combat trick text patterns - """ - # Numeric buff patterns (handles +N/+N, +N/+0, 0/+N, and negatives; N can be digits or X) - buff_regex = r'\bget(?:s)?\s+[+\-]?(?:\d+|X)\s*/\s*[+\-]?(?:\d+|X)\b' - - # Base power/toughness setting patterns (e.g., "has base power and toughness 3/3") - base_pt_regex = r'\b(?:has|with)\s+base\s+power\s+and\s+toughness\s+[+\-]?(?:\d+|X)\s*/\s*[+\-]?(?:\d+|X)\b' - - other_patterns = [ - buff_regex, - base_pt_regex, - 'bolster', - 'double strike', - 'first strike', - 'untap all creatures', - 'untap target creature', - ] - - return tag_utils.create_text_mask(df, other_patterns) - -def create_combat_tricks_type_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for instant-speed combat tricks. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards are instant-speed combat tricks - """ - return tag_utils.create_type_mask(df, 'Instant') - -def create_combat_tricks_flash_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for flash-based combat tricks. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have flash-based combat tricks - """ - return tag_utils.create_keyword_mask(df, 'Flash') - -def create_combat_tricks_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from combat tricks. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - # Specific cards to exclude - excluded_cards = [ - 'Assimilate Essence', - 'Mantle of Leadership', - 'Michiko\'s Reign of Truth // Portrait of Michiko' - ] - name_mask = tag_utils.create_name_mask(df, excluded_cards) - - # Text patterns to exclude - text_patterns = [ - 'remains tapped', - 'only as a sorcery' - ] - text_mask = tag_utils.create_text_mask(df, text_patterns) - - return name_mask | text_mask - -def tag_for_combat_tricks(df: pd.DataFrame, color: str) -> None: - """Tag cards that function as combat tricks using vectorized operations. - - This function identifies and tags cards that modify combat through: - - Power/toughness buffs at instant speed - - Flash creatures and enchantments with combat effects - - Tap abilities that modify power/toughness - - Combat-relevant keywords and abilities - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'type', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_combat_tricks_text_mask(df) - type_mask = create_combat_tricks_type_mask(df) - flash_mask = create_combat_tricks_flash_mask(df) - exclusion_mask = create_combat_tricks_exclusion_mask(df) - final_mask = ((text_mask & (type_mask | flash_mask)) | - (flash_mask & tag_utils.create_type_mask(df, 'Enchantment'))) & ~exclusion_mask - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Combat Tricks', 'Interaction'], - 'combat trick effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_combat_tricks: {str(e)}') - raise - -## Protection/Safety spells -def create_protection_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with protection-related text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have protection text patterns - """ - text_patterns = [ - 'has indestructible', - 'has protection', - 'has shroud', - 'has ward', - 'have indestructible', - 'have protection', - 'have shroud', - 'have ward', - 'hexproof from', - 'gain hexproof', - 'gain indestructible', - 'gain protection', - 'gain shroud', - 'gain ward', - 'gains hexproof', - 'gains indestructible', - 'gains protection', - 'gains shroud', - 'gains ward', - 'phases out', - 'protection from' - ] - return tag_utils.create_text_mask(df, text_patterns) - -def create_protection_keyword_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with protection-related keywords. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have protection keywords - """ - keyword_patterns = [ - 'Hexproof', - 'Indestructible', - 'Protection', - 'Shroud', - 'Ward' - ] - return tag_utils.create_keyword_mask(df, keyword_patterns) - -def create_protection_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from protection effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - excluded_cards = [ - 'Out of Time', - 'The War Doctor' - ] - return tag_utils.create_name_mask(df, excluded_cards) - -def _identify_protection_granting_cards(df: pd.DataFrame) -> pd.Series: - """Identify cards that grant protection to other permanents. - - Args: - df: DataFrame containing card data - - Returns: - Boolean Series indicating which cards grant protection - """ - from code.tagging.protection_grant_detection import is_granting_protection - - grant_mask = df.apply( - lambda row: is_granting_protection( - str(row.get('text', '')), - str(row.get('keywords', '')) - ), - axis=1 - ) - return grant_mask - - -def _apply_kindred_protection_tags(df: pd.DataFrame, grant_mask: pd.Series) -> int: - """Apply creature-type-specific protection tags. - - Args: - df: DataFrame containing card data - grant_mask: Boolean Series indicating which cards grant protection - - Returns: - Number of cards tagged with kindred protection - """ - from code.tagging.protection_grant_detection import get_kindred_protection_tags - - kindred_count = 0 - for idx, row in df[grant_mask].iterrows(): - text = str(row.get('text', '')) - kindred_tags = get_kindred_protection_tags(text) - - if kindred_tags: - current_tags = row.get('themeTags', []) - if not isinstance(current_tags, list): - current_tags = [] - - updated_tags = list(set(current_tags) | set(kindred_tags)) - df.at[idx, 'themeTags'] = updated_tags - kindred_count += 1 - - return kindred_count - - -def _apply_protection_scope_tags(df: pd.DataFrame) -> int: - """Apply scope metadata tags (Self, Your Permanents, Blanket, Opponent). - - Applies to ALL cards with protection effects, not just those that grant protection. - - Args: - df: DataFrame containing card data - - Returns: - Number of cards tagged with scope metadata - """ - from code.tagging.protection_scope_detection import get_protection_scope_tags, has_any_protection - - scope_count = 0 - for idx, row in df.iterrows(): - text = str(row.get('text', '')) - name = str(row.get('name', '')) - keywords = str(row.get('keywords', '')) - - # Check if card has ANY protection effects - if not has_any_protection(text) and not any(k in keywords.lower() for k in ['hexproof', 'shroud', 'indestructible', 'ward', 'protection', 'phasing']): - continue - - scope_tags = get_protection_scope_tags(text, name, keywords) - - if scope_tags: - current_tags = row.get('themeTags', []) - if not isinstance(current_tags, list): - current_tags = [] - - updated_tags = list(set(current_tags) | set(scope_tags)) - df.at[idx, 'themeTags'] = updated_tags - scope_count += 1 - - return scope_count - - -def _get_all_protection_mask(df: pd.DataFrame) -> pd.Series: - """Build mask for ALL cards with protection keywords (granting or inherent). - - Args: - df: DataFrame containing card data - - Returns: - Boolean Series indicating which cards have protection keywords - """ - text_series = tag_utils._ensure_norm_series(df, 'text', '__text_s') - keywords_series = tag_utils._ensure_norm_series(df, 'keywords', '__keywords_s') - - all_protection_mask = ( - text_series.str.contains('hexproof|shroud|indestructible|ward|protection from|protection|phasing', case=False, regex=True, na=False) | - keywords_series.str.contains('hexproof|shroud|indestructible|ward|protection|phasing', case=False, regex=True, na=False) - ) - return all_protection_mask - - -def _apply_specific_protection_ability_tags(df: pd.DataFrame, all_protection_mask: pd.Series) -> int: - """Apply specific protection ability tags (Hexproof, Indestructible, etc.). - - Args: - df: DataFrame containing card data - all_protection_mask: Boolean Series indicating cards with protection - - Returns: - Number of cards tagged with specific abilities - """ - ability_tag_count = 0 - for idx, row in df[all_protection_mask].iterrows(): - text = str(row.get('text', '')) - keywords = str(row.get('keywords', '')) - - ability_tags = set() - text_lower = text.lower() - keywords_lower = keywords.lower() - - # Check for each protection ability - if 'hexproof' in text_lower or 'hexproof' in keywords_lower: - ability_tags.add('Hexproof') - if 'indestructible' in text_lower or 'indestructible' in keywords_lower: - ability_tags.add('Indestructible') - if 'shroud' in text_lower or 'shroud' in keywords_lower: - ability_tags.add('Shroud') - if 'ward' in text_lower or 'ward' in keywords_lower: - ability_tags.add('Ward') - - # Distinguish types of protection - if 'protection from' in text_lower or 'protection from' in keywords_lower: - # Check for color protection - if any(color in text_lower or color in keywords_lower for color in ['white', 'blue', 'black', 'red', 'green', 'multicolored', 'monocolored', 'colorless', 'each color', 'all colors', 'the chosen color', 'a color']): - ability_tags.add('Protection from Color') - # Check for creature type protection - elif 'protection from creatures' in text_lower or 'protection from creatures' in keywords_lower: - ability_tags.add('Protection from Creatures') - elif any(ctype.lower() in text_lower for ctype in ['Dragons', 'Zombies', 'Vampires', 'Demons', 'Humans', 'Elves', 'Goblins', 'Werewolves']): - ability_tags.add('Protection from Creature Type') - else: - ability_tags.add('Protection from Quality') - - if ability_tags: - current_tags = row.get('themeTags', []) - if not isinstance(current_tags, list): - current_tags = [] - - updated_tags = list(set(current_tags) | ability_tags) - df.at[idx, 'themeTags'] = updated_tags - ability_tag_count += 1 - - return ability_tag_count - - -def tag_for_protection(df: pd.DataFrame, color: str) -> None: - """Tag cards that provide or have protection effects using vectorized operations. - - This function identifies and tags cards with protection effects including: - - Indestructible - - Protection from [quality] - - Hexproof/Shroud - - Ward - - Phase out - - With TAG_PROTECTION_GRANTS=1, only tags cards that grant protection to other - permanents, filtering out cards with inherent protection. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - - # Check if grant detection is enabled (M2 feature flag) - use_grant_detection = os.getenv('TAG_PROTECTION_GRANTS', '1').lower() in ('1', 'true', 'yes') - - if use_grant_detection: - # M2: Use grant detection to filter out inherent-only protection - final_mask = _identify_protection_granting_cards(df) - logger.info('Using M2 grant detection (TAG_PROTECTION_GRANTS=1)') - - # Apply kindred metadata tags for creature-type-specific grants - kindred_count = _apply_kindred_protection_tags(df, final_mask) - if kindred_count > 0: - logger.info(f'Applied kindred protection tags to {kindred_count} cards (will be moved to metadata by partition)') - - # M5: Add protection scope metadata tags - scope_count = _apply_protection_scope_tags(df) - if scope_count > 0: - logger.info(f'Applied protection scope tags to {scope_count} cards (will be moved to metadata by partition)') - else: - # Legacy: Use original text/keyword patterns - text_mask = create_protection_text_mask(df) - keyword_mask = create_protection_keyword_mask(df) - exclusion_mask = create_protection_exclusion_mask(df) - final_mask = (text_mask | keyword_mask) & ~exclusion_mask - - # Build comprehensive mask for ALL cards with protection keywords - all_protection_mask = _get_all_protection_mask(df) - - # Apply generic 'Protective Effects' tag to ALL cards with protection - tag_utils.apply_rules(df, rules=[ - {'mask': all_protection_mask, 'tags': ['Protective Effects']} - ]) - - # Apply 'Interaction' tag ONLY to cards that GRANT protection - tag_utils.apply_rules(df, rules=[ - {'mask': final_mask, 'tags': ['Interaction']} - ]) - - # Apply specific protection ability tags - ability_tag_count = _apply_specific_protection_ability_tags(df, all_protection_mask) - if ability_tag_count > 0: - logger.info(f'Applied specific protection ability tags to {ability_tag_count} cards') - - # Log results - logger.info(f'Tagged {final_mask.sum()} cards with protection effects for {color}') - - except Exception as e: - logger.error(f'Error in tag_for_protection: {str(e)}') - raise - -## Phasing effects -def tag_for_phasing(df: pd.DataFrame, color: str) -> None: - """Tag cards that provide phasing effects using vectorized operations. - - This function identifies and tags cards with phasing effects including: - - Cards that phase permanents out - - Cards with phasing keyword - - Similar to M5 protection tagging, adds scope metadata tags: - - Self: Phasing (card phases itself out) - - Your Permanents: Phasing (phases your permanents out) - - Blanket: Phasing (phases all permanents out) - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - from code.tagging.phasing_scope_detection import has_phasing, get_phasing_scope_tags, is_removal_phasing - - phasing_mask = df.apply( - lambda row: has_phasing(str(row.get('text', ''))) or - 'phasing' in str(row.get('keywords', '')).lower(), - axis=1 - ) - - # Apply generic "Phasing" theme tag first - tag_utils.apply_rules(df, rules=[ - { - 'mask': phasing_mask, - 'tags': ['Phasing', 'Interaction'] - } - ]) - - # Add phasing scope metadata tags and removal tags - scope_count = 0 - removal_count = 0 - for idx, row in df[phasing_mask].iterrows(): - text = str(row.get('text', '')) - name = str(row.get('name', '')) - keywords = str(row.get('keywords', '')) - - # Check if card has phasing (in text or keywords) - if not has_phasing(text) and 'phasing' not in keywords.lower(): - continue - - scope_tags = get_phasing_scope_tags(text, name, keywords) - - if scope_tags: - current_tags = row.get('themeTags', []) - if not isinstance(current_tags, list): - current_tags = [] - - # Add scope tags to themeTags (partition will move to metadataTags) - updated_tags = list(set(current_tags) | scope_tags) - - # If this is removal-style phasing, add Removal tag - if is_removal_phasing(scope_tags): - updated_tags.append('Removal') - removal_count += 1 - - df.at[idx, 'themeTags'] = updated_tags - scope_count += 1 - - if scope_count > 0: - logger.info(f'Applied phasing scope tags to {scope_count} cards (will be moved to metadata by partition)') - if removal_count > 0: - logger.info(f'Applied Removal tag to {removal_count} cards with opponent-targeting phasing') - - # Log results - logger.info(f'Tagged {phasing_mask.sum()} cards with phasing effects for {color}') - - except Exception as e: - logger.error(f'Error in tag_for_phasing: {str(e)}') - raise - -## Spot removal -def create_removal_text_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards with removal text patterns. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards have removal text patterns - """ - return tag_utils.create_text_mask(df, tag_constants.REMOVAL_TEXT_PATTERNS) - -def create_removal_exclusion_mask(df: pd.DataFrame) -> pd.Series: - """Create a boolean mask for cards that should be excluded from removal effects. - - Args: - df: DataFrame to search - - Returns: - Boolean Series indicating which cards should be excluded - """ - return tag_utils.create_text_mask(df, tag_constants.REMOVAL_EXCLUSION_PATTERNS) - - -def tag_for_removal(df: pd.DataFrame, color: str) -> None: - """Tag cards that provide spot removal using vectorized operations. - - This function identifies and tags cards that remove permanents through: - - Destroy effects - - Exile effects - - Bounce effects - - Sacrifice effects - - The function uses helper functions to identify different types of removal - and applies tags consistently using vectorized operations. - - Args: - df: DataFrame containing card data - color: Color identifier for logging purposes - - Raises: - ValueError: If required DataFrame columns are missing - TypeError: If inputs are not of correct type - """ - try: - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame") - if not isinstance(color, str): - raise TypeError("color must be a string") - required_cols = {'text', 'themeTags', 'keywords'} - tag_utils.validate_dataframe_columns(df, required_cols) - text_mask = create_removal_text_mask(df) - exclude_mask = create_removal_exclusion_mask(df) - - # Combine masks (and exclude self-targeting effects like 'target permanent you control') - final_mask = text_mask & (~exclude_mask) - - # Apply tags via utility - tag_utils.tag_with_logging( - df, final_mask, ['Removal', 'Interaction'], - 'removal effects', color=color, logger=logger - ) - - except Exception as e: - logger.error(f'Error in tag_for_removal: {str(e)}') - raise - -def run_tagging(parallel: bool = False, max_workers: int | None = None): - """Run tagging across all COLORS. - - Args: - parallel: If True, process colors in parallel using multiple processes. - max_workers: Optional cap on worker processes. - """ - start_time = pd.Timestamp.now() - - if parallel and DFC_PER_FACE_SNAPSHOT: - logger.warning("DFC_PER_FACE_SNAPSHOT=1 detected; per-face metadata snapshots require sequential tagging. Parallel run will skip snapshot emission.") - - if parallel: - try: - import concurrent.futures as _f - # Use processes to bypass GIL; each color reads/writes distinct CSV - with _f.ProcessPoolExecutor(max_workers=max_workers) as ex: - futures = {ex.submit(load_dataframe, color): color for color in COLORS} - for fut in _f.as_completed(futures): - color = futures[fut] - try: - fut.result() - except Exception as e: - logger.error(f'Parallel worker failed for {color}: {e}') - raise - except Exception: - # Fallback to sequential on any multiprocessing setup error - logger.warning('Parallel mode failed to initialize; falling back to sequential.') - for color in COLORS: - load_dataframe(color) - else: - for color in COLORS: - load_dataframe(color) - - _flush_per_face_snapshot() - duration = (pd.Timestamp.now() - start_time).total_seconds() - logger.info(f'Tagged cards in {duration:.2f}s') diff --git a/code/tagging/parallel_utils.py b/code/tagging/parallel_utils.py deleted file mode 100644 index 85288c6..0000000 --- a/code/tagging/parallel_utils.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Utilities for parallel card tagging operations. - -This module provides functions to split DataFrames by color identity for -parallel processing and merge them back together. This enables the tagging -system to use ProcessPoolExecutor for significant performance improvements -while maintaining the unified Parquet approach. -""" - -from __future__ import annotations - -from typing import Dict -import pandas as pd -import logging_util - -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - - -def split_by_color_identity(df: pd.DataFrame) -> Dict[str, pd.DataFrame]: - """Split DataFrame into color identity groups for parallel processing. - - Each color identity group is a separate DataFrame that can be tagged - independently. This function preserves all columns and ensures no cards - are lost during the split. - - Color identity groups are based on the 'colorIdentity' column which contains - strings like 'W', 'WU', 'WUB', 'WUBRG', etc. - - Args: - df: DataFrame containing all cards with 'colorIdentity' column - - Returns: - Dictionary mapping color identity strings to DataFrames - Example: {'W': df_white, 'WU': df_azorius, '': df_colorless, ...} - - Raises: - ValueError: If 'colorIdentity' column is missing - """ - if 'colorIdentity' not in df.columns: - raise ValueError("DataFrame must have 'colorIdentity' column for parallel splitting") - - # Group by color identity - groups: Dict[str, pd.DataFrame] = {} - - for color_id, group_df in df.groupby('colorIdentity', dropna=False): - # Handle NaN/None as colorless - if pd.isna(color_id): - color_id = '' - - # Convert to string (in case it's already a string, this is safe) - color_id_str = str(color_id) - - # Create a copy to avoid SettingWithCopyWarning in parallel workers - groups[color_id_str] = group_df.copy() - - logger.debug(f"Split group '{color_id_str}': {len(group_df)} cards") - - # Verify split is complete - total_split = sum(len(group_df) for group_df in groups.values()) - if total_split != len(df): - logger.warning( - f"Split verification failed: {total_split} cards in groups vs {len(df)} original. " - f"Some cards may be missing!" - ) - else: - logger.info(f"Split {len(df)} cards into {len(groups)} color identity groups") - - return groups - - -def merge_color_groups(groups: Dict[str, pd.DataFrame]) -> pd.DataFrame: - """Merge tagged color identity groups back into a single DataFrame. - - This function concatenates all color group DataFrames and ensures: - - All columns are preserved - - No duplicate cards (by index) - - Proper index handling - - Consistent column ordering - - Args: - groups: Dictionary mapping color identity strings to tagged DataFrames - - Returns: - Single DataFrame containing all tagged cards - - Raises: - ValueError: If groups is empty or contains invalid DataFrames - """ - if not groups: - raise ValueError("Cannot merge empty color groups") - - # Verify all values are DataFrames - for color_id, group_df in groups.items(): - if not isinstance(group_df, pd.DataFrame): - raise ValueError(f"Group '{color_id}' is not a DataFrame: {type(group_df)}") - - # Concatenate all groups - # ignore_index=False preserves original indices - # sort=False maintains column order from first DataFrame - merged_df = pd.concat(groups.values(), ignore_index=False, sort=False) - - # Check for duplicate indices (shouldn't happen if split was lossless) - if merged_df.index.duplicated().any(): - logger.warning( - f"Found {merged_df.index.duplicated().sum()} duplicate indices after merge. " - f"This may indicate a bug in the split/merge process." - ) - # Remove duplicates (keep first occurrence) - merged_df = merged_df[~merged_df.index.duplicated(keep='first')] - - # Verify merge is complete - total_merged = len(merged_df) - total_groups = sum(len(group_df) for group_df in groups.values()) - - if total_merged != total_groups: - logger.warning( - f"Merge verification failed: {total_merged} cards in result vs {total_groups} in groups. " - f"Lost {total_groups - total_merged} cards!" - ) - else: - logger.info(f"Merged {len(groups)} color groups into {total_merged} cards") - - # Reset index to ensure clean sequential indexing - merged_df = merged_df.reset_index(drop=True) - - return merged_df - - -__all__ = [ - 'split_by_color_identity', - 'merge_color_groups', -] diff --git a/code/tagging/tag_constants.py b/code/tagging/tag_constants.py index ec97bda..b197fc5 100644 --- a/code/tagging/tag_constants.py +++ b/code/tagging/tag_constants.py @@ -1072,9 +1072,6 @@ METADATA_TAG_ALLOWLIST: set[str] = { # Cost reduction diagnostics (from Applied: namespace) 'Applied: Cost Reduction', - # Colorless commander filtering (M1) - 'Useless in Colorless', - # Kindred-specific protection metadata (from M2) # Format: "{CreatureType}s Gain Protection" # These are auto-generated for kindred-specific protection grants diff --git a/code/tagging/tag_index.py b/code/tagging/tag_index.py deleted file mode 100644 index 19c3de8..0000000 --- a/code/tagging/tag_index.py +++ /dev/null @@ -1,425 +0,0 @@ -"""Fast tag indexing for reverse lookups and bulk operations. - -Provides a reverse index (tag → cards) for efficient tag-based queries. -Typical queries complete in <1ms after index is built. - -Usage: - # Build index from all_cards - index = TagIndex() - index.build() - - # Query cards with specific tag - cards = index.get_cards_with_tag("ramp") # Returns set of card names - - # Query cards with multiple tags (AND logic) - cards = index.get_cards_with_all_tags(["tokens", "sacrifice"]) - - # Query cards with any of several tags (OR logic) - cards = index.get_cards_with_any_tags(["lifegain", "lifelink"]) - - # Get tags for a specific card - tags = index.get_tags_for_card("Sol Ring") -""" -from __future__ import annotations - -import json -import os -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, List, Set, Optional - -from code.logging_util import get_logger -from code.services.all_cards_loader import AllCardsLoader - -logger = get_logger(__name__) - -# Default cache path for persisted index -DEFAULT_CACHE_PATH = Path("card_files/.tag_index_metadata.json") - - -@dataclass -class IndexStats: - """Statistics about the tag index.""" - total_cards: int - total_tags: int - total_mappings: int - build_time_seconds: float - indexed_at: float # Unix timestamp - all_cards_mtime: float # Unix timestamp of source file - - -class TagIndex: - """Fast reverse index for tag-based card queries. - - Builds two indexes: - - tag → set(card names) - Reverse index for fast tag queries - - card → list(tags) - Forward index for card tag lookups - - Performance: - - Index build: <5s for 50k cards - - Query time: <1ms per lookup - - Memory: ~50-100MB for 30k cards - """ - - def __init__(self, cache_path: Optional[Path] = None): - """Initialize empty tag index. - - Args: - cache_path: Path to persist index (default: card_files/.tag_index_metadata.json) - """ - self._tag_to_cards: Dict[str, Set[str]] = {} - self._card_to_tags: Dict[str, List[str]] = {} - self._stats: Optional[IndexStats] = None - self._cache_path = cache_path or DEFAULT_CACHE_PATH - self._loader = AllCardsLoader() - - def build(self, force_rebuild: bool = False) -> IndexStats: - """Build the tag index from all_cards. - - Loads all_cards and creates reverse index. If a cached index exists - and is up-to-date, loads from cache instead. - - Args: - force_rebuild: If True, rebuild even if cache is valid - - Returns: - IndexStats with build metrics - """ - # Check if we can use cached index - if not force_rebuild and self._try_load_from_cache(): - logger.info(f"Loaded tag index from cache: {self._stats.total_cards} cards, {self._stats.total_tags} tags") - return self._stats - - logger.info("Building tag index from all_cards...") - start_time = time.perf_counter() - - # Load all cards - df = self._loader.load() - - if "themeTags" not in df.columns: - logger.warning("themeTags column not found in all_cards") - self._stats = IndexStats( - total_cards=0, - total_tags=0, - total_mappings=0, - build_time_seconds=0, - indexed_at=time.time(), - all_cards_mtime=0 - ) - return self._stats - - # Clear existing indexes - self._tag_to_cards.clear() - self._card_to_tags.clear() - - # Build indexes - total_mappings = 0 - for _, row in df.iterrows(): - name = row.get("name") - if not name: - continue - - tags = self._normalize_tags(row.get("themeTags", [])) - if not tags: - continue - - # Store forward mapping (card → tags) - self._card_to_tags[name] = tags - - # Build reverse mapping (tag → cards) - for tag in tags: - if tag not in self._tag_to_cards: - self._tag_to_cards[tag] = set() - self._tag_to_cards[tag].add(name) - total_mappings += 1 - - build_time = time.perf_counter() - start_time - - # Get all_cards mtime for cache validation - all_cards_mtime = 0 - if os.path.exists(self._loader.file_path): - all_cards_mtime = os.path.getmtime(self._loader.file_path) - - self._stats = IndexStats( - total_cards=len(self._card_to_tags), - total_tags=len(self._tag_to_cards), - total_mappings=total_mappings, - build_time_seconds=build_time, - indexed_at=time.time(), - all_cards_mtime=all_cards_mtime - ) - - logger.info( - f"Built tag index: {self._stats.total_cards} cards, " - f"{self._stats.total_tags} unique tags, " - f"{self._stats.total_mappings} mappings in {build_time:.2f}s" - ) - - # Save to cache - self._save_to_cache() - - return self._stats - - def _normalize_tags(self, tags: object) -> List[str]: - """Normalize tags from various formats to list of strings. - - Handles: - - List of strings/objects - - String representations like "['tag1', 'tag2']" - - Comma-separated strings - - Empty/None values - """ - if not tags: - return [] - - if isinstance(tags, list): - # Already a list - normalize to strings - return [str(t).strip() for t in tags if t and str(t).strip()] - - if isinstance(tags, str): - # Handle empty or list repr - if not tags or tags == "[]": - return [] - - # Try parsing as list repr - if tags.startswith("["): - import ast - try: - parsed = ast.literal_eval(tags) - if isinstance(parsed, list): - return [str(t).strip() for t in parsed if t and str(t).strip()] - except (ValueError, SyntaxError): - pass - - # Fall back to comma-separated - return [t.strip() for t in tags.split(",") if t.strip()] - - return [] - - def get_cards_with_tag(self, tag: str) -> Set[str]: - """Get all card names that have a specific tag. - - Args: - tag: Theme tag to search for (case-sensitive) - - Returns: - Set of card names with the tag (empty if tag not found) - - Performance: O(1) lookup after index is built - """ - return self._tag_to_cards.get(tag, set()).copy() - - def get_cards_with_all_tags(self, tags: List[str]) -> Set[str]: - """Get cards that have ALL specified tags (AND logic). - - Args: - tags: List of tags (card must have all of them) - - Returns: - Set of card names with all tags (empty if no matches) - - Performance: O(k) where k is number of tags - """ - if not tags: - return set() - - # Start with cards for first tag - result = self.get_cards_with_tag(tags[0]) - - # Intersect with cards for each additional tag - for tag in tags[1:]: - result &= self.get_cards_with_tag(tag) - if not result: - # Short-circuit if no cards remain - break - - return result - - def get_cards_with_any_tags(self, tags: List[str]) -> Set[str]: - """Get cards that have ANY of the specified tags (OR logic). - - Args: - tags: List of tags (card needs at least one) - - Returns: - Set of card names with at least one tag - - Performance: O(k) where k is number of tags - """ - result: Set[str] = set() - for tag in tags: - result |= self.get_cards_with_tag(tag) - return result - - def get_tags_for_card(self, card_name: str) -> List[str]: - """Get all tags for a specific card. - - Args: - card_name: Name of the card - - Returns: - List of theme tags for the card (empty if not found) - - Performance: O(1) lookup - """ - return self._card_to_tags.get(card_name, []).copy() - - def get_all_tags(self) -> List[str]: - """Get list of all tags in the index. - - Returns: - Sorted list of all unique tags - """ - return sorted(self._tag_to_cards.keys()) - - def get_tag_stats(self, tag: str) -> Dict[str, int]: - """Get statistics for a specific tag. - - Args: - tag: Tag to get stats for - - Returns: - Dict with 'card_count' key - """ - return { - "card_count": len(self._tag_to_cards.get(tag, set())) - } - - def get_popular_tags(self, limit: int = 50) -> List[tuple[str, int]]: - """Get most popular tags sorted by card count. - - Args: - limit: Maximum number of tags to return - - Returns: - List of (tag, card_count) tuples sorted by count descending - """ - tag_counts = [ - (tag, len(cards)) - for tag, cards in self._tag_to_cards.items() - ] - tag_counts.sort(key=lambda x: x[1], reverse=True) - return tag_counts[:limit] - - def _save_to_cache(self) -> None: - """Save index to cache file.""" - if not self._stats: - return - - try: - cache_data = { - "stats": { - "total_cards": self._stats.total_cards, - "total_tags": self._stats.total_tags, - "total_mappings": self._stats.total_mappings, - "build_time_seconds": self._stats.build_time_seconds, - "indexed_at": self._stats.indexed_at, - "all_cards_mtime": self._stats.all_cards_mtime - }, - "tag_to_cards": { - tag: list(cards) - for tag, cards in self._tag_to_cards.items() - }, - "card_to_tags": self._card_to_tags - } - - self._cache_path.parent.mkdir(parents=True, exist_ok=True) - with self._cache_path.open("w", encoding="utf-8") as f: - json.dump(cache_data, f, indent=2) - - logger.debug(f"Saved tag index cache to {self._cache_path}") - - except Exception as e: - logger.warning(f"Failed to save tag index cache: {e}") - - def _try_load_from_cache(self) -> bool: - """Try to load index from cache file. - - Returns: - True if cache loaded successfully and is up-to-date - """ - if not self._cache_path.exists(): - return False - - try: - with self._cache_path.open("r", encoding="utf-8") as f: - cache_data = json.load(f) - - # Check if cache is up-to-date - stats_data = cache_data.get("stats", {}) - cached_mtime = stats_data.get("all_cards_mtime", 0) - - current_mtime = 0 - if os.path.exists(self._loader.file_path): - current_mtime = os.path.getmtime(self._loader.file_path) - - if current_mtime > cached_mtime: - logger.debug("Tag index cache outdated (all_cards modified)") - return False - - # Load indexes - self._tag_to_cards = { - tag: set(cards) - for tag, cards in cache_data.get("tag_to_cards", {}).items() - } - self._card_to_tags = cache_data.get("card_to_tags", {}) - - # Restore stats - self._stats = IndexStats(**stats_data) - - return True - - except Exception as e: - logger.warning(f"Failed to load tag index cache: {e}") - return False - - def clear_cache(self) -> None: - """Delete the cached index file.""" - if self._cache_path.exists(): - self._cache_path.unlink() - logger.debug(f"Deleted tag index cache: {self._cache_path}") - - def get_stats(self) -> Optional[IndexStats]: - """Get index statistics. - - Returns: - IndexStats if index has been built, None otherwise - """ - return self._stats - - -# Global index instance -_global_index: Optional[TagIndex] = None - - -def get_tag_index(force_rebuild: bool = False) -> TagIndex: - """Get or create the global tag index. - - Lazy-loads the index on first access. Subsequent calls return - the cached instance. - - Args: - force_rebuild: If True, rebuild the index even if cached - - Returns: - Global TagIndex instance - """ - global _global_index - - if _global_index is None or force_rebuild: - _global_index = TagIndex() - _global_index.build(force_rebuild=force_rebuild) - elif _global_index._stats is None: - # Index exists but hasn't been built yet - _global_index.build() - - return _global_index - - -def clear_global_index() -> None: - """Clear the global tag index instance.""" - global _global_index - if _global_index: - _global_index.clear_cache() - _global_index = None diff --git a/code/tagging/tag_loader.py b/code/tagging/tag_loader.py deleted file mode 100644 index 238a52d..0000000 --- a/code/tagging/tag_loader.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Efficient tag loading using consolidated all_cards file. - -Provides batch tag loading functions that leverage the all_cards.parquet file -instead of reading individual card CSV files. This is 10-50x faster for bulk -operations like deck building. - -Usage: - # Load tags for multiple cards at once - tags_dict = load_tags_for_cards(["Sol Ring", "Lightning Bolt", "Counterspell"]) - # Returns: {"Sol Ring": ["artifacts"], "Lightning Bolt": ["burn"], ...} - - # Load tags for a single card - tags = load_tags_for_card("Sol Ring") - # Returns: ["artifacts", "ramp"] -""" -from __future__ import annotations - -import os -from typing import Dict, List, Optional - -from code.logging_util import get_logger -from code.services.all_cards_loader import AllCardsLoader - -logger = get_logger(__name__) - -# Global loader instance for caching -_loader_instance: Optional[AllCardsLoader] = None - - -def _get_loader() -> AllCardsLoader: - """Get or create the global AllCardsLoader instance.""" - global _loader_instance - if _loader_instance is None: - _loader_instance = AllCardsLoader() - return _loader_instance - - -def clear_cache() -> None: - """Clear the cached all_cards data (useful after updates).""" - global _loader_instance - _loader_instance = None - - -def load_tags_for_cards(card_names: List[str]) -> Dict[str, List[str]]: - """Load theme tags for multiple cards in one batch operation. - - This is much faster than loading tags for each card individually, - especially when dealing with 50+ cards (typical deck size). - - Args: - card_names: List of card names to load tags for - - Returns: - Dictionary mapping card name to list of theme tags. - Cards not found or without tags will have empty list. - - Example: - >>> tags = load_tags_for_cards(["Sol Ring", "Lightning Bolt"]) - >>> tags["Sol Ring"] - ["artifacts", "ramp"] - """ - if not card_names: - return {} - - loader = _get_loader() - - try: - # Batch lookup - single query for all cards - df = loader.get_by_names(card_names) - - if df.empty: - logger.debug(f"No cards found for {len(card_names)} names") - return {name: [] for name in card_names} - - # Extract tags from DataFrame - result: Dict[str, List[str]] = {} - - if "themeTags" not in df.columns: - logger.warning("themeTags column not found in all_cards") - return {name: [] for name in card_names} - - # Build lookup dictionary - for _, row in df.iterrows(): - name = row.get("name") - if not name: - continue - - tags = row.get("themeTags", []) - - # Handle different themeTags formats - if isinstance(tags, list): - # Already a list - use directly - result[name] = [str(t).strip() for t in tags if t] - elif isinstance(tags, str): - # String format - could be comma-separated or list repr - if not tags or tags == "[]": - result[name] = [] - elif tags.startswith("["): - # List representation like "['tag1', 'tag2']" - import ast - try: - parsed = ast.literal_eval(tags) - if isinstance(parsed, list): - result[name] = [str(t).strip() for t in parsed if t] - else: - result[name] = [] - except (ValueError, SyntaxError): - # Fallback to comma split - result[name] = [t.strip() for t in tags.split(",") if t.strip()] - else: - # Comma-separated tags - result[name] = [t.strip() for t in tags.split(",") if t.strip()] - else: - result[name] = [] - - # Fill in missing cards with empty lists - for name in card_names: - if name not in result: - result[name] = [] - - return result - - except FileNotFoundError: - logger.warning("all_cards file not found, returning empty tags") - return {name: [] for name in card_names} - except Exception as e: - logger.error(f"Error loading tags for cards: {e}") - return {name: [] for name in card_names} - - -def load_tags_for_card(card_name: str) -> List[str]: - """Load theme tags for a single card. - - For loading tags for multiple cards, use load_tags_for_cards() instead - for better performance. - - Args: - card_name: Name of the card - - Returns: - List of theme tags for the card (empty if not found) - - Example: - >>> tags = load_tags_for_card("Sol Ring") - >>> "artifacts" in tags - True - """ - result = load_tags_for_cards([card_name]) - return result.get(card_name, []) - - -def get_cards_with_tag(tag: str, limit: Optional[int] = None) -> List[str]: - """Get all card names that have a specific tag. - - Args: - tag: Theme tag to search for - limit: Maximum number of cards to return (None = no limit) - - Returns: - List of card names with the tag - - Example: - >>> cards = get_cards_with_tag("ramp", limit=10) - >>> len(cards) <= 10 - True - """ - loader = _get_loader() - - try: - df = loader.filter_by_themes([tag], mode="any") - - if "name" not in df.columns: - return [] - - cards = df["name"].tolist() - - if limit is not None and len(cards) > limit: - return cards[:limit] - - return cards - - except Exception as e: - logger.error(f"Error getting cards with tag '{tag}': {e}") - return [] - - -def get_cards_with_all_tags(tags: List[str], limit: Optional[int] = None) -> List[str]: - """Get all card names that have ALL of the specified tags. - - Args: - tags: List of theme tags (card must have all of them) - limit: Maximum number of cards to return (None = no limit) - - Returns: - List of card names with all specified tags - - Example: - >>> cards = get_cards_with_all_tags(["ramp", "artifacts"]) - >>> # Returns cards that have both ramp AND artifacts tags - """ - loader = _get_loader() - - try: - df = loader.filter_by_themes(tags, mode="all") - - if "name" not in df.columns: - return [] - - cards = df["name"].tolist() - - if limit is not None and len(cards) > limit: - return cards[:limit] - - return cards - - except Exception as e: - logger.error(f"Error getting cards with all tags {tags}: {e}") - return [] - - -def is_use_all_cards_enabled() -> bool: - """Check if all_cards-based tag loading is enabled. - - Returns: - True if USE_ALL_CARDS_FOR_TAGS is enabled (default: True) - """ - # Check environment variable - env_value = os.environ.get("USE_ALL_CARDS_FOR_TAGS", "true").lower() - return env_value in ("1", "true", "yes", "on") diff --git a/code/tagging/tag_utils.py b/code/tagging/tag_utils.py index f547020..1fd771b 100644 --- a/code/tagging/tag_utils.py +++ b/code/tagging/tag_utils.py @@ -841,42 +841,7 @@ def tag_with_rules_and_logging( affected |= mask count = affected.sum() - # M4 (Parquet Migration): Display color identity more clearly - if color: - # Map color codes to friendly names - color_map = { - 'w': 'white', - 'u': 'blue', - 'b': 'black', - 'r': 'red', - 'g': 'green', - 'wu': 'Azorius', - 'wb': 'Orzhov', - 'wr': 'Boros', - 'wg': 'Selesnya', - 'ub': 'Dimir', - 'ur': 'Izzet', - 'ug': 'Simic', - 'br': 'Rakdos', - 'bg': 'Golgari', - 'rg': 'Gruul', - 'wub': 'Esper', - 'wur': 'Jeskai', - 'wug': 'Bant', - 'wbr': 'Mardu', - 'wbg': 'Abzan', - 'wrg': 'Naya', - 'ubr': 'Grixis', - 'ubg': 'Sultai', - 'urg': 'Temur', - 'brg': 'Jund', - 'wubrg': '5-color', - '': 'colorless' - } - color_display = color_map.get(color, color) - color_part = f'{color_display} ' - else: - color_part = '' + color_part = f'{color} ' if color else '' full_message = f'Tagged {count} {color_part}{summary_message}' if logger: diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index 3251bf6..f0d3538 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -16,38 +16,16 @@ from . import regex_patterns as rgx from . import tag_constants from . import tag_utils from .bracket_policy_applier import apply_bracket_policy_tags -from .colorless_filter_applier import apply_colorless_filter_tags -from .combo_tag_applier import apply_combo_tags from .multi_face_merger import merge_multi_face_rows import logging_util -from file_setup.data_loader import DataLoader -from settings import COLORS, MULTIPLE_COPY_CARDS +from file_setup import setup +from file_setup.setup_utils import enrich_commander_rows_with_tags +from settings import COLORS, CSV_DIRECTORY, MULTIPLE_COPY_CARDS logger = logging_util.logging.getLogger(__name__) logger.setLevel(logging_util.LOG_LEVEL) logger.addHandler(logging_util.file_handler) logger.addHandler(logging_util.stream_handler) -# Create DataLoader instance for Parquet operations -_data_loader = DataLoader() - - -def _get_batch_id_for_color(color: str) -> int: - """Get unique batch ID for a color (for parallel-safe batch writes). - - Args: - color: Color name (e.g., 'white', 'blue', 'commander') - - Returns: - Unique integer batch ID based on COLORS index - """ - try: - return COLORS.index(color) - except ValueError: - # Fallback for unknown colors (shouldn't happen) - logger.warning(f"Unknown color '{color}', using hash-based batch ID") - return hash(color) % 1000 - - _MERGE_FLAG_RAW = str(os.getenv("ENABLE_DFC_MERGE", "") or "").strip().lower() if _MERGE_FLAG_RAW in {"0", "false", "off", "disabled"}: logger.warning( @@ -172,11 +150,10 @@ def _merge_summary_recorder(color: str): def _write_compat_snapshot(df: pd.DataFrame, color: str) -> None: - """Write DFC compatibility snapshot (diagnostic output, kept as CSV for now).""" - try: + try: # type: ignore[name-defined] _DFC_COMPAT_DIR.mkdir(parents=True, exist_ok=True) path = _DFC_COMPAT_DIR / f"{color}_cards_unmerged.csv" - df.to_csv(path, index=False) # M3: Kept as CSV (diagnostic only, not main data flow) + df.to_csv(path, index=False) logger.info("Wrote unmerged snapshot for %s to %s", color, path) except Exception as exc: logger.warning("Failed to write unmerged snapshot for %s: %s", color, exc) @@ -327,134 +304,70 @@ def _apply_metadata_partition(df: pd.DataFrame) -> tuple[pd.DataFrame, Dict[str, return df, diagnostics ### Setup -## Load and tag all cards from Parquet (M3: no longer per-color) -def load_and_tag_all_cards(parallel: bool = False, max_workers: int | None = None) -> None: +## Load the dataframe +def load_dataframe(color: str) -> None: """ - Load all cards from Parquet, apply tags, write back. - - M3.13: Now supports parallel tagging for significant performance improvement. - + Load and validate the card dataframe for a given color. + Args: - parallel: If True, use parallel tagging (recommended - 2-3x faster) - max_workers: Maximum parallel workers (default: CPU count) - + color (str): The color of cards to load ('white', 'blue', etc) + Raises: - FileNotFoundError: If all_cards.parquet doesn't exist + FileNotFoundError: If CSV file doesn't exist and can't be regenerated ValueError: If required columns are missing """ try: - from code.path_util import get_processed_cards_path - - # Load from all_cards.parquet - all_cards_path = get_processed_cards_path() - - if not os.path.exists(all_cards_path): - raise FileNotFoundError( - f"Processed cards file not found: {all_cards_path}. " - "Run initial_setup_parquet() first." - ) - - logger.info(f"Loading all cards from {all_cards_path}") - - # Load all cards from Parquet - df = _data_loader.read_cards(all_cards_path, format="parquet") - logger.info(f"Loaded {len(df)} cards for tagging") - - # Validate and add required columns - required_columns = ['creatureTypes', 'themeTags'] - missing_columns = [col for col in required_columns if col not in df.columns] - + filepath = f'{CSV_DIRECTORY}/{color}_cards.csv' + + # Check if file exists, regenerate if needed + if not os.path.exists(filepath): + logger.warning(f'{color}_cards.csv not found, regenerating it.') + setup.regenerate_csv_by_color(color) + if not os.path.exists(filepath): + raise FileNotFoundError(f"Failed to generate {filepath}") + + # Load initial dataframe for validation + check_df = pd.read_csv(filepath) + required_columns = ['creatureTypes', 'themeTags'] + missing_columns = [col for col in required_columns if col not in check_df.columns] if missing_columns: logger.warning(f"Missing columns: {missing_columns}") - - if 'creatureTypes' not in df.columns: - kindred_tagging(df, 'wubrg') # Use wubrg (all colors) for unified tagging - - if 'themeTags' not in df.columns: - create_theme_tags(df, 'wubrg') - - # Parquet stores lists natively, no need for converters - # Just ensure list columns are properly initialized - if 'themeTags' in df.columns and df['themeTags'].isna().any(): - df['themeTags'] = df['themeTags'].apply(lambda x: x if isinstance(x, list) else []) - - if 'creatureTypes' in df.columns and df['creatureTypes'].isna().any(): - df['creatureTypes'] = df['creatureTypes'].apply(lambda x: x if isinstance(x, list) else []) - - if 'metadataTags' in df.columns and df['metadataTags'].isna().any(): - df['metadataTags'] = df['metadataTags'].apply(lambda x: x if isinstance(x, list) else []) - - # M3.13: Run tagging (parallel or sequential) - if parallel: - logger.info("Using PARALLEL tagging (ProcessPoolExecutor)") - df_tagged = tag_all_cards_parallel(df, max_workers=max_workers) - else: - logger.info("Using SEQUENTIAL tagging (single-threaded)") - df_tagged = _tag_all_cards_sequential(df) - - # M3.13: Common post-processing (DFC merge, sorting, partitioning, writing) - color = 'wubrg' - - # Merge multi-face entries before final ordering (feature-flagged) - if DFC_COMPAT_SNAPSHOT: + if 'creatureTypes' not in check_df.columns: + kindred_tagging(check_df, color) + if 'themeTags' not in check_df.columns: + create_theme_tags(check_df, color) + + # Persist newly added columns before re-reading with converters try: - _write_compat_snapshot(df_tagged.copy(deep=True), color) - except Exception: - pass + check_df.to_csv(filepath, index=False) + except Exception as e: + logger.error(f'Failed to persist added columns to {filepath}: {e}') + raise - df_merged = merge_multi_face_rows(df_tagged, color, logger=logger, recorder=_merge_summary_recorder(color)) - - # Commander enrichment - TODO: Update for Parquet - logger.info("Commander enrichment temporarily disabled for Parquet migration") + # Verify columns were added successfully + check_df = pd.read_csv(filepath) + still_missing = [col for col in required_columns if col not in check_df.columns] + if still_missing: + raise ValueError(f"Failed to add required columns: {still_missing}") - # Sort all theme tags for easier reading and reorder columns - df_final = sort_theme_tags(df_merged, color) + # Load final dataframe with proper converters + # M3: metadataTags is optional (may not exist in older CSVs) + converters = {'themeTags': pd.eval, 'creatureTypes': pd.eval} + if 'metadataTags' in check_df.columns: + converters['metadataTags'] = pd.eval - # Apply combo tags (Commander Spellbook integration) - must run after merge - apply_combo_tags(df_final) - - # M3: Partition metadata tags from theme tags - df_final, partition_diagnostics = _apply_metadata_partition(df_final) - if partition_diagnostics.get("enabled"): - logger.info(f"Metadata partition: {partition_diagnostics['metadata_tags_moved']} metadata, " - f"{partition_diagnostics['theme_tags_kept']} theme tags") - - # M3: Write directly to all_cards.parquet - output_path = get_processed_cards_path() - _data_loader.write_cards(df_final, output_path, format="parquet") - logger.info(f'✓ Wrote {len(df_final)} tagged cards to {output_path}') - - # M7: Write commander-only cache file for fast lookups - try: - if 'isCommander' in df_final.columns: - commander_df = df_final[df_final['isCommander'] == True].copy() # noqa: E712 - commander_path = os.path.join(os.path.dirname(output_path), 'commander_cards.parquet') - _data_loader.write_cards(commander_df, commander_path, format="parquet") - logger.info(f'✓ Wrote {len(commander_df)} commanders to {commander_path}') - except Exception as e: - logger.warning(f'Failed to write commander cache: {e}') + df = pd.read_csv(filepath, converters=converters) + tag_by_color(df, color) except FileNotFoundError as e: logger.error(f'Error: {e}') raise - except Exception as e: - logger.error(f'An unexpected error occurred during tagging: {e}') + except pd.errors.ParserError as e: + logger.error(f'Error parsing the CSV file: {e}') + raise + except Exception as e: + logger.error(f'An unexpected error occurred: {e}') raise - - -# M3: Keep old load_dataframe for backward compatibility (deprecated) -def load_dataframe(color: str) -> None: - """DEPRECATED: Use load_and_tag_all_cards() instead. - - M3 Note: This function is kept for backward compatibility but should - not be used. The per-color approach was only needed for CSV files. - """ - logger.warning( - f"load_dataframe({color}) is deprecated in Parquet migration. " - "This will process all cards unnecessarily." - ) - load_and_tag_all_cards() - def _tag_foundational_categories(df: pd.DataFrame, color: str) -> None: """Apply foundational card categorization (creature types, card types, keywords). @@ -504,8 +417,6 @@ def _tag_mechanical_themes(df: pd.DataFrame, color: str) -> None: print('\n====================\n') tag_for_bending(df, color) print('\n====================\n') - tag_for_land_types(df, color) - print('\n====================\n') tag_for_web_slinging(df, color) print('\n====================\n') tag_for_tokens(df, color) @@ -580,9 +491,6 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: # Apply bracket policy tags (from config/card_lists/*.json) apply_bracket_policy_tags(df) - - # Apply colorless filter tags (M1: Useless in Colorless) - apply_colorless_filter_tags(df) print('\n====================\n') # Merge multi-face entries before final ordering (feature-flagged) @@ -595,9 +503,7 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: df = merge_multi_face_rows(df, color, logger=logger, recorder=_merge_summary_recorder(color)) if color == 'commander': - # M3 TODO: Update commander enrichment for Parquet - logger.warning("Commander enrichment temporarily disabled for Parquet migration") - # df = enrich_commander_rows_with_tags(df, CSV_DIRECTORY) + df = enrich_commander_rows_with_tags(df, CSV_DIRECTORY) # Sort all theme tags for easier reading and reorder columns df = sort_theme_tags(df, color) @@ -608,214 +514,11 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: logger.info(f"Metadata partition for {color}: {partition_diagnostics['metadata_tags_moved']} metadata, " f"{partition_diagnostics['theme_tags_kept']} theme tags") - # M3: Write batch Parquet file instead of CSV - batch_id = _get_batch_id_for_color(color) - batch_path = _data_loader.write_batch_parquet(df, batch_id=batch_id, tag=color) - logger.info(f'✓ Wrote batch {batch_id} ({color}): {len(df)} cards → {batch_path}') - - -## M3.13: Parallel worker function (runs in separate process) -def _tag_color_group_worker(df_pickled: bytes, color_id: str) -> bytes: - """Worker function for parallel tagging (runs in separate process). - - This function is designed to run in a ProcessPoolExecutor worker. It receives - a pickled DataFrame subset (one color identity group), applies all tag functions, - and returns the tagged DataFrame (also pickled). - - Args: - df_pickled: Pickled DataFrame containing cards of a single color identity - color_id: Color identity string for logging (e.g., 'W', 'WU', 'WUBRG', '') - - Returns: - Pickled DataFrame with all tags applied - - Note: - - This function must be picklable itself (no lambdas, local functions, etc.) - - Logging is color-prefixed for easier debugging in parallel execution - - DFC merge is NOT done here (happens after parallel merge in main process) - - Uses 'wubrg' as the color parameter for tag functions (generic "all colors") - """ - import pickle - - # Unpickle the DataFrame - df = pickle.loads(df_pickled) - - # Use 'wubrg' for tag functions (they don't actually need color-specific logic) - # Just use color_id for logging display - display_color = color_id if color_id else 'colorless' - tag_color = 'wubrg' # Generic color for tag functions - - logger.info(f"[{display_color}] Starting tagging for {len(df)} cards") - - # Apply all tagging functions (same order as tag_all_cards) - # Note: Tag functions use tag_color ('wubrg') for internal logic - _tag_foundational_categories(df, tag_color) - _tag_mechanical_themes(df, tag_color) - _tag_strategic_themes(df, tag_color) - _tag_archetype_themes(df, tag_color) - - # Apply bracket policy tags (from config/card_lists/*.json) - apply_bracket_policy_tags(df) - - # Apply colorless filter tags (M1: Useless in Colorless) - apply_colorless_filter_tags(df) - - logger.info(f"[{display_color}] ✓ Completed tagging for {len(df)} cards") - - # Return pickled DataFrame - return pickle.dumps(df) - - -## M3.13: Parallel tagging implementation -def tag_all_cards_parallel(df: pd.DataFrame, max_workers: int | None = None) -> pd.DataFrame: - """Tag all cards using parallel processing by color identity groups. - - This function splits the input DataFrame by color identity, processes each - group in parallel using ProcessPoolExecutor, then merges the results back - together. This provides significant speedup over sequential processing. - - Args: - df: DataFrame containing all card data - max_workers: Maximum number of parallel workers (default: CPU count) - - Returns: - Tagged DataFrame (note: does NOT include DFC merge - caller handles that) - - Note: - - Typical speedup: 2-3x faster than sequential on multi-core systems - - Each color group is tagged independently (pure functions) - - DFC merge happens after parallel merge in calling function - """ - from concurrent.futures import ProcessPoolExecutor, as_completed - from .parallel_utils import split_by_color_identity, merge_color_groups - import pickle - - logger.info(f"Starting parallel tagging for {len(df)} cards (max_workers={max_workers})") - - # Split into color identity groups - color_groups = split_by_color_identity(df) - logger.info(f"Split into {len(color_groups)} color identity groups") - - # Track results - tagged_groups: dict[str, pd.DataFrame] = {} - - # Process groups in parallel - with ProcessPoolExecutor(max_workers=max_workers) as executor: - # Submit all work - future_to_color = { - executor.submit(_tag_color_group_worker, pickle.dumps(group_df), color_id): color_id - for color_id, group_df in color_groups.items() - } - - # Collect results as they complete - completed = 0 - total = len(future_to_color) - - for future in as_completed(future_to_color): - color_id = future_to_color[future] - display_color = color_id if color_id else 'colorless' - - try: - # Get result and unpickle - result_pickled = future.result() - tagged_df = pickle.loads(result_pickled) - tagged_groups[color_id] = tagged_df - - completed += 1 - pct = int(completed * 100 / total) - logger.info(f"✓ [{display_color}] Completed ({completed}/{total}, {pct}%)") - - except Exception as e: - logger.error(f"✗ [{display_color}] Worker failed: {e}") - raise - - # Merge all tagged groups back together - logger.info("Merging tagged color groups...") - df_tagged = merge_color_groups(tagged_groups) - logger.info(f"✓ Parallel tagging complete: {len(df_tagged)} cards tagged") - - return df_tagged - - -## M3.13: Sequential tagging (refactored to return DataFrame) -def _tag_all_cards_sequential(df: pd.DataFrame) -> pd.DataFrame: - """Tag all cards sequentially (single-threaded). - - This is the sequential version used when parallel=False. - It applies all tag functions to the full DataFrame at once. - - Args: - df: DataFrame containing all card data - - Returns: - Tagged DataFrame (does NOT include DFC merge - caller handles that) - """ - logger.info(f"Starting sequential tagging for {len(df)} cards") - - # M3: Use 'wubrg' as color identifier (represents all colors, exists in COLORS list) - color = 'wubrg' - - _tag_foundational_categories(df, color) - _tag_mechanical_themes(df, color) - _tag_strategic_themes(df, color) - _tag_archetype_themes(df, color) - - # Apply bracket policy tags (from config/card_lists/*.json) - apply_bracket_policy_tags(df) - - # Apply colorless filter tags (M1: Useless in Colorless) - apply_colorless_filter_tags(df) + df.to_csv(f'{CSV_DIRECTORY}/{color}_cards.csv', index=False) + #print(df) print('\n====================\n') - - logger.info(f"✓ Sequential tagging complete: {len(df)} cards tagged") - return df - - -## M3: Keep old tag_all_cards for backward compatibility (now calls sequential version) -def tag_all_cards(df: pd.DataFrame) -> None: - """DEPRECATED: Use load_and_tag_all_cards() instead. - - This function is kept for backward compatibility but does the full - workflow including DFC merge and file writing, which may not be desired. - - Args: - df: DataFrame containing all card data - """ - logger.warning("tag_all_cards() is deprecated. Use load_and_tag_all_cards() instead.") - - # Tag the cards (modifies df in-place) - _tag_all_cards_sequential(df) - - # Do post-processing (for backward compatibility) - color = 'wubrg' - - # Merge multi-face entries before final ordering (feature-flagged) - if DFC_COMPAT_SNAPSHOT: - try: - _write_compat_snapshot(df.copy(deep=True), color) - except Exception: - pass - - df_merged = merge_multi_face_rows(df, color, logger=logger, recorder=_merge_summary_recorder(color)) - - # Commander enrichment - TODO: Update for Parquet - logger.info("Commander enrichment temporarily disabled for Parquet migration") - - # Sort all theme tags for easier reading and reorder columns - df_final = sort_theme_tags(df_merged, color) - - # M3: Partition metadata tags from theme tags - df_final, partition_diagnostics = _apply_metadata_partition(df_final) - if partition_diagnostics.get("enabled"): - logger.info(f"Metadata partition: {partition_diagnostics['metadata_tags_moved']} metadata, " - f"{partition_diagnostics['theme_tags_kept']} theme tags") - - # M3: Write directly to all_cards.parquet - from code.path_util import get_processed_cards_path - output_path = get_processed_cards_path() - _data_loader.write_cards(df_final, output_path, format="parquet") - logger.info(f'✓ Wrote {len(df_final)} tagged cards to {output_path}') - + logger.info(f'Tags are done being set on {color}_cards.csv') + #keyboard.wait('esc') ## Determine any non-creature cards that have creature types mentioned def kindred_tagging(df: pd.DataFrame, color: str) -> None: @@ -1064,7 +767,7 @@ def tag_for_keywords(df: pd.DataFrame, color: str) -> None: exclusion_keywords = {'partner'} def _merge_keywords(row: pd.Series) -> list[str]: - base_tags = list(row['themeTags']) if hasattr(row.get('themeTags'), '__len__') and not isinstance(row.get('themeTags'), str) else [] + base_tags = row['themeTags'] if isinstance(row['themeTags'], list) else [] keywords_raw = row['keywords'] if isinstance(keywords_raw, str): @@ -1109,27 +812,9 @@ def sort_theme_tags(df, color): # Sort the list of tags in-place per row df['themeTags'] = df['themeTags'].apply(tag_utils.sort_list) - # Reorder columns for final output - # M3: Preserve ALL columns (isCommander, isBackground, metadataTags, etc.) - # BUT exclude temporary cache columns (__*_s) - base_columns = ['name', 'faceName','edhrecRank', 'colorIdentity', 'colors', 'manaCost', 'manaValue', 'type', 'creatureTypes', 'text', 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'] - - # Add M3 columns if present - if 'metadataTags' in df.columns and 'metadataTags' not in base_columns: - base_columns.append('metadataTags') - - # Add columns from setup_parquet (isCommander, isBackground) - for col in ['isCommander', 'isBackground']: - if col in df.columns and col not in base_columns: - base_columns.append(col) - - # Preserve any other columns not in base list (flexibility for future additions) - # EXCEPT temporary cache columns (start with __) - for col in df.columns: - if col not in base_columns and not col.startswith('__'): - base_columns.append(col) - - available = [c for c in base_columns if c in df.columns] + # Reorder columns for final CSV output; return a reindexed copy + columns_to_keep = ['name', 'faceName','edhrecRank', 'colorIdentity', 'colors', 'manaCost', 'manaValue', 'type', 'creatureTypes', 'text', 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'] + available = [c for c in columns_to_keep if c in df.columns] logger.info(f'Theme tags alphabetically sorted in {color}_cards.csv.') return df.reindex(columns=available) @@ -4253,9 +3938,7 @@ def tag_for_themes(df: pd.DataFrame, color: str) -> None: ValueError: If required DataFrame columns are missing """ start_time = pd.Timestamp.now() - # M4 (Parquet Migration): Updated logging to reflect unified tagging - color_display = color if color else 'colorless' - logger.info(f'Starting tagging for remaining themes in {color_display} cards') + logger.info(f'Starting tagging for remaining themes in {color}_cards.csv') print('\n===============\n') tag_for_aggro(df, color) print('\n==========\n') @@ -4556,55 +4239,6 @@ def tag_for_web_slinging(df: pd.DataFrame, color: str) -> None: logger.error(f'Error tagging Web-Slinging keywords: {str(e)}') raise -### Tag for land types -def tag_for_land_types(df: pd.DataFrame, color: str) -> None: - """Tag card for specific non-basic land types. - - Looks for 'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Sphere', 'Urza's' in rules text and applies tags accordingly. - """ - try: - cave_mask = ( - (tag_utils.create_text_mask(df, 'Cave') & ~tag_utils.create_text_mask(df, 'scavenge')) | - tag_utils.create_type_mask(df, 'Cave') - ) - desert_mask = ( - tag_utils.create_text_mask(df, 'Desert') | - tag_utils.create_type_mask(df, 'Desert') - ) - gate_mask = ( - ( - tag_utils.create_text_mask(df, 'Gate') & - ~tag_utils.create_text_mask(df, 'Agate') & - ~tag_utils.create_text_mask(df, 'Legate') & - ~tag_utils.create_text_mask(df, 'Throw widethe Gates') & - ~tag_utils.create_text_mask(df, 'Eternity Gate') & - ~tag_utils.create_text_mask(df, 'Investigates') - ) | - tag_utils.create_text_mask(df, 'Gate card') | - tag_utils.create_type_mask(df, 'Gate') - ) - lair_mask = (tag_utils.create_type_mask(df, 'Lair')) - locus_mask = (tag_utils.create_type_mask(df, 'Locus')) - sphere_mask = ( - (tag_utils.create_text_mask(df, 'Sphere') & ~tag_utils.create_text_mask(df, 'Detention Sphere')) | - tag_utils.create_type_mask(df, 'Sphere')) - urzas_mask = (tag_utils.create_type_mask(df, "Urza's")) - rules = [ - {'mask': cave_mask, 'tags': ['Caves Matter', 'Lands Matter']}, - {'mask': desert_mask, 'tags': ['Deserts Matter', 'Lands Matter']}, - {'mask': gate_mask, 'tags': ['Gates Matter', 'Lands Matter']}, - {'mask': lair_mask, 'tags': ['Lairs Matter', 'Lands Matter']}, - {'mask': locus_mask, 'tags': ['Locus Matter', 'Lands Matter']}, - {'mask': sphere_mask, 'tags': ['Spheres Matter', 'Lands Matter']}, - {'mask': urzas_mask, 'tags': ["Urza's Lands Matter", 'Lands Matter']}, - ] - - tag_utils.tag_with_rules_and_logging(df, rules, 'non-basic land types', color=color, logger=logger) - - except Exception as e: - logger.error(f'Error tagging non-basic land types: {str(e)}') - raise - ## Big Mana def create_big_mana_cost_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards with high mana costs or X costs. @@ -5443,7 +5077,7 @@ def tag_for_multiple_copies(df: pd.DataFrame, color: str) -> None: # Add per-card rules for individual name tags rules.extend({'mask': (df['name'] == card_name), 'tags': [card_name]} for card_name in matching_cards) tag_utils.apply_rules(df, rules=rules) - logger.info(f'Tagged {multiple_copies_mask.sum()} cards with multiple copies effects') + logger.info(f'Tagged {multiple_copies_mask.sum()} cards with multiple copies effects for {color}') except Exception as e: logger.error(f'Error in tag_for_multiple_copies: {str(e)}') @@ -6694,7 +6328,7 @@ def tag_for_protection(df: pd.DataFrame, color: str) -> None: logger.info(f'Applied specific protection ability tags to {ability_tag_count} cards') # Log results - logger.info(f'Tagged {final_mask.sum()} cards with protection effects') + logger.info(f'Tagged {final_mask.sum()} cards with protection effects for {color}') except Exception as e: logger.error(f'Error in tag_for_protection: {str(e)}') @@ -6780,7 +6414,7 @@ def tag_for_phasing(df: pd.DataFrame, color: str) -> None: logger.info(f'Applied Removal tag to {removal_count} cards with opponent-targeting phasing') # Log results - logger.info(f'Tagged {phasing_mask.sum()} cards with phasing effects') + logger.info(f'Tagged {phasing_mask.sum()} cards with phasing effects for {color}') except Exception as e: logger.error(f'Error in tag_for_phasing: {str(e)}') @@ -6854,52 +6488,39 @@ def tag_for_removal(df: pd.DataFrame, color: str) -> None: raise def run_tagging(parallel: bool = False, max_workers: int | None = None): - """Run tagging on all cards (M3.13: now supports parallel processing). + """Run tagging across all COLORS. Args: - parallel: If True, use parallel tagging (recommended - 2-3x faster) - max_workers: Maximum parallel workers (default: CPU count) + parallel: If True, process colors in parallel using multiple processes. + max_workers: Optional cap on worker processes. """ start_time = pd.Timestamp.now() - if DFC_PER_FACE_SNAPSHOT: - logger.info("DFC_PER_FACE_SNAPSHOT enabled for unified tagging") + if parallel and DFC_PER_FACE_SNAPSHOT: + logger.warning("DFC_PER_FACE_SNAPSHOT=1 detected; per-face metadata snapshots require sequential tagging. Parallel run will skip snapshot emission.") + + if parallel: + try: + import concurrent.futures as _f + # Use processes to bypass GIL; each color reads/writes distinct CSV + with _f.ProcessPoolExecutor(max_workers=max_workers) as ex: + futures = {ex.submit(load_dataframe, color): color for color in COLORS} + for fut in _f.as_completed(futures): + color = futures[fut] + try: + fut.result() + except Exception as e: + logger.error(f'Parallel worker failed for {color}: {e}') + raise + except Exception: + # Fallback to sequential on any multiprocessing setup error + logger.warning('Parallel mode failed to initialize; falling back to sequential.') + for color in COLORS: + load_dataframe(color) + else: + for color in COLORS: + load_dataframe(color) - # M3.13: Unified tagging with optional parallelization - mode = "PARALLEL" if parallel else "SEQUENTIAL" - logger.info(f"Starting unified tagging ({mode} mode)") - load_and_tag_all_cards(parallel=parallel, max_workers=max_workers) - - # Flush per-face snapshots if enabled _flush_per_face_snapshot() - duration = (pd.Timestamp.now() - start_time).total_seconds() - logger.info(f'✓ Tagged cards in {duration:.2f}s ({mode} mode)') - - # M4: Write tagging completion flag to processed directory - try: - import os - import json - from datetime import datetime, UTC - - flag_dir = os.path.join("card_files", "processed") - os.makedirs(flag_dir, exist_ok=True) - flag_path = os.path.join(flag_dir, ".tagging_complete.json") - - with open(flag_path, "w", encoding="utf-8") as f: - json.dump({ - "completed_at": datetime.now(UTC).isoformat(timespec="seconds"), - "mode": mode, - "parallel": parallel, - "duration_seconds": duration - }, f, indent=2) - - logger.info(f"✓ Wrote tagging completion flag to {flag_path}") - except Exception as e: - logger.warning(f"Failed to write tagging completion flag: {e}") - - - - - - + logger.info(f'Tagged cards in {duration:.2f}s') diff --git a/code/tagging/tagger_card_centric.py b/code/tagging/tagger_card_centric.py deleted file mode 100644 index fd18258..0000000 --- a/code/tagging/tagger_card_centric.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Card-centric tagging approach for performance comparison. - -This module implements a single-pass tagging strategy where we iterate -through each card once and apply all applicable tags, rather than -iterating through all cards for each tag type. - -Performance hypothesis: Single-pass should be faster due to: -- Better cache locality (sequential card access) -- Fewer DataFrame iterations -- Less memory thrashing - -Trade-offs: -- All tagging logic in one place (harder to maintain) -- More complex per-card logic -- Less modular than tag-centric approach - -M3: Created for Parquet migration performance testing. -""" - -from __future__ import annotations - -import re -from typing import List, Set - -import pandas as pd - -from logging_util import get_logger - -logger = get_logger(__name__) - - -class CardCentricTagger: - """Single-pass card tagger that applies all tags to each card sequentially.""" - - def __init__(self): - """Initialize tagger with compiled regex patterns for performance.""" - # Pre-compile common regex patterns - self.ramp_pattern = re.compile( - r'add .*mana|search.*land|ramp|cultivate|kodama|explosive vegetation', - re.IGNORECASE - ) - self.draw_pattern = re.compile( - r'draw.*card|card draw|divination|ancestral|opt|cantrip', - re.IGNORECASE - ) - self.removal_pattern = re.compile( - r'destroy|exile|counter|return.*hand|bounce|murder|wrath|swords', - re.IGNORECASE - ) - self.token_pattern = re.compile( - r'create.*token|token.*creature|populate|embalm', - re.IGNORECASE - ) - # Add more patterns as needed - - def tag_single_card(self, row: pd.Series) -> List[str]: - """Apply all applicable tags to a single card. - - Args: - row: pandas Series representing a card - - Returns: - List of tags that apply to this card - """ - tags: Set[str] = set() - - # Extract common fields - text = str(row.get('text', '')).lower() - type_line = str(row.get('type', '')).lower() - keywords = row.get('keywords', []) - if isinstance(keywords, str): - keywords = [keywords] - mana_value = row.get('manaValue', 0) - - # === FOUNDATIONAL TAGS === - - # Card types - if 'creature' in type_line: - tags.add('Creature') - if 'instant' in type_line: - tags.add('Instant') - if 'sorcery' in type_line: - tags.add('Sorcery') - if 'artifact' in type_line: - tags.add('Artifact') - if 'enchantment' in type_line: - tags.add('Enchantment') - if 'planeswalker' in type_line: - tags.add('Planeswalker') - if 'land' in type_line: - tags.add('Land') - - # === MECHANICAL TAGS === - - # Ramp - if self.ramp_pattern.search(text): - tags.add('Ramp') - - # Card draw - if self.draw_pattern.search(text): - tags.add('Card Draw') - - # Removal - if self.removal_pattern.search(text): - tags.add('Removal') - tags.add('Interaction') - - # Tokens - if self.token_pattern.search(text): - tags.add('Tokens') - - # Keywords - if keywords: - for kw in keywords: - kw_lower = str(kw).lower() - if 'flash' in kw_lower: - tags.add('Flash') - if 'haste' in kw_lower: - tags.add('Haste') - if 'flying' in kw_lower: - tags.add('Flying') - # Add more keyword mappings - - # === STRATEGIC TAGS === - - # Voltron (equipment, auras on creatures) - if 'equipment' in type_line or 'equip' in text: - tags.add('Voltron') - tags.add('Equipment') - - if 'aura' in type_line and 'enchant creature' in text: - tags.add('Voltron') - tags.add('Auras') - - # Spellslinger (cares about instants/sorceries) - if 'instant' in text and 'sorcery' in text: - tags.add('Spellslinger') - - # Graveyard matters - if any(word in text for word in ['graveyard', 'flashback', 'unearth', 'delve', 'escape']): - tags.add('Graveyard') - - # === ARCHETYPE TAGS === - - # Combo pieces (based on specific card text patterns) - if 'infinite' in text or 'any number' in text: - tags.add('Combo') - - # === MV-BASED TAGS === - - if mana_value <= 2: - tags.add('Low MV') - elif mana_value >= 6: - tags.add('High MV') - - return sorted(list(tags)) - - def tag_all_cards(self, df: pd.DataFrame) -> pd.DataFrame: - """Apply tags to all cards in a single pass. - - Args: - df: DataFrame containing card data - - Returns: - DataFrame with themeTags column populated - """ - logger.info(f"Starting card-centric tagging for {len(df)} cards") - - # Initialize themeTags column if not exists - if 'themeTags' not in df.columns: - df['themeTags'] = None - - # Single pass through all cards - tag_counts = {} - for idx in df.index: - row = df.loc[idx] - tags = self.tag_single_card(row) - df.at[idx, 'themeTags'] = tags - - # Track tag frequency - for tag in tags: - tag_counts[tag] = tag_counts.get(tag, 0) + 1 - - logger.info(f"Tagged {len(df)} cards with {len(tag_counts)} unique tags") - logger.info(f"Top 10 tags: {sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]}") - - return df - - -def tag_all_cards_single_pass(df: pd.DataFrame) -> pd.DataFrame: - """Convenience function for single-pass tagging. - - Args: - df: DataFrame containing card data - - Returns: - DataFrame with themeTags populated - """ - tagger = CardCentricTagger() - return tagger.tag_all_cards(df) diff --git a/code/tagging/theme_enrichment.py b/code/tagging/theme_enrichment.py deleted file mode 100644 index 7e194d7..0000000 --- a/code/tagging/theme_enrichment.py +++ /dev/null @@ -1,602 +0,0 @@ -"""Consolidated theme metadata enrichment pipeline. - -Replaces 7 separate subprocess scripts with single efficient in-memory pipeline: -1. autofill_min_examples - Add placeholder examples -2. pad_min_examples - Pad to minimum threshold -3. cleanup_placeholder_examples - Remove placeholders when real examples added -4. purge_anchor_placeholders - Purge legacy anchor placeholders -5. augment_theme_yaml_from_catalog - Add descriptions/popularity from catalog -6. generate_theme_editorial_suggestions - Generate editorial suggestions -7. lint_theme_editorial - Validate metadata - -Performance improvement: 5-10x faster by loading all YAMLs once, processing in memory, -writing once at the end. -""" -from __future__ import annotations - -import json -import re -import string -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set - -try: - import yaml # type: ignore -except ImportError: # pragma: no cover - yaml = None - - -@dataclass -class ThemeData: - """In-memory representation of a theme YAML file.""" - path: Path - data: Dict[str, Any] - modified: bool = False - - -@dataclass -class EnrichmentStats: - """Statistics for enrichment pipeline run.""" - autofilled: int = 0 - padded: int = 0 - cleaned: int = 0 - purged: int = 0 - augmented: int = 0 - suggestions_added: int = 0 - lint_errors: int = 0 - lint_warnings: int = 0 - total_themes: int = 0 - - def __str__(self) -> str: - return ( - f"Enrichment complete: {self.total_themes} themes processed | " - f"autofilled:{self.autofilled} padded:{self.padded} cleaned:{self.cleaned} " - f"purged:{self.purged} augmented:{self.augmented} suggestions:{self.suggestions_added} | " - f"lint: {self.lint_errors} errors, {self.lint_warnings} warnings" - ) - - -class ThemeEnrichmentPipeline: - """Consolidated theme metadata enrichment pipeline.""" - - def __init__( - self, - root: Optional[Path] = None, - min_examples: int = 5, - progress_callback: Optional[Callable[[str], None]] = None, - ): - """Initialize the enrichment pipeline. - - Args: - root: Project root directory (defaults to auto-detect) - min_examples: Minimum number of example commanders required - progress_callback: Optional callback for progress updates (for web UI) - """ - if root is None: - # Auto-detect root (3 levels up from this file) - root = Path(__file__).resolve().parents[2] - - self.root = root - self.catalog_dir = root / 'config' / 'themes' / 'catalog' - self.theme_json = root / 'config' / 'themes' / 'theme_list.json' - self.csv_dir = root / 'csv_files' - self.min_examples = min_examples - self.progress_callback = progress_callback - - self.themes: Dict[Path, ThemeData] = {} - self.stats = EnrichmentStats() - - # Cached data - self._catalog_map: Optional[Dict[str, Dict[str, Any]]] = None - self._card_suggestions: Optional[Dict[str, Any]] = None - - def _emit(self, message: str) -> None: - """Emit progress message via callback or print.""" - if self.progress_callback: - try: - self.progress_callback(message) - except Exception: - pass - else: - print(message, flush=True) - - def load_all_themes(self) -> None: - """Load all theme YAML files into memory (Step 0).""" - if not self.catalog_dir.exists(): - self._emit("Warning: Catalog directory does not exist") - return - - paths = sorted(self.catalog_dir.glob('*.yml')) - self.stats.total_themes = len(paths) - - for path in paths: - try: - if yaml is None: - raise RuntimeError("PyYAML not installed") - data = yaml.safe_load(path.read_text(encoding='utf-8')) - if isinstance(data, dict): - self.themes[path] = ThemeData(path=path, data=data) - except Exception as e: - self._emit(f"Warning: Failed to load {path.name}: {e}") - - self._emit(f"Loaded {len(self.themes)} theme files") - - def _is_deprecated_alias(self, theme_data: Dict[str, Any]) -> bool: - """Check if theme is a deprecated alias placeholder.""" - notes = theme_data.get('notes') - return isinstance(notes, str) and 'Deprecated alias file' in notes - - def _is_placeholder(self, entry: str) -> bool: - """Check if an example entry is a placeholder. - - Matches: - - "Theme Anchor" - - "Theme Anchor B" - - "Theme Anchor C" - etc. - """ - pattern = re.compile(r" Anchor( [A-Z])?$") - return bool(pattern.search(entry)) - - # Step 1: Autofill minimal placeholders - def autofill_placeholders(self) -> None: - """Add placeholder examples for themes with zero examples.""" - for theme in self.themes.values(): - data = theme.data - - if self._is_deprecated_alias(data): - continue - - if not data.get('display_name'): - continue - - # Skip if theme already has real (non-placeholder) examples in YAML - examples = data.get('example_commanders') or [] - if isinstance(examples, list) and examples: - # Check if any examples are real (not " Anchor" placeholders) - has_real_examples = any( - isinstance(ex, str) and ex and not ex.endswith(' Anchor') - for ex in examples - ) - if has_real_examples: - continue # Already has real examples, skip placeholder generation - # If only placeholders, continue to avoid overwriting - - display = data['display_name'] - synergies = data.get('synergies') or [] - if not isinstance(synergies, list): - synergies = [] - - # Generate placeholders from display name + synergies - placeholders = [f"{display} Anchor"] - for s in synergies[:2]: # First 2 synergies - if isinstance(s, str) and s and s != display: - placeholders.append(f"{s} Anchor") - - data['example_commanders'] = placeholders - if not data.get('editorial_quality'): - data['editorial_quality'] = 'draft' - - theme.modified = True - self.stats.autofilled += 1 - - # Step 2: Pad to minimum examples - def pad_examples(self) -> None: - """Pad example lists to minimum threshold with placeholders.""" - for theme in self.themes.values(): - data = theme.data - - if self._is_deprecated_alias(data): - continue - - if not data.get('display_name'): - continue - - examples = data.get('example_commanders') or [] - if not isinstance(examples, list): - continue - - if len(examples) >= self.min_examples: - continue - - # Only pad pure placeholder sets (heuristic: don't mix real + placeholders) - if any(not self._is_placeholder(e) for e in examples): - continue - - display = data['display_name'] - synergies = data.get('synergies') if isinstance(data.get('synergies'), list) else [] - need = self.min_examples - len(examples) - - # Build additional placeholders - new_placeholders = [] - used = set(examples) - - # 1. Additional synergies beyond first 2 - for syn in synergies[2:]: - cand = f"{syn} Anchor" - if cand not in used and syn != display: - new_placeholders.append(cand) - if len(new_placeholders) >= need: - break - - # 2. Generic letter suffixes (B, C, D, ...) - if len(new_placeholders) < need: - for suffix in string.ascii_uppercase[1:]: # Start from 'B' - cand = f"{display} Anchor {suffix}" - if cand not in used: - new_placeholders.append(cand) - if len(new_placeholders) >= need: - break - - if new_placeholders: - data['example_commanders'] = examples + new_placeholders - if not data.get('editorial_quality'): - data['editorial_quality'] = 'draft' - theme.modified = True - self.stats.padded += 1 - - # Step 3: Cleanup placeholders when real examples exist - def cleanup_placeholders(self) -> None: - """Remove placeholders when real examples have been added.""" - for theme in self.themes.values(): - data = theme.data - - if self._is_deprecated_alias(data): - continue - - if not data.get('display_name'): - continue - - examples = data.get('example_commanders') - if not isinstance(examples, list) or not examples: - continue - - placeholders = [e for e in examples if isinstance(e, str) and self._is_placeholder(e)] - real = [e for e in examples if isinstance(e, str) and not self._is_placeholder(e)] - - # Only cleanup if we have both placeholders AND real examples - if placeholders and real: - new_list = real if real else placeholders[:1] # Keep at least one if all placeholders - if new_list != examples: - data['example_commanders'] = new_list - theme.modified = True - self.stats.cleaned += 1 - - # Step 4: Purge legacy anchor placeholders - def purge_anchors(self) -> None: - """Remove all legacy anchor placeholders.""" - pattern = re.compile(r" Anchor( [A-Z])?$") - - for theme in self.themes.values(): - data = theme.data - - examples = data.get('example_commanders') - if not isinstance(examples, list) or not examples: - continue - - placeholders = [e for e in examples if isinstance(e, str) and pattern.search(e)] - if not placeholders: - continue - - real = [e for e in examples if isinstance(e, str) and not pattern.search(e)] - new_list = real # Remove ALL placeholders (even if list becomes empty) - - if new_list != examples: - data['example_commanders'] = new_list - theme.modified = True - self.stats.purged += 1 - - # Step 5: Augment from catalog - def _load_catalog_map(self) -> Dict[str, Dict[str, Any]]: - """Load theme_list.json catalog into memory.""" - if self._catalog_map is not None: - return self._catalog_map - - if not self.theme_json.exists(): - self._emit("Warning: theme_list.json not found") - self._catalog_map = {} - return self._catalog_map - - try: - data = json.loads(self.theme_json.read_text(encoding='utf-8') or '{}') - themes = data.get('themes') or [] - self._catalog_map = {} - for t in themes: - if isinstance(t, dict) and t.get('theme'): - self._catalog_map[str(t['theme'])] = t - except Exception as e: - self._emit(f"Warning: Failed to parse theme_list.json: {e}") - self._catalog_map = {} - - return self._catalog_map - - def augment_from_catalog(self) -> None: - """Add description, popularity, etc. from theme_list.json.""" - catalog_map = self._load_catalog_map() - if not catalog_map: - return - - for theme in self.themes.values(): - data = theme.data - - if self._is_deprecated_alias(data): - continue - - name = str(data.get('display_name') or '').strip() - if not name: - continue - - cat_entry = catalog_map.get(name) - if not cat_entry: - continue - - modified = False - - # Add description if missing - if 'description' not in data and 'description' in cat_entry and cat_entry['description']: - data['description'] = cat_entry['description'] - modified = True - - # Add popularity bucket if missing - if 'popularity_bucket' not in data and cat_entry.get('popularity_bucket'): - data['popularity_bucket'] = cat_entry['popularity_bucket'] - modified = True - - # Add popularity hint if missing - if 'popularity_hint' not in data and cat_entry.get('popularity_hint'): - data['popularity_hint'] = cat_entry['popularity_hint'] - modified = True - - # Backfill deck archetype if missing (defensive) - if 'deck_archetype' not in data and cat_entry.get('deck_archetype'): - data['deck_archetype'] = cat_entry['deck_archetype'] - modified = True - - if modified: - theme.modified = True - self.stats.augmented += 1 - - # Step 6: Generate editorial suggestions (simplified - full implementation would scan CSVs) - def generate_suggestions(self) -> None: - """Generate editorial suggestions for missing example_cards/commanders. - - This runs the generate_theme_editorial_suggestions.py script to populate - example_cards and example_commanders from CSV data (EDHREC ranks + themeTags). - """ - import os - import subprocess - - # Check if we should run the editorial suggestions generator - skip_suggestions = os.environ.get('SKIP_EDITORIAL_SUGGESTIONS', '').lower() in ('1', 'true', 'yes') - if skip_suggestions: - self._emit("Skipping editorial suggestions generation (SKIP_EDITORIAL_SUGGESTIONS=1)") - return - - script_path = self.root / 'code' / 'scripts' / 'generate_theme_editorial_suggestions.py' - if not script_path.exists(): - self._emit("Editorial suggestions script not found; skipping") - return - - try: - self._emit("Generating example_cards and example_commanders from CSV data...") - # Run with --apply to write missing fields, limit to reasonable batch - result = subprocess.run( - [sys.executable, str(script_path), '--apply', '--limit-yaml', '1000', '--top', '8'], - capture_output=True, - text=True, - timeout=300, # 5 minute timeout - cwd=str(self.root) - ) - if result.returncode == 0: - # Reload themes to pick up the generated examples - self.load_all_themes() - self._emit("Editorial suggestions generated successfully") - else: - self._emit(f"Editorial suggestions script failed (exit {result.returncode}): {result.stderr[:200]}") - except subprocess.TimeoutExpired: - self._emit("Editorial suggestions generation timed out (skipping)") - except Exception as e: - self._emit(f"Failed to generate editorial suggestions: {e}") - - # Step 7: Lint/validate - ALLOWED_ARCHETYPES: Set[str] = { - 'Lands', 'Graveyard', 'Planeswalkers', 'Tokens', 'Counters', 'Spells', - 'Artifacts', 'Enchantments', 'Politics', 'Combo', 'Aggro', 'Control', - 'Midrange', 'Stax', 'Ramp', 'Toolbox' - } - - CORNERSTONE: Set[str] = { - 'Landfall', 'Reanimate', 'Superfriends', 'Tokens Matter', '+1/+1 Counters' - } - - def validate(self, enforce_min: bool = False, strict: bool = False) -> None: - """Validate theme metadata (lint).""" - errors: List[str] = [] - warnings: List[str] = [] - seen_display: Set[str] = set() - - for theme in self.themes.values(): - data = theme.data - - if self._is_deprecated_alias(data): - continue - - name = str(data.get('display_name') or '').strip() - if not name: - continue - - if name in seen_display: - continue # Skip duplicates - seen_display.add(name) - - ex_cmd = data.get('example_commanders') or [] - ex_cards = data.get('example_cards') or [] - - if not isinstance(ex_cmd, list): - errors.append(f"{name}: example_commanders not a list") - ex_cmd = [] - - if not isinstance(ex_cards, list): - errors.append(f"{name}: example_cards not a list") - ex_cards = [] - - # Length checks - if len(ex_cmd) > 12: - warnings.append(f"{name}: example_commanders has {len(ex_cmd)} entries (>12)") - - if len(ex_cards) > 20: - warnings.append(f"{name}: example_cards has {len(ex_cards)} entries (>20)") - - # Minimum examples check - if ex_cmd and len(ex_cmd) < self.min_examples: - msg = f"{name}: only {len(ex_cmd)} example_commanders (<{self.min_examples} minimum)" - if enforce_min: - errors.append(msg) - else: - warnings.append(msg) - - # Cornerstone themes should have examples (if strict) - if strict and name in self.CORNERSTONE: - if not ex_cmd: - errors.append(f"{name}: cornerstone theme missing example_commanders") - if not ex_cards: - errors.append(f"{name}: cornerstone theme missing example_cards") - - # Deck archetype validation - archetype = data.get('deck_archetype') - if archetype and archetype not in self.ALLOWED_ARCHETYPES: - warnings.append(f"{name}: unknown deck_archetype '{archetype}'") - - self.stats.lint_errors = len(errors) - self.stats.lint_warnings = len(warnings) - - if errors: - for err in errors: - self._emit(f"ERROR: {err}") - - if warnings: - for warn in warnings: - self._emit(f"WARNING: {warn}") - - def write_all_themes(self) -> None: - """Write all modified themes back to disk (final step).""" - if yaml is None: - raise RuntimeError("PyYAML not installed; cannot write themes") - - written = 0 - for theme in self.themes.values(): - if theme.modified: - try: - theme.path.write_text( - yaml.safe_dump(theme.data, sort_keys=False, allow_unicode=True), - encoding='utf-8' - ) - written += 1 - except Exception as e: - self._emit(f"Error writing {theme.path.name}: {e}") - - self._emit(f"Wrote {written} modified theme files") - - def run_all( - self, - write: bool = True, - enforce_min: bool = False, - strict_lint: bool = False, - run_purge: bool = False, - ) -> EnrichmentStats: - """Run the full enrichment pipeline. - - Args: - write: Whether to write changes to disk (False = dry run) - enforce_min: Whether to treat min_examples violations as errors - strict_lint: Whether to enforce strict validation rules - run_purge: Whether to run purge step (removes ALL anchor placeholders) - - Returns: - EnrichmentStats with summary of operations - """ - self._emit("Starting theme enrichment pipeline...") - - # Step 0: Load all themes - self.load_all_themes() - - # Step 1: Autofill placeholders - self._emit("Step 1/7: Autofilling placeholders...") - self.autofill_placeholders() - - # Step 2: Pad to minimum - self._emit("Step 2/7: Padding to minimum examples...") - self.pad_examples() - - # Step 3: Cleanup mixed placeholder/real lists - self._emit("Step 3/7: Cleaning up placeholders...") - self.cleanup_placeholders() - - # Step 4: Purge all anchor placeholders (optional - disabled by default) - # Note: Purge removes ALL anchors, even from pure placeholder lists. - # Only enable for one-time migration away from placeholder system. - if run_purge: - self._emit("Step 4/7: Purging legacy anchors...") - self.purge_anchors() - else: - self._emit("Step 4/7: Skipping purge (preserving placeholders)...") - - # Step 5: Augment from catalog - self._emit("Step 5/7: Augmenting from catalog...") - self.augment_from_catalog() - - # Step 6: Generate suggestions (skipped for performance) - self._emit("Step 6/7: Generating suggestions...") - self.generate_suggestions() - - # Step 7: Validate - self._emit("Step 7/7: Validating metadata...") - self.validate(enforce_min=enforce_min, strict=strict_lint) - - # Write changes - if write: - self._emit("Writing changes to disk...") - self.write_all_themes() - else: - self._emit("Dry run: no files written") - - self._emit(str(self.stats)) - return self.stats - - -def run_enrichment_pipeline( - root: Optional[Path] = None, - min_examples: int = 5, - write: bool = True, - enforce_min: bool = False, - strict: bool = False, - run_purge: bool = False, - progress_callback: Optional[Callable[[str], None]] = None, -) -> EnrichmentStats: - """Convenience function to run the enrichment pipeline. - - Args: - root: Project root directory - min_examples: Minimum number of example commanders - write: Whether to write changes (False = dry run) - enforce_min: Treat min examples violations as errors - strict: Enforce strict validation rules - run_purge: Whether to run purge step (removes ALL placeholders) - progress_callback: Optional progress callback - - Returns: - EnrichmentStats summary - """ - pipeline = ThemeEnrichmentPipeline( - root=root, - min_examples=min_examples, - progress_callback=progress_callback, - ) - return pipeline.run_all( - write=write, - enforce_min=enforce_min, - strict_lint=strict, - run_purge=run_purge - ) diff --git a/code/tagging/verify_columns.py b/code/tagging/verify_columns.py deleted file mode 100644 index 0042655..0000000 --- a/code/tagging/verify_columns.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Quick verification script to check column preservation after tagging.""" - -import pandas as pd -from code.path_util import get_processed_cards_path - -def verify_columns(): - """Verify that all expected columns are present after tagging.""" - path = get_processed_cards_path() - df = pd.read_parquet(path) - - print(f"Loaded {len(df):,} cards from {path}") - print(f"\nColumns ({len(df.columns)}):") - for col in df.columns: - print(f" - {col}") - - # Check critical columns - expected = ['isCommander', 'isBackground', 'metadataTags', 'themeTags'] - missing = [col for col in expected if col not in df.columns] - - if missing: - print(f"\n❌ MISSING COLUMNS: {missing}") - return False - - print(f"\n✅ All critical columns present!") - - # Check counts - if 'isCommander' in df.columns: - print(f" isCommander: {df['isCommander'].sum()} True") - if 'isBackground' in df.columns: - print(f" isBackground: {df['isBackground'].sum()} True") - if 'themeTags' in df.columns: - total_tags = df['themeTags'].apply(lambda x: len(x) if isinstance(x, list) else 0).sum() - print(f" themeTags: {total_tags:,} total tags") - if 'metadataTags' in df.columns: - total_meta = df['metadataTags'].apply(lambda x: len(x) if isinstance(x, list) else 0).sum() - print(f" metadataTags: {total_meta:,} total tags") - - return True - -if __name__ == "__main__": - verify_columns() diff --git a/code/tests/test_additional_theme_config.py b/code/tests/test_additional_theme_config.py index 40687e0..5c6aae7 100644 --- a/code/tests/test_additional_theme_config.py +++ b/code/tests/test_additional_theme_config.py @@ -4,23 +4,7 @@ from pathlib import Path import pytest -from code.headless_runner import resolve_additional_theme_inputs as _resolve_additional_theme_inputs - - -def _parse_theme_list(themes_str: str) -> list[str]: - """Parse semicolon-separated theme list (helper for tests).""" - if not themes_str: - return [] - themes = [t.strip() for t in themes_str.split(';') if t.strip()] - # Deduplicate while preserving order (case-insensitive) - seen = set() - result = [] - for theme in themes: - key = theme.lower() - if key not in seen: - seen.add(key) - result.append(theme) - return result +from code.headless_runner import resolve_additional_theme_inputs as _resolve_additional_theme_inputs, _parse_theme_list def _write_catalog(path: Path) -> None: diff --git a/code/tests/test_all_cards_loader.py b/code/tests/test_all_cards_loader.py deleted file mode 100644 index 44f8a38..0000000 --- a/code/tests/test_all_cards_loader.py +++ /dev/null @@ -1,408 +0,0 @@ -""" -Tests for AllCardsLoader and CardQueryBuilder - -Tests cover: -- Loading and caching behavior -- Single and batch card lookups -- Color, theme, and type filtering -- Text search -- Query builder fluent API -- Performance benchmarks -""" - -from __future__ import annotations - -import os -import tempfile -import time - -import pandas as pd -import pytest - -from code.services.all_cards_loader import AllCardsLoader -from code.services.card_query_builder import CardQueryBuilder - - -@pytest.fixture -def sample_cards_df(): - """Create a sample DataFrame for testing.""" - return pd.DataFrame( - { - "name": [ - "Sol Ring", - "Lightning Bolt", - "Counterspell", - "Giant Growth", - "Goblin Token Maker", - "Dark Ritual", - "Swords to Plowshares", - "Birds of Paradise", - ], - "colorIdentity": ["Colorless", "R", "U", "G", "R", "B", "W", "G"], - "type": [ - "Artifact", - "Instant", - "Instant", - "Instant", - "Creature — Goblin", - "Instant", - "Instant", - "Creature — Bird", - ], - "text": [ - "Add two mana", - "Deal 3 damage", - "Counter target spell", - "Target creature gets +3/+3", - "When this enters, create two 1/1 red Goblin creature tokens", - "Add three black mana", - "Exile target creature", - "Flying, Add one mana of any color", - ], - "themeTags": [ - "", - "burn,damage", - "control,counterspells", - "combat,pump", - "tokens,goblins", - "ritual,fast-mana", - "removal,exile", - "ramp,mana-dork", - ], - } - ) - - -@pytest.fixture -def sample_parquet_file(sample_cards_df): - """Create a temporary Parquet file for testing.""" - with tempfile.NamedTemporaryFile(delete=False, suffix=".parquet") as tmp: - sample_cards_df.to_parquet(tmp.name, engine="pyarrow") - yield tmp.name - os.unlink(tmp.name) - - -def test_loader_initialization(sample_parquet_file): - """Test AllCardsLoader initialization.""" - loader = AllCardsLoader(file_path=sample_parquet_file, cache_ttl=60) - assert loader.file_path == sample_parquet_file - assert loader.cache_ttl == 60 - assert loader._df is None - - -def test_loader_load(sample_parquet_file): - """Test loading Parquet file.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - df = loader.load() - assert len(df) == 8 - assert "name" in df.columns - assert "colorIdentity" in df.columns - - -def test_loader_caching(sample_parquet_file): - """Test that caching works and doesn't reload unnecessarily.""" - loader = AllCardsLoader(file_path=sample_parquet_file, cache_ttl=300) - - # First load - start_time = time.time() - df1 = loader.load() - first_load_time = time.time() - start_time - - # Second load (should use cache) - start_time = time.time() - df2 = loader.load() - cached_load_time = time.time() - start_time - - # Cache should be much faster - assert cached_load_time < first_load_time / 2 - assert df1 is df2 # Same object - - -def test_loader_force_reload(sample_parquet_file): - """Test force_reload flag.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - df1 = loader.load() - df2 = loader.load(force_reload=True) - - assert df1 is not df2 # Different objects - assert len(df1) == len(df2) # Same data - - -def test_loader_cache_expiration(sample_parquet_file): - """Test cache expiration after TTL.""" - loader = AllCardsLoader(file_path=sample_parquet_file, cache_ttl=1) - - df1 = loader.load() - time.sleep(1.1) # Wait for TTL to expire - df2 = loader.load() - - assert df1 is not df2 # Should have reloaded - - -def test_get_by_name(sample_parquet_file): - """Test single card lookup by name.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - card = loader.get_by_name("Sol Ring") - assert card is not None - assert card["name"] == "Sol Ring" - assert card["colorIdentity"] == "Colorless" - - # Non-existent card - card = loader.get_by_name("Nonexistent Card") - assert card is None - - -def test_get_by_names(sample_parquet_file): - """Test batch card lookup by names.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - cards = loader.get_by_names(["Sol Ring", "Lightning Bolt", "Counterspell"]) - assert len(cards) == 3 - assert "Sol Ring" in cards["name"].values - assert "Lightning Bolt" in cards["name"].values - - # Empty list - cards = loader.get_by_names([]) - assert len(cards) == 0 - - # Non-existent cards - cards = loader.get_by_names(["Nonexistent1", "Nonexistent2"]) - assert len(cards) == 0 - - -def test_filter_by_color_identity(sample_parquet_file): - """Test color identity filtering.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - # Single color - red_cards = loader.filter_by_color_identity(["R"]) - assert len(red_cards) == 2 - assert "Lightning Bolt" in red_cards["name"].values - assert "Goblin Token Maker" in red_cards["name"].values - - # Colorless - colorless = loader.filter_by_color_identity(["Colorless"]) - assert len(colorless) == 1 - assert colorless["name"].values[0] == "Sol Ring" - - -def test_filter_by_themes(sample_parquet_file): - """Test theme filtering.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - # Single theme - token_cards = loader.filter_by_themes(["tokens"], mode="any") - assert len(token_cards) == 1 - assert token_cards["name"].values[0] == "Goblin Token Maker" - - # Multiple themes (any) - cards = loader.filter_by_themes(["burn", "removal"], mode="any") - assert len(cards) == 2 # Lightning Bolt and Swords to Plowshares - - # Multiple themes (all) - cards = loader.filter_by_themes(["tokens", "goblins"], mode="all") - assert len(cards) == 1 - assert cards["name"].values[0] == "Goblin Token Maker" - - -def test_filter_by_type(sample_parquet_file): - """Test type filtering.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - creatures = loader.filter_by_type("Creature") - assert len(creatures) == 2 - assert "Goblin Token Maker" in creatures["name"].values - assert "Birds of Paradise" in creatures["name"].values - - instants = loader.filter_by_type("Instant") - assert len(instants) == 5 - - -def test_search(sample_parquet_file): - """Test text search.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - # Search in text - results = loader.search("token") - assert len(results) >= 1 - assert "Goblin Token Maker" in results["name"].values - - # Search in name - results = loader.search("Sol") - assert len(results) == 1 - assert results["name"].values[0] == "Sol Ring" - - # Limit results - results = loader.search("mana", limit=1) - assert len(results) == 1 - - -def test_get_stats(sample_parquet_file): - """Test stats retrieval.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - loader.load() - - stats = loader.get_stats() - assert stats["total_cards"] == 8 - assert stats["cached"] is True - assert stats["file_size_mb"] >= 0 # Small test file may round to 0 - assert "cache_age_seconds" in stats - - -def test_clear_cache(sample_parquet_file): - """Test cache clearing.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - loader.load() - - assert loader._df is not None - loader.clear_cache() - assert loader._df is None - - -def test_query_builder_basic(sample_parquet_file): - """Test basic query builder usage.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - builder = CardQueryBuilder(loader=loader) - - # Execute without filters - results = builder.execute() - assert len(results) == 8 - - # Single filter - results = builder.reset().colors(["R"]).execute() - assert len(results) == 2 - - -def test_query_builder_chaining(sample_parquet_file): - """Test query builder method chaining.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - results = ( - CardQueryBuilder(loader=loader) - .types("Creature") - .themes(["tokens"], mode="any") - .execute() - ) - assert len(results) == 1 - assert results["name"].values[0] == "Goblin Token Maker" - - -def test_query_builder_names(sample_parquet_file): - """Test query builder with specific names.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - results = ( - CardQueryBuilder(loader=loader) - .names(["Sol Ring", "Lightning Bolt"]) - .execute() - ) - assert len(results) == 2 - - -def test_query_builder_limit(sample_parquet_file): - """Test query builder limit.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - results = CardQueryBuilder(loader=loader).limit(3).execute() - assert len(results) == 3 - - -def test_query_builder_count(sample_parquet_file): - """Test query builder count method.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - count = CardQueryBuilder(loader=loader).types("Instant").count() - assert count == 5 - - -def test_query_builder_first(sample_parquet_file): - """Test query builder first method.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - card = CardQueryBuilder(loader=loader).colors(["R"]).first() - assert card is not None - assert card["colorIdentity"] == "R" - - # No results - card = CardQueryBuilder(loader=loader).colors(["X"]).first() - assert card is None - - -def test_query_builder_complex(sample_parquet_file): - """Test complex query with multiple filters.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - - results = ( - CardQueryBuilder(loader=loader) - .types("Instant") - .colors(["R"]) - .search("damage") - .limit(5) - .execute() - ) - assert len(results) == 1 - assert results["name"].values[0] == "Lightning Bolt" - - -def test_performance_single_lookup(sample_parquet_file): - """Benchmark single card lookup performance.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - loader.load() # Warm up cache - - start = time.time() - for _ in range(100): - loader.get_by_name("Sol Ring") - elapsed = time.time() - start - - avg_time_ms = (elapsed / 100) * 1000 - print(f"\nSingle lookup avg: {avg_time_ms:.3f}ms") - assert avg_time_ms < 10 # Should be <10ms per lookup - - -def test_performance_batch_lookup(sample_parquet_file): - """Benchmark batch card lookup performance.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - loader.load() # Warm up cache - - names = ["Sol Ring", "Lightning Bolt", "Counterspell"] - - start = time.time() - for _ in range(100): - loader.get_by_names(names) - elapsed = time.time() - start - - avg_time_ms = (elapsed / 100) * 1000 - print(f"\nBatch lookup (3 cards) avg: {avg_time_ms:.3f}ms") - assert avg_time_ms < 15 # Should be <15ms per batch - - -def test_performance_filter_by_color(sample_parquet_file): - """Benchmark color filtering performance.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - loader.load() # Warm up cache - - start = time.time() - for _ in range(100): - loader.filter_by_color_identity(["R"]) - elapsed = time.time() - start - - avg_time_ms = (elapsed / 100) * 1000 - print(f"\nColor filter avg: {avg_time_ms:.3f}ms") - assert avg_time_ms < 20 # Should be <20ms per filter - - -def test_performance_search(sample_parquet_file): - """Benchmark text search performance.""" - loader = AllCardsLoader(file_path=sample_parquet_file) - loader.load() # Warm up cache - - start = time.time() - for _ in range(100): - loader.search("token", limit=100) - elapsed = time.time() - start - - avg_time_ms = (elapsed / 100) * 1000 - print(f"\nText search avg: {avg_time_ms:.3f}ms") - assert avg_time_ms < 50 # Should be <50ms per search diff --git a/code/tests/test_bracket_policy_applier.py b/code/tests/test_bracket_policy_applier.py index 17ad9c8..d7d5dfe 100644 --- a/code/tests/test_bracket_policy_applier.py +++ b/code/tests/test_bracket_policy_applier.py @@ -11,9 +11,9 @@ def _load_applier(): root = Path(__file__).resolve().parents[2] mod_path = root / 'code' / 'tagging' / 'bracket_policy_applier.py' spec = importlib.util.spec_from_file_location('bracket_policy_applier', str(mod_path)) - mod = importlib.util.module_from_spec(spec) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] assert spec and spec.loader - spec.loader.exec_module(mod) + spec.loader.exec_module(mod) # type: ignore[assignment] return mod diff --git a/code/tests/test_card_aggregator.py b/code/tests/test_card_aggregator.py deleted file mode 100644 index 84d6ff3..0000000 --- a/code/tests/test_card_aggregator.py +++ /dev/null @@ -1,340 +0,0 @@ -""" -Tests for Card Aggregator - -Tests the CardAggregator class functionality including: -- Full aggregation of multiple CSV files -- Deduplication (keeping most recent) -- Exclusion of master files (cards.csv, commander_cards.csv) -- Validation of output -- Version rotation -""" - -from __future__ import annotations - -import json -import os -import tempfile -from datetime import datetime, timedelta -from pathlib import Path - -import pandas as pd -import pytest - -from code.file_setup.card_aggregator import CardAggregator - - -@pytest.fixture -def temp_dirs(): - """Create temporary directories for testing.""" - with tempfile.TemporaryDirectory() as source_dir, tempfile.TemporaryDirectory() as output_dir: - yield source_dir, output_dir - - -@pytest.fixture -def sample_card_data(): - """Sample card data for testing.""" - return { - "name": ["Sol Ring", "Lightning Bolt", "Counterspell"], - "faceName": ["Sol Ring", "Lightning Bolt", "Counterspell"], - "colorIdentity": ["Colorless", "R", "U"], - "manaCost": ["{1}", "{R}", "{U}{U}"], - "manaValue": [1, 1, 2], - "type": ["Artifact", "Instant", "Instant"], - "text": [ - "Add two colorless mana", - "Deal 3 damage", - "Counter target spell", - ], - } - - -def test_ensure_output_dir(temp_dirs): - """Test that output directory is created.""" - _, output_dir = temp_dirs - aggregator = CardAggregator(output_dir=output_dir) - - assert os.path.exists(output_dir) - assert aggregator.output_dir == output_dir - - -def test_get_card_csvs_excludes_master_files(temp_dirs): - """Test that cards.csv and commander_cards.csv are excluded.""" - source_dir, _ = temp_dirs - - # Create test files - Path(source_dir, "cards.csv").touch() - Path(source_dir, "commander_cards.csv").touch() - Path(source_dir, "blue_cards.csv").touch() - Path(source_dir, "red_cards.csv").touch() - Path(source_dir, ".temp_cards.csv").touch() - Path(source_dir, "_temp_cards.csv").touch() - - aggregator = CardAggregator() - csv_files = aggregator.get_card_csvs(source_dir) - - # Should only include blue_cards.csv and red_cards.csv - basenames = [os.path.basename(f) for f in csv_files] - assert "blue_cards.csv" in basenames - assert "red_cards.csv" in basenames - assert "cards.csv" not in basenames - assert "commander_cards.csv" not in basenames - assert ".temp_cards.csv" not in basenames - assert "_temp_cards.csv" not in basenames - assert len(csv_files) == 2 - - -def test_deduplicate_cards(sample_card_data): - """Test that duplicate cards are removed, keeping the last occurrence.""" - # Create DataFrame with duplicates - df = pd.DataFrame(sample_card_data) - - # Add duplicate Sol Ring with different text - duplicate_data = { - "name": ["Sol Ring"], - "faceName": ["Sol Ring"], - "colorIdentity": ["Colorless"], - "manaCost": ["{1}"], - "manaValue": [1], - "type": ["Artifact"], - "text": ["Add two colorless mana (updated)"], - } - df_duplicate = pd.DataFrame(duplicate_data) - df_combined = pd.concat([df, df_duplicate], ignore_index=True) - - # Should have 4 rows before deduplication - assert len(df_combined) == 4 - - aggregator = CardAggregator() - df_deduped = aggregator.deduplicate_cards(df_combined) - - # Should have 3 rows after deduplication - assert len(df_deduped) == 3 - - # Should keep the last Sol Ring (updated text) - sol_ring = df_deduped[df_deduped["name"] == "Sol Ring"].iloc[0] - assert "updated" in sol_ring["text"] - - -def test_aggregate_all(temp_dirs, sample_card_data): - """Test full aggregation of multiple CSV files.""" - source_dir, output_dir = temp_dirs - - # Create test CSV files - df1 = pd.DataFrame( - { - "name": ["Sol Ring", "Lightning Bolt"], - "faceName": ["Sol Ring", "Lightning Bolt"], - "colorIdentity": ["Colorless", "R"], - "manaCost": ["{1}", "{R}"], - "manaValue": [1, 1], - "type": ["Artifact", "Instant"], - "text": ["Add two colorless mana", "Deal 3 damage"], - } - ) - - df2 = pd.DataFrame( - { - "name": ["Counterspell", "Path to Exile"], - "faceName": ["Counterspell", "Path to Exile"], - "colorIdentity": ["U", "W"], - "manaCost": ["{U}{U}", "{W}"], - "manaValue": [2, 1], - "type": ["Instant", "Instant"], - "text": ["Counter target spell", "Exile target creature"], - } - ) - - df1.to_csv(os.path.join(source_dir, "blue_cards.csv"), index=False) - df2.to_csv(os.path.join(source_dir, "white_cards.csv"), index=False) - - # Create excluded files (should be ignored) - df1.to_csv(os.path.join(source_dir, "cards.csv"), index=False) - df1.to_csv(os.path.join(source_dir, "commander_cards.csv"), index=False) - - # Aggregate - aggregator = CardAggregator(output_dir=output_dir) - output_path = os.path.join(output_dir, "all_cards.parquet") - stats = aggregator.aggregate_all(source_dir, output_path) - - # Verify stats - assert stats["files_processed"] == 2 # Only 2 files (excluded 2) - assert stats["total_cards"] == 4 # 2 + 2 cards - assert stats["duplicates_removed"] == 0 - assert os.path.exists(output_path) - - # Verify output - df_result = pd.read_parquet(output_path) - assert len(df_result) == 4 - assert "Sol Ring" in df_result["name"].values - assert "Counterspell" in df_result["name"].values - - -def test_aggregate_with_duplicates(temp_dirs): - """Test aggregation with duplicate cards across files.""" - source_dir, output_dir = temp_dirs - - # Create two files with the same card - df1 = pd.DataFrame( - { - "name": ["Sol Ring"], - "faceName": ["Sol Ring"], - "colorIdentity": ["Colorless"], - "manaCost": ["{1}"], - "manaValue": [1], - "type": ["Artifact"], - "text": ["Version 1"], - } - ) - - df2 = pd.DataFrame( - { - "name": ["Sol Ring"], - "faceName": ["Sol Ring"], - "colorIdentity": ["Colorless"], - "manaCost": ["{1}"], - "manaValue": [1], - "type": ["Artifact"], - "text": ["Version 2 (newer)"], - } - ) - - # Write file1 first, then file2 (file2 is newer) - file1 = os.path.join(source_dir, "file1.csv") - file2 = os.path.join(source_dir, "file2.csv") - df1.to_csv(file1, index=False) - df2.to_csv(file2, index=False) - - # Make file2 newer by touching it - os.utime(file2, (datetime.now().timestamp() + 1, datetime.now().timestamp() + 1)) - - # Aggregate - aggregator = CardAggregator(output_dir=output_dir) - output_path = os.path.join(output_dir, "all_cards.parquet") - stats = aggregator.aggregate_all(source_dir, output_path) - - # Should have removed 1 duplicate - assert stats["duplicates_removed"] == 1 - assert stats["total_cards"] == 1 - - # Should keep the newer version (file2) - df_result = pd.read_parquet(output_path) - assert "Version 2 (newer)" in df_result["text"].iloc[0] - - -def test_validate_output(temp_dirs, sample_card_data): - """Test output validation.""" - source_dir, output_dir = temp_dirs - - # Create and aggregate test data - df = pd.DataFrame(sample_card_data) - df.to_csv(os.path.join(source_dir, "test_cards.csv"), index=False) - - aggregator = CardAggregator(output_dir=output_dir) - output_path = os.path.join(output_dir, "all_cards.parquet") - aggregator.aggregate_all(source_dir, output_path) - - # Validate - is_valid, errors = aggregator.validate_output(output_path, source_dir) - - assert is_valid - assert len(errors) == 0 - - -def test_validate_missing_file(temp_dirs): - """Test validation with missing output file.""" - source_dir, output_dir = temp_dirs - - aggregator = CardAggregator(output_dir=output_dir) - output_path = os.path.join(output_dir, "nonexistent.parquet") - - is_valid, errors = aggregator.validate_output(output_path, source_dir) - - assert not is_valid - assert len(errors) > 0 - assert "not found" in errors[0].lower() - - -def test_rotate_versions(temp_dirs, sample_card_data): - """Test version rotation.""" - _, output_dir = temp_dirs - - # Create initial file - df = pd.DataFrame(sample_card_data) - output_path = os.path.join(output_dir, "all_cards.parquet") - df.to_parquet(output_path) - - aggregator = CardAggregator(output_dir=output_dir) - - # Rotate versions - aggregator.rotate_versions(output_path, keep_versions=3) - - # Should have created v1 - v1_path = os.path.join(output_dir, "all_cards_v1.parquet") - assert os.path.exists(v1_path) - assert not os.path.exists(output_path) # Original moved to v1 - - # Create new file and rotate again - df.to_parquet(output_path) - aggregator.rotate_versions(output_path, keep_versions=3) - - # Should have v1 and v2 - v2_path = os.path.join(output_dir, "all_cards_v2.parquet") - assert os.path.exists(v1_path) - assert os.path.exists(v2_path) - - -def test_detect_changes(temp_dirs): - """Test change detection for incremental updates.""" - source_dir, output_dir = temp_dirs - - # Create metadata file - metadata_path = os.path.join(output_dir, ".aggregate_metadata.json") - past_time = (datetime.now() - timedelta(hours=1)).isoformat() - metadata = {"timestamp": past_time} - with open(metadata_path, "w") as f: - json.dump(metadata, f) - - # Create CSV files (one old, one new) - old_file = os.path.join(source_dir, "old_cards.csv") - new_file = os.path.join(source_dir, "new_cards.csv") - - df = pd.DataFrame({"name": ["Test Card"]}) - df.to_csv(old_file, index=False) - df.to_csv(new_file, index=False) - - # Make old_file older than metadata - old_time = (datetime.now() - timedelta(hours=2)).timestamp() - os.utime(old_file, (old_time, old_time)) - - aggregator = CardAggregator(output_dir=output_dir) - changed_files = aggregator.detect_changes(source_dir, metadata_path) - - # Should only detect new_file as changed - assert len(changed_files) == 1 - assert os.path.basename(changed_files[0]) == "new_cards.csv" - - -def test_aggregate_all_no_files(temp_dirs): - """Test aggregation with no CSV files.""" - source_dir, output_dir = temp_dirs - - aggregator = CardAggregator(output_dir=output_dir) - output_path = os.path.join(output_dir, "all_cards.parquet") - - with pytest.raises(ValueError, match="No CSV files found"): - aggregator.aggregate_all(source_dir, output_path) - - -def test_aggregate_all_empty_files(temp_dirs): - """Test aggregation with empty CSV files.""" - source_dir, output_dir = temp_dirs - - # Create empty CSV file - empty_file = os.path.join(source_dir, "empty.csv") - pd.DataFrame().to_csv(empty_file, index=False) - - aggregator = CardAggregator(output_dir=output_dir) - output_path = os.path.join(output_dir, "all_cards.parquet") - - with pytest.raises(ValueError, match="No valid CSV files"): - aggregator.aggregate_all(source_dir, output_path) diff --git a/code/tests/test_card_index_color_identity_edge_cases.py b/code/tests/test_card_index_color_identity_edge_cases.py index 0969bf3..548ab0c 100644 --- a/code/tests/test_card_index_color_identity_edge_cases.py +++ b/code/tests/test_card_index_color_identity_edge_cases.py @@ -1,15 +1,9 @@ from __future__ import annotations -import pytest from pathlib import Path from code.web.services import card_index -# M4 (Parquet Migration): This test relied on injecting custom CSV data via CARD_INDEX_EXTRA_CSV, -# which is no longer supported. The card_index now loads from the global all_cards.parquet file. -# Skipping this test as custom data injection is not possible with unified Parquet. -pytestmark = pytest.mark.skip(reason="M4: CARD_INDEX_EXTRA_CSV removed, cannot inject test data") - CSV_CONTENT = """name,themeTags,colorIdentity,manaCost,rarity Hybrid Test,"Blink",WG,{W/G}{W/G},uncommon Devoid Test,"Blink",C,3U,uncommon @@ -30,8 +24,8 @@ def test_card_index_color_identity_list_handles_edge_cases(tmp_path, monkeypatch csv_path = write_csv(tmp_path) monkeypatch.setenv("CARD_INDEX_EXTRA_CSV", str(csv_path)) # Force rebuild - card_index._CARD_INDEX.clear() - card_index._CARD_INDEX_MTIME = None + card_index._CARD_INDEX.clear() # type: ignore + card_index._CARD_INDEX_MTIME = None # type: ignore card_index.maybe_build_index() pool = card_index.get_tag_pool("Blink") diff --git a/code/tests/test_card_index_rarity_normalization.py b/code/tests/test_card_index_rarity_normalization.py index 70afa67..08b8e5d 100644 --- a/code/tests/test_card_index_rarity_normalization.py +++ b/code/tests/test_card_index_rarity_normalization.py @@ -1,12 +1,6 @@ -import pytest import csv from code.web.services import card_index -# M4 (Parquet Migration): This test relied on monkeypatching CARD_FILES_GLOB to inject custom CSV data, -# which is no longer supported. The card_index now loads from the global all_cards.parquet file. -# Skipping this test as custom data injection is not possible with unified Parquet. -pytestmark = pytest.mark.skip(reason="M4: CARD_FILES_GLOB removed, cannot inject test data") - def test_rarity_normalization_and_duplicate_handling(tmp_path, monkeypatch): # Create a temporary CSV simulating duplicate rarities and variant casing csv_path = tmp_path / "cards.csv" diff --git a/code/tests/test_combo_tag_applier.py b/code/tests/test_combo_tag_applier.py index 29130f9..6fe7c30 100644 --- a/code/tests/test_combo_tag_applier.py +++ b/code/tests/test_combo_tag_applier.py @@ -4,7 +4,6 @@ import json from pathlib import Path import pandas as pd -import pytest from tagging.combo_tag_applier import apply_combo_tags @@ -14,7 +13,6 @@ def _write_csv(dirpath: Path, color: str, rows: list[dict]): df.to_csv(dirpath / f"{color}_cards.csv", index=False) -@pytest.mark.skip(reason="M4: apply_combo_tags no longer accepts colors/csv_dir parameters - uses unified Parquet") def test_apply_combo_tags_bidirectional(tmp_path: Path): # Arrange: create a minimal CSV for blue with two combo cards csv_dir = tmp_path / "csv" @@ -57,13 +55,12 @@ def test_apply_combo_tags_bidirectional(tmp_path: Path): assert "Kiki-Jiki, Mirror Breaker" in row_conscripts.get("comboTags") -@pytest.mark.skip(reason="M4: apply_combo_tags no longer accepts colors/csv_dir parameters - uses unified Parquet") def test_name_normalization_curly_apostrophes(tmp_path: Path): csv_dir = tmp_path / "csv" csv_dir.mkdir(parents=True) # Use curly apostrophe in CSV name, straight in combos rows = [ - {"name": "Thassa's Oracle", "themeTags": "[]", "creatureTypes": "[]"}, + {"name": "Thassa’s Oracle", "themeTags": "[]", "creatureTypes": "[]"}, {"name": "Demonic Consultation", "themeTags": "[]", "creatureTypes": "[]"}, ] _write_csv(csv_dir, "blue", rows) @@ -81,11 +78,10 @@ def test_name_normalization_curly_apostrophes(tmp_path: Path): counts = apply_combo_tags(colors=["blue"], combos_path=str(combos_path), csv_dir=str(csv_dir)) assert counts.get("blue", 0) >= 1 df = pd.read_csv(csv_dir / "blue_cards.csv") - row = df[df["name"] == "Thassa's Oracle"].iloc[0] + row = df[df["name"] == "Thassa’s Oracle"].iloc[0] assert "Demonic Consultation" in row["comboTags"] -@pytest.mark.skip(reason="M4: apply_combo_tags no longer accepts colors/csv_dir parameters - uses unified Parquet") def test_split_card_face_matching(tmp_path: Path): csv_dir = tmp_path / "csv" csv_dir.mkdir(parents=True) diff --git a/code/tests/test_commander_build_cta.py b/code/tests/test_commander_build_cta.py index 337edf7..d61387a 100644 --- a/code/tests/test_commander_build_cta.py +++ b/code/tests/test_commander_build_cta.py @@ -8,7 +8,7 @@ from urllib.parse import parse_qs, urlparse import pytest from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore from code.web.services.commander_catalog_loader import clear_commander_catalog_cache diff --git a/code/tests/test_commander_catalog_loader.py b/code/tests/test_commander_catalog_loader.py index 4d7e3e1..cdc958c 100644 --- a/code/tests/test_commander_catalog_loader.py +++ b/code/tests/test_commander_catalog_loader.py @@ -1,5 +1,8 @@ from __future__ import annotations +import csv +import json +import time from pathlib import Path import pytest @@ -11,48 +14,118 @@ FIXTURE_DIR = Path(__file__).resolve().parents[2] / "csv_files" / "testdata" def _set_csv_dir(monkeypatch: pytest.MonkeyPatch, path: Path) -> None: - """Legacy CSV directory setter - kept for compatibility but no longer used in M4.""" monkeypatch.setenv("CSV_FILES_DIR", str(path)) loader.clear_commander_catalog_cache() def test_commander_catalog_basic_normalization(monkeypatch: pytest.MonkeyPatch) -> None: - """Test commander catalog loading from Parquet (M4: updated for Parquet migration).""" - # Note: Commander catalog now loads from all_cards.parquet, not commander_cards.csv - # This test validates the real production data instead of test fixtures - + _set_csv_dir(monkeypatch, FIXTURE_DIR) + catalog = loader.load_commander_catalog() - # Changed: source_path now points to all_cards.parquet - assert catalog.source_path.name == "all_cards.parquet" - # Changed: Real data has 2800+ commanders, not just 4 test fixtures - assert len(catalog.entries) > 2700 # At least 2700 commanders + assert catalog.source_path.name == "commander_cards.csv" + assert len(catalog.entries) == 4 - # Test a known commander from production data - krenko = catalog.by_slug.get("krenko-mob-boss") - if krenko: # May not be in every version of the data - assert krenko.display_name == "Krenko, Mob Boss" - assert krenko.color_identity == ("R",) - assert krenko.color_identity_key == "R" - assert not krenko.is_colorless - assert "Goblin Kindred" in krenko.themes or "goblin kindred" in [t.lower() for t in krenko.themes] + krenko = catalog.by_slug["krenko-mob-boss"] + assert krenko.display_name == "Krenko, Mob Boss" + assert krenko.color_identity == ("R",) + assert krenko.color_identity_key == "R" + assert not krenko.is_colorless + assert krenko.themes == ("Goblin Kindred",) + assert "goblin kindred" in krenko.theme_tokens + assert "version=small" in krenko.image_small_url + assert "exact=Krenko%2C%20Mob%20Boss" in krenko.image_small_url + + traxos = catalog.by_slug["traxos-scourge-of-kroog"] + assert traxos.is_colorless + assert traxos.color_identity == () + assert traxos.color_identity_key == "C" + + atraxa = catalog.by_slug["atraxa-praetors-voice"] + assert atraxa.color_identity == ("W", "U", "B", "G") + assert atraxa.color_identity_key == "WUBG" + assert atraxa.is_partner is False + assert atraxa.supports_backgrounds is False def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Test commander catalog cache invalidation. - - M4 NOTE: This test is skipped because commander data now comes from all_cards.parquet, - which is managed globally, not per-test-directory. Cache invalidation is tested - at the file level in test_data_loader.py. - """ - pytest.skip("M4: Cache invalidation testing moved to integration level (all_cards.parquet managed globally)") + fixture_csv = FIXTURE_DIR / "commander_cards.csv" + work_dir = tmp_path / "csv" + work_dir.mkdir() + target_csv = work_dir / "commander_cards.csv" + target_csv.write_text(fixture_csv.read_text(encoding="utf-8"), encoding="utf-8") + + _set_csv_dir(monkeypatch, work_dir) + + first = loader.load_commander_catalog() + again = loader.load_commander_catalog() + assert again is first + + time.sleep(1.1) # ensure mtime tick on systems with 1s resolution + target_csv.write_text( + fixture_csv.read_text(encoding="utf-8") + + "\"Zada, Hedron Grinder\",\"Zada, Hedron Grinder\",9999,R,R,{3}{R},4,\"Legendary Creature — Goblin\",\"['Goblin']\",\"Test\",3,3,,\"['Goblin Kindred']\",normal,\n", + encoding="utf-8", + ) + + updated = loader.load_commander_catalog() + assert updated is not first + assert "zada-hedron-grinder" in updated.by_slug def test_commander_theme_labels_unescape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Test theme label escaping in commander data. - - M4 NOTE: This test is skipped because we can't easily inject custom test data - into all_cards.parquet without affecting other tests. The theme label unescaping - logic is still tested in the theme tag parsing tests. - """ - pytest.skip("M4: Custom test data injection not supported with global all_cards.parquet") + custom_dir = tmp_path / "csv_custom" + custom_dir.mkdir() + csv_path = custom_dir / "commander_cards.csv" + with csv_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow( + [ + "name", + "faceName", + "edhrecRank", + "colorIdentity", + "colors", + "manaCost", + "manaValue", + "type", + "creatureTypes", + "text", + "power", + "toughness", + "keywords", + "themeTags", + "layout", + "side", + ] + ) + theme_value = json.dumps([r"\+2/\+2 Counters", "+1/+1 Counters"]) + writer.writerow( + [ + "Escape Tester", + "Escape Tester", + "1234", + "R", + "R", + "{3}{R}", + "4", + "Legendary Creature — Archer", + "['Archer']", + "Test", + "2", + "2", + "", + theme_value, + "normal", + "", + ] + ) + + _set_csv_dir(monkeypatch, custom_dir) + + catalog = loader.load_commander_catalog() + assert len(catalog.entries) == 1 + + record = catalog.entries[0] + assert record.themes == ("+2/+2 Counters", "+1/+1 Counters") + assert "+2/+2 counters" in record.theme_tokens diff --git a/code/tests/test_commander_telemetry.py b/code/tests/test_commander_telemetry.py index d978252..d566da4 100644 --- a/code/tests/test_commander_telemetry.py +++ b/code/tests/test_commander_telemetry.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore from code.web.services import telemetry from code.web.services.commander_catalog_loader import clear_commander_catalog_cache diff --git a/code/tests/test_commanders_route.py b/code/tests/test_commanders_route.py index bf724f7..6f4d064 100644 --- a/code/tests/test_commanders_route.py +++ b/code/tests/test_commanders_route.py @@ -7,7 +7,7 @@ from types import SimpleNamespace import pytest from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore from code.web.routes import commanders from code.web.services import commander_catalog_loader from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog diff --git a/code/tests/test_data_loader.py b/code/tests/test_data_loader.py deleted file mode 100644 index 9b15783..0000000 --- a/code/tests/test_data_loader.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Tests for DataLoader abstraction layer. - -Tests CSV/Parquet reading, writing, conversion, and schema validation. -""" - -import os -import shutil -import tempfile - -import pandas as pd -import pytest - -from code.file_setup.data_loader import DataLoader, validate_schema - - -@pytest.fixture -def sample_card_data(): - """Sample card data for testing.""" - return pd.DataFrame({ - "name": ["Sol Ring", "Lightning Bolt", "Counterspell"], - "colorIdentity": ["C", "R", "U"], - "type": ["Artifact", "Instant", "Instant"], # MTGJSON uses 'type' not 'types' - "keywords": ["", "", ""], - "manaValue": [1.0, 1.0, 2.0], - "text": ["Tap: Add 2 mana", "Deal 3 damage", "Counter spell"], - "power": ["", "", ""], - "toughness": ["", "", ""], - }) - - -@pytest.fixture -def temp_dir(): - """Temporary directory for test files.""" - tmpdir = tempfile.mkdtemp() - yield tmpdir - shutil.rmtree(tmpdir, ignore_errors=True) - - -class TestDataLoader: - """Test DataLoader class functionality.""" - - def test_read_csv(self, sample_card_data, temp_dir): - """Test reading CSV files.""" - csv_path = os.path.join(temp_dir, "test.csv") - sample_card_data.to_csv(csv_path, index=False) - - loader = DataLoader() - df = loader.read_cards(csv_path) - - assert len(df) == 3 - assert "name" in df.columns - assert df["name"].iloc[0] == "Sol Ring" - - def test_read_parquet(self, sample_card_data, temp_dir): - """Test reading Parquet files.""" - parquet_path = os.path.join(temp_dir, "test.parquet") - sample_card_data.to_parquet(parquet_path, index=False) - - loader = DataLoader() - df = loader.read_cards(parquet_path) - - assert len(df) == 3 - assert "name" in df.columns - assert df["name"].iloc[0] == "Sol Ring" - - def test_read_with_columns(self, sample_card_data, temp_dir): - """Test column filtering (Parquet optimization).""" - parquet_path = os.path.join(temp_dir, "test.parquet") - sample_card_data.to_parquet(parquet_path, index=False) - - loader = DataLoader() - df = loader.read_cards(parquet_path, columns=["name", "manaValue"]) - - assert len(df) == 3 - assert len(df.columns) == 2 - assert "name" in df.columns - assert "manaValue" in df.columns - assert "colorIdentity" not in df.columns - - def test_write_csv(self, sample_card_data, temp_dir): - """Test writing CSV files.""" - csv_path = os.path.join(temp_dir, "output.csv") - - loader = DataLoader() - loader.write_cards(sample_card_data, csv_path) - - assert os.path.exists(csv_path) - df = pd.read_csv(csv_path) - assert len(df) == 3 - - def test_write_parquet(self, sample_card_data, temp_dir): - """Test writing Parquet files.""" - parquet_path = os.path.join(temp_dir, "output.parquet") - - loader = DataLoader() - loader.write_cards(sample_card_data, parquet_path) - - assert os.path.exists(parquet_path) - df = pd.read_parquet(parquet_path) - assert len(df) == 3 - - def test_format_detection_csv(self, sample_card_data, temp_dir): - """Test automatic CSV format detection.""" - csv_path = os.path.join(temp_dir, "test.csv") - sample_card_data.to_csv(csv_path, index=False) - - loader = DataLoader(format="auto") - df = loader.read_cards(csv_path) - - assert len(df) == 3 - - def test_format_detection_parquet(self, sample_card_data, temp_dir): - """Test automatic Parquet format detection.""" - parquet_path = os.path.join(temp_dir, "test.parquet") - sample_card_data.to_parquet(parquet_path, index=False) - - loader = DataLoader(format="auto") - df = loader.read_cards(parquet_path) - - assert len(df) == 3 - - def test_convert_csv_to_parquet(self, sample_card_data, temp_dir): - """Test CSV to Parquet conversion.""" - csv_path = os.path.join(temp_dir, "input.csv") - parquet_path = os.path.join(temp_dir, "output.parquet") - - sample_card_data.to_csv(csv_path, index=False) - - loader = DataLoader() - loader.convert(csv_path, parquet_path) - - assert os.path.exists(parquet_path) - df = pd.read_parquet(parquet_path) - assert len(df) == 3 - - def test_convert_parquet_to_csv(self, sample_card_data, temp_dir): - """Test Parquet to CSV conversion.""" - parquet_path = os.path.join(temp_dir, "input.parquet") - csv_path = os.path.join(temp_dir, "output.csv") - - sample_card_data.to_parquet(parquet_path, index=False) - - loader = DataLoader() - loader.convert(parquet_path, csv_path) - - assert os.path.exists(csv_path) - df = pd.read_csv(csv_path) - assert len(df) == 3 - - def test_file_not_found(self, temp_dir): - """Test error handling for missing files.""" - loader = DataLoader() - - with pytest.raises(FileNotFoundError): - loader.read_cards(os.path.join(temp_dir, "nonexistent.csv")) - - def test_unsupported_format(self, temp_dir): - """Test error handling for unsupported formats.""" - with pytest.raises(ValueError, match="Unsupported format"): - DataLoader(format="xlsx") - - -class TestSchemaValidation: - """Test schema validation functionality.""" - - def test_valid_schema(self, sample_card_data): - """Test validation with valid schema.""" - # Should not raise - validate_schema(sample_card_data) - - def test_missing_columns(self): - """Test validation with missing required columns.""" - df = pd.DataFrame({ - "name": ["Sol Ring"], - "type": ["Artifact"], # MTGJSON uses 'type' - }) - - with pytest.raises(ValueError, match="missing required columns"): - validate_schema(df) - - def test_custom_required_columns(self, sample_card_data): - """Test validation with custom required columns.""" - # Should not raise with minimal requirements - validate_schema(sample_card_data, required=["name", "type"]) - - def test_empty_dataframe(self): - """Test validation with empty DataFrame.""" - df = pd.DataFrame() - - with pytest.raises(ValueError): - validate_schema(df) - - -class TestBatchParquet: - """Test batch Parquet functionality for tagging workflow.""" - - def test_write_batch_parquet(self, sample_card_data, temp_dir): - """Test writing batch Parquet files.""" - loader = DataLoader() - batches_dir = os.path.join(temp_dir, "batches") - - # Write batch with tag - batch_path = loader.write_batch_parquet( - sample_card_data, - batch_id=0, - tag="white", - batches_dir=batches_dir - ) - - assert os.path.exists(batch_path) - assert batch_path.endswith("batch_0_white.parquet") - - # Verify content - df = loader.read_cards(batch_path) - assert len(df) == 3 - assert list(df["name"]) == ["Sol Ring", "Lightning Bolt", "Counterspell"] - - def test_write_batch_parquet_no_tag(self, sample_card_data, temp_dir): - """Test writing batch without tag.""" - loader = DataLoader() - batches_dir = os.path.join(temp_dir, "batches") - - batch_path = loader.write_batch_parquet( - sample_card_data, - batch_id=1, - batches_dir=batches_dir - ) - - assert batch_path.endswith("batch_1.parquet") - - def test_merge_batches(self, sample_card_data, temp_dir): - """Test merging batch files.""" - loader = DataLoader() - batches_dir = os.path.join(temp_dir, "batches") - output_path = os.path.join(temp_dir, "all_cards.parquet") - - # Create multiple batches - batch1 = sample_card_data.iloc[:2] # First 2 cards - batch2 = sample_card_data.iloc[2:] # Last card - - loader.write_batch_parquet(batch1, batch_id=0, tag="white", batches_dir=batches_dir) - loader.write_batch_parquet(batch2, batch_id=1, tag="blue", batches_dir=batches_dir) - - # Merge batches - merged_df = loader.merge_batches( - output_path=output_path, - batches_dir=batches_dir, - cleanup=True - ) - - # Verify merged data - assert len(merged_df) == 3 - assert os.path.exists(output_path) - - # Verify batches directory cleaned up - assert not os.path.exists(batches_dir) - - def test_merge_batches_no_cleanup(self, sample_card_data, temp_dir): - """Test merging without cleanup.""" - loader = DataLoader() - batches_dir = os.path.join(temp_dir, "batches") - output_path = os.path.join(temp_dir, "all_cards.parquet") - - loader.write_batch_parquet(sample_card_data, batch_id=0, batches_dir=batches_dir) - - merged_df = loader.merge_batches( - output_path=output_path, - batches_dir=batches_dir, - cleanup=False - ) - - assert len(merged_df) == 3 - assert os.path.exists(batches_dir) # Should still exist - - def test_merge_batches_no_files(self, temp_dir): - """Test error handling when no batch files exist.""" - loader = DataLoader() - batches_dir = os.path.join(temp_dir, "empty_batches") - os.makedirs(batches_dir, exist_ok=True) - - with pytest.raises(FileNotFoundError, match="No batch files found"): - loader.merge_batches(batches_dir=batches_dir) - diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py index 7ac06c5..4d38a2b 100644 --- a/code/tests/test_diagnostics.py +++ b/code/tests/test_diagnostics.py @@ -24,7 +24,7 @@ def load_app_with_env(**env: str) -> types.ModuleType: os.environ.pop(key, None) for k, v in env.items(): os.environ[k] = v - import code.web.app as app_module + import code.web.app as app_module # type: ignore importlib.reload(app_module) return app_module diff --git a/code/tests/test_editorial_governance_phase_d_closeout.py b/code/tests/test_editorial_governance_phase_d_closeout.py index 83b1494..e3713e0 100644 --- a/code/tests/test_editorial_governance_phase_d_closeout.py +++ b/code/tests/test_editorial_governance_phase_d_closeout.py @@ -50,7 +50,7 @@ def _load_catalog() -> Dict[str, Any]: def test_deterministic_build_under_seed(): # Import build after setting seed env os.environ['EDITORIAL_SEED'] = '999' - from scripts.build_theme_catalog import build_catalog + from scripts.build_theme_catalog import build_catalog # type: ignore first = build_catalog(limit=0, verbose=False) second = build_catalog(limit=0, verbose=False) # Drop volatile metadata_info/timestamp fields before comparison @@ -106,7 +106,7 @@ def test_metadata_info_block_coverage(): def test_synergy_commanders_exclusion_of_examples(): - import yaml + import yaml # type: ignore pattern = re.compile(r" - Synergy \(.*\)$") violations: List[str] = [] for p in CATALOG_DIR.glob('*.yml'): @@ -128,7 +128,7 @@ def test_synergy_commanders_exclusion_of_examples(): def test_mapping_trigger_specialization_guard(): - import yaml + import yaml # type: ignore assert MAPPING.exists(), "description_mapping.yml missing" mapping_yaml = yaml.safe_load(MAPPING.read_text(encoding='utf-8')) or [] triggers: Set[str] = set() diff --git a/code/tests/test_home_actions_buttons.py b/code/tests/test_home_actions_buttons.py index d9aaec3..0dd2815 100644 --- a/code/tests/test_home_actions_buttons.py +++ b/code/tests/test_home_actions_buttons.py @@ -20,7 +20,7 @@ def load_app_with_env(**env: str) -> types.ModuleType: os.environ.pop(key, None) for k, v in env.items(): os.environ[k] = v - import code.web.app as app_module + import code.web.app as app_module # type: ignore importlib.reload(app_module) return app_module diff --git a/code/tests/test_land_summary_totals.py b/code/tests/test_land_summary_totals.py index b08ed16..9fddcb2 100644 --- a/code/tests/test_land_summary_totals.py +++ b/code/tests/test_land_summary_totals.py @@ -14,7 +14,7 @@ class DummyBuilder(ReportingMixin): self.card_library = card_library self.color_identity = colors self.output_lines: List[str] = [] - self.output_func = self.output_lines.append + self.output_func = self.output_lines.append # type: ignore[assignment] self._full_cards_df = None self._combined_cards_df = None self.include_exclude_diagnostics = None diff --git a/code/tests/test_lightning_direct.py b/code/tests/test_lightning_direct.py index 2fe4028..747e5ee 100644 --- a/code/tests/test_lightning_direct.py +++ b/code/tests/test_lightning_direct.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Test Lightning Bolt directly - M4: Updated for Parquet""" +"""Test Lightning Bolt directly""" import sys import os @@ -7,10 +7,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'code')) from deck_builder.include_exclude_utils import fuzzy_match_card_name import pandas as pd -from path_util import get_processed_cards_path -# M4: Load from Parquet instead of CSV -cards_df = pd.read_parquet(get_processed_cards_path()) +cards_df = pd.read_csv('csv_files/cards.csv', low_memory=False) available_cards = set(cards_df['name'].dropna().unique()) # Test if Lightning Bolt gets the right score diff --git a/code/tests/test_mdfc_basic_swap.py b/code/tests/test_mdfc_basic_swap.py index 535f8da..e78dafa 100644 --- a/code/tests/test_mdfc_basic_swap.py +++ b/code/tests/test_mdfc_basic_swap.py @@ -20,7 +20,7 @@ def _stub_modal_matrix(builder: DeckBuilder) -> None: "Forest": {"G": 1}, } - builder._compute_color_source_matrix = MethodType(fake_matrix, builder) + builder._compute_color_source_matrix = MethodType(fake_matrix, builder) # type: ignore[attr-defined] def test_modal_dfc_swaps_basic_when_enabled(): diff --git a/code/tests/test_migration_compatibility.py b/code/tests/test_migration_compatibility.py deleted file mode 100644 index 9754b2b..0000000 --- a/code/tests/test_migration_compatibility.py +++ /dev/null @@ -1,280 +0,0 @@ -""" -Migration Compatibility Tests - -Ensures backward compatibility during migration from individual CSV files -to consolidated all_cards.parquet. Tests verify that legacy adapter functions -produce identical results to direct AllCardsLoader calls. -""" - -from __future__ import annotations - -import os -import tempfile - -import pandas as pd -import pytest - -from code.services.all_cards_loader import AllCardsLoader -from code.services.legacy_loader_adapter import ( - load_all_cards, - load_cards_by_color_identity, - load_cards_by_name, - load_cards_by_names, - load_cards_by_type, - load_cards_with_tag, - load_cards_with_tags, - search_cards, -) - - -@pytest.fixture -def sample_cards_df(): - """Create a sample DataFrame for testing.""" - return pd.DataFrame( - { - "name": [ - "Sol Ring", - "Lightning Bolt", - "Counterspell", - "Giant Growth", - "Goblin Token Maker", - ], - "colorIdentity": ["Colorless", "R", "U", "G", "R"], - "type": ["Artifact", "Instant", "Instant", "Instant", "Creature — Goblin"], - "text": [ - "Add two mana", - "Deal 3 damage", - "Counter target spell", - "Target creature gets +3/+3", - "When this enters, create two 1/1 red Goblin creature tokens", - ], - "themeTags": ["", "burn,damage", "control,counterspells", "combat,pump", "tokens,goblins"], - } - ) - - -@pytest.fixture -def temp_parquet_file(sample_cards_df): - """Create a temporary Parquet file for testing.""" - with tempfile.NamedTemporaryFile(delete=False, suffix=".parquet") as tmp: - sample_cards_df.to_parquet(tmp.name, engine="pyarrow") - yield tmp.name - os.unlink(tmp.name) - - -def test_load_all_cards_adapter(temp_parquet_file): - """Test load_all_cards() legacy function.""" - # Direct loader call - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.load() - - # Legacy adapter call - # Note: We need to temporarily override the loader's file path - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_all_cards() - - # Results should be identical - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_load_cards_by_name_adapter(temp_parquet_file): - """Test load_cards_by_name() legacy function.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.get_by_name("Sol Ring") - - # Setup adapter with test file - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_by_name("Sol Ring") - - # Results should be identical - assert adapter_result is not None - pd.testing.assert_series_equal(direct_result, adapter_result) - - -def test_load_cards_by_names_adapter(temp_parquet_file): - """Test load_cards_by_names() legacy function.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - names = ["Sol Ring", "Lightning Bolt"] - direct_result = loader.get_by_names(names) - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_by_names(names) - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_load_cards_by_type_adapter(temp_parquet_file): - """Test load_cards_by_type() legacy function.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.filter_by_type("Instant") - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_by_type("Instant") - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_load_cards_with_tag_adapter(temp_parquet_file): - """Test load_cards_with_tag() legacy function.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.filter_by_themes(["tokens"], mode="any") - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_with_tag("tokens") - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_load_cards_with_tags_any_mode(temp_parquet_file): - """Test load_cards_with_tags() with mode='any'.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.filter_by_themes(["burn", "tokens"], mode="any") - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_with_tags(["burn", "tokens"], require_all=False) - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_load_cards_with_tags_all_mode(temp_parquet_file): - """Test load_cards_with_tags() with mode='all'.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.filter_by_themes(["tokens", "goblins"], mode="all") - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_with_tags(["tokens", "goblins"], require_all=True) - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_load_cards_by_color_identity_adapter(temp_parquet_file): - """Test load_cards_by_color_identity() legacy function.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.filter_by_color_identity(["R"]) - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = load_cards_by_color_identity(["R"]) - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_search_cards_adapter(temp_parquet_file): - """Test search_cards() legacy function.""" - loader = AllCardsLoader(file_path=temp_parquet_file) - direct_result = loader.search("token", limit=100) - - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - adapter_result = search_cards("token", limit=100) - - pd.testing.assert_frame_equal(direct_result, adapter_result) - - -def test_deprecation_warnings_logged(temp_parquet_file, caplog): - """Test that deprecation warnings are properly logged.""" - from code.services import legacy_loader_adapter - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - load_cards_by_name("Sol Ring") - - # Check that warning was logged - assert any("DEPRECATION" in record.message for record in caplog.records) - - -def test_feature_flag_disabled(temp_parquet_file, monkeypatch): - """Test behavior when USE_ALL_CARDS_FILE is disabled.""" - # Disable feature flag - monkeypatch.setattr("code.settings.USE_ALL_CARDS_FILE", False) - - # Reimport to pick up new setting - import importlib - from code.services import legacy_loader_adapter - importlib.reload(legacy_loader_adapter) - - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - result = load_all_cards() - - # Should return empty DataFrame when disabled - assert result.empty - - -def test_adapter_uses_shared_loader(temp_parquet_file): - """Test that adapter reuses shared loader instance for performance.""" - from code.services import legacy_loader_adapter - - # Clear any existing loader - legacy_loader_adapter._shared_loader = None - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - with pytest.warns(DeprecationWarning): - load_all_cards() - - loader1 = legacy_loader_adapter._shared_loader - - with pytest.warns(DeprecationWarning): - load_cards_by_name("Sol Ring") - - loader2 = legacy_loader_adapter._shared_loader - - # Should be the same instance - assert loader1 is loader2 - - -def test_multiple_calls_use_cache(temp_parquet_file, monkeypatch): - """Test that multiple adapter calls benefit from caching.""" - import time - from code.services import legacy_loader_adapter - - # Ensure feature flag is enabled - monkeypatch.setattr("code.settings.USE_ALL_CARDS_FILE", True) - - # Reimport to pick up setting - import importlib - importlib.reload(legacy_loader_adapter) - - legacy_loader_adapter._shared_loader = AllCardsLoader(file_path=temp_parquet_file) - - # First call (loads from disk) - start = time.time() - with pytest.warns(DeprecationWarning): - load_all_cards() - first_time = time.time() - start - - # Second call (should use cache) - start = time.time() - with pytest.warns(DeprecationWarning): - load_all_cards() - second_time = time.time() - start - - # Cache should make second call faster (or at least not slower) - # Use a more lenient check since file is very small - assert second_time <= first_time * 2 # Allow some variance diff --git a/code/tests/test_multicopy_clamp_strong.py b/code/tests/test_multicopy_clamp_strong.py index 3538e6c..b7cdc4d 100644 --- a/code/tests/test_multicopy_clamp_strong.py +++ b/code/tests/test_multicopy_clamp_strong.py @@ -18,7 +18,7 @@ def test_multicopy_clamp_trims_current_stage_additions_only(): # Preseed 95 cards in the library b.card_library = {"Filler": {"Count": 95, "Role": "Test", "SubRole": "", "AddedBy": "Test"}} # Set a multi-copy selection that would exceed 100 by 15 - b._web_multi_copy = { + b._web_multi_copy = { # type: ignore[attr-defined] "id": "persistent_petitioners", "name": "Persistent Petitioners", "count": 20, diff --git a/code/tests/test_multicopy_petitioners_clamp.py b/code/tests/test_multicopy_petitioners_clamp.py index dfa8b7f..e7a37c7 100644 --- a/code/tests/test_multicopy_petitioners_clamp.py +++ b/code/tests/test_multicopy_petitioners_clamp.py @@ -23,7 +23,7 @@ def test_petitioners_clamp_to_100_and_reduce_creature_slots(): "card_advantage": 8, "protection": 4, } # Thread multi-copy selection for Petitioners as a creature archetype - b._web_multi_copy = { + b._web_multi_copy = { # type: ignore[attr-defined] "id": "persistent_petitioners", "name": "Persistent Petitioners", "count": 40, # intentionally large to trigger clamp/adjustments diff --git a/code/tests/test_multicopy_stage_runner.py b/code/tests/test_multicopy_stage_runner.py index 4054fc0..886b277 100644 --- a/code/tests/test_multicopy_stage_runner.py +++ b/code/tests/test_multicopy_stage_runner.py @@ -17,7 +17,7 @@ def _minimal_ctx(selection: dict): b = DeckBuilder(output_func=out, input_func=lambda *_: "", headless=True) # Thread selection and ensure empty library - b._web_multi_copy = selection + b._web_multi_copy = selection # type: ignore[attr-defined] b.card_library = {} ctx = { diff --git a/code/tests/test_multicopy_web_flow.py b/code/tests/test_multicopy_web_flow.py index 52f64c2..22fb79a 100644 --- a/code/tests/test_multicopy_web_flow.py +++ b/code/tests/test_multicopy_web_flow.py @@ -1,7 +1,7 @@ import importlib import pytest try: - from starlette.testclient import TestClient + from starlette.testclient import TestClient # type: ignore except Exception: # pragma: no cover - optional dep in CI TestClient = None # type: ignore diff --git a/code/tests/test_partner_suggestions_api.py b/code/tests/test_partner_suggestions_api.py index 5180329..a54838f 100644 --- a/code/tests/test_partner_suggestions_api.py +++ b/code/tests/test_partner_suggestions_api.py @@ -128,7 +128,7 @@ def _make_request(path: str = "/api/partner/suggestions", query_string: str = "" "client": ("203.0.113.5", 52345), "server": ("testserver", 80), } - request = Request(scope, receive=_receive) + request = Request(scope, receive=_receive) # type: ignore[arg-type] request.state.request_id = "req-telemetry" return request @@ -197,21 +197,21 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey from code.web.services import orchestrator as orchestrator_service original_default = partner_service.DEFAULT_DATASET_PATH - original_path = partner_service._DATASET_PATH - original_cache = partner_service._DATASET_CACHE - original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED + original_path = partner_service._DATASET_PATH # type: ignore[attr-defined] + original_cache = partner_service._DATASET_CACHE # type: ignore[attr-defined] + original_attempted = partner_service._DATASET_REFRESH_ATTEMPTED # type: ignore[attr-defined] partner_service.DEFAULT_DATASET_PATH = dataset_path - partner_service._DATASET_PATH = dataset_path - partner_service._DATASET_CACHE = None - partner_service._DATASET_REFRESH_ATTEMPTED = True + partner_service._DATASET_PATH = dataset_path # type: ignore[attr-defined] + partner_service._DATASET_CACHE = None # type: ignore[attr-defined] + partner_service._DATASET_REFRESH_ATTEMPTED = True # type: ignore[attr-defined] calls = {"count": 0} payload_path = tmp_path / "seed_dataset.json" _write_dataset(payload_path) - def seeded_refresh(out_func=None, *, force=False, root=None): + def seeded_refresh(out_func=None, *, force=False, root=None): # type: ignore[override] calls["count"] += 1 dataset_path.write_text(payload_path.read_text(encoding="utf-8"), encoding="utf-8") @@ -227,9 +227,9 @@ def test_load_dataset_refresh_retries_after_prior_failure(tmp_path: Path, monkey assert calls["count"] == 1 finally: partner_service.DEFAULT_DATASET_PATH = original_default - partner_service._DATASET_PATH = original_path - partner_service._DATASET_CACHE = original_cache - partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted + partner_service._DATASET_PATH = original_path # type: ignore[attr-defined] + partner_service._DATASET_CACHE = original_cache # type: ignore[attr-defined] + partner_service._DATASET_REFRESH_ATTEMPTED = original_attempted # type: ignore[attr-defined] try: dataset_path.unlink() except FileNotFoundError: diff --git a/code/tests/test_partner_synergy_refresh.py b/code/tests/test_partner_synergy_refresh.py index 984b79a..cf3c2e1 100644 --- a/code/tests/test_partner_synergy_refresh.py +++ b/code/tests/test_partner_synergy_refresh.py @@ -33,7 +33,7 @@ def _invoke_helper( ) -> list[tuple[list[str], str]]: calls: list[tuple[list[str], str]] = [] - def _fake_run(cmd, check=False, cwd=None): + def _fake_run(cmd, check=False, cwd=None): # type: ignore[no-untyped-def] calls.append((list(cmd), cwd)) class _Completed: returncode = 0 diff --git a/code/tests/test_preview_cache_redis_poc.py b/code/tests/test_preview_cache_redis_poc.py index afe616e..34e8c1e 100644 --- a/code/tests/test_preview_cache_redis_poc.py +++ b/code/tests/test_preview_cache_redis_poc.py @@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi") def load_app_with_env(**env: str) -> types.ModuleType: for k,v in env.items(): os.environ[k] = v - import code.web.app as app_module + import code.web.app as app_module # type: ignore importlib.reload(app_module) return app_module diff --git a/code/tests/test_preview_curated_examples_regression.py b/code/tests/test_preview_curated_examples_regression.py index fc81d13..9839784 100644 --- a/code/tests/test_preview_curated_examples_regression.py +++ b/code/tests/test_preview_curated_examples_regression.py @@ -1,7 +1,7 @@ import json from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore def test_preview_includes_curated_examples_regression(): diff --git a/code/tests/test_preview_eviction_advanced.py b/code/tests/test_preview_eviction_advanced.py index 337b6c2..63447d5 100644 --- a/code/tests/test_preview_eviction_advanced.py +++ b/code/tests/test_preview_eviction_advanced.py @@ -1,8 +1,8 @@ import os -from code.web.services.theme_preview import get_theme_preview, bust_preview_cache -from code.web.services import preview_cache as pc -from code.web.services.preview_metrics import preview_metrics +from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore +from code.web.services import preview_cache as pc # type: ignore +from code.web.services.preview_metrics import preview_metrics # type: ignore def _prime(slug: str, limit: int = 12, hits: int = 0, *, colors=None): @@ -89,7 +89,7 @@ def test_env_weight_override(monkeypatch): bust_preview_cache() # Clear module-level caches for weights if hasattr(pc, '_EVICT_WEIGHTS_CACHE'): - pc._EVICT_WEIGHTS_CACHE = None + pc._EVICT_WEIGHTS_CACHE = None # type: ignore # Create two entries: one older with many hits, one fresh with none. _prime('Blink', limit=6, hits=6, colors=None) # older hot entry old_key = next(iter(pc.PREVIEW_CACHE.keys())) diff --git a/code/tests/test_preview_eviction_basic.py b/code/tests/test_preview_eviction_basic.py index 804c2d5..848bcce 100644 --- a/code/tests/test_preview_eviction_basic.py +++ b/code/tests/test_preview_eviction_basic.py @@ -1,6 +1,6 @@ import os -from code.web.services.theme_preview import get_theme_preview, bust_preview_cache -from code.web.services import preview_cache as pc +from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore +from code.web.services import preview_cache as pc # type: ignore def test_basic_low_score_eviction(monkeypatch): @@ -17,7 +17,7 @@ def test_basic_low_score_eviction(monkeypatch): get_theme_preview('Blink', limit=6, colors=c) # Cache limit 5, inserted 6 distinct -> eviction should have occurred assert len(pc.PREVIEW_CACHE) <= 5 - from code.web.services.preview_metrics import preview_metrics + from code.web.services.preview_metrics import preview_metrics # type: ignore m = preview_metrics() assert m['preview_cache_evictions'] >= 1, 'Expected at least one eviction' assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1 diff --git a/code/tests/test_preview_minimal_variant.py b/code/tests/test_preview_minimal_variant.py index b134a23..2fec530 100644 --- a/code/tests/test_preview_minimal_variant.py +++ b/code/tests/test_preview_minimal_variant.py @@ -1,5 +1,5 @@ from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore def test_minimal_variant_hides_controls_and_headers(): diff --git a/code/tests/test_preview_perf_fetch_retry.py b/code/tests/test_preview_perf_fetch_retry.py index a0bdb9a..00311fb 100644 --- a/code/tests/test_preview_perf_fetch_retry.py +++ b/code/tests/test_preview_perf_fetch_retry.py @@ -1,14 +1,10 @@ -import pytest - -# M4 (Parquet Migration): preview_perf_benchmark module was removed during refactoring -# These tests are no longer applicable -pytestmark = pytest.mark.skip(reason="M4: preview_perf_benchmark module removed during refactoring") +from code.scripts import preview_perf_benchmark as perf def test_fetch_all_theme_slugs_retries(monkeypatch): calls = {"count": 0} - def fake_fetch(url): + def fake_fetch(url): # type: ignore[override] calls["count"] += 1 if calls["count"] == 1: raise RuntimeError("transient 500") @@ -27,7 +23,7 @@ def test_fetch_all_theme_slugs_retries(monkeypatch): def test_fetch_all_theme_slugs_page_level_retry(monkeypatch): calls = {"count": 0} - def fake_fetch_with_retry(url, attempts=3, delay=0.6): + def fake_fetch_with_retry(url, attempts=3, delay=0.6): # type: ignore[override] calls["count"] += 1 if calls["count"] < 3: raise RuntimeError("service warming up") diff --git a/code/tests/test_preview_suppress_curated_flag.py b/code/tests/test_preview_suppress_curated_flag.py index bea1467..9ab5283 100644 --- a/code/tests/test_preview_suppress_curated_flag.py +++ b/code/tests/test_preview_suppress_curated_flag.py @@ -1,5 +1,5 @@ from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore def test_preview_fragment_suppress_curated_removes_examples(): diff --git a/code/tests/test_preview_ttl_adaptive.py b/code/tests/test_preview_ttl_adaptive.py index aa952d3..e4b72b7 100644 --- a/code/tests/test_preview_ttl_adaptive.py +++ b/code/tests/test_preview_ttl_adaptive.py @@ -3,16 +3,16 @@ from code.web.services import preview_cache as pc def _force_interval_elapsed(): # Ensure adaptation interval guard passes - if pc._LAST_ADAPT_AT is not None: - pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) + if pc._LAST_ADAPT_AT is not None: # type: ignore[attr-defined] + pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) # type: ignore[attr-defined] def test_ttl_adapts_down_and_up(capsys): # Enable adaptation regardless of env - pc._ADAPTATION_ENABLED = True - pc.TTL_SECONDS = pc._TTL_BASE - pc._RECENT_HITS.clear() - pc._LAST_ADAPT_AT = None + pc._ADAPTATION_ENABLED = True # type: ignore[attr-defined] + pc.TTL_SECONDS = pc._TTL_BASE # type: ignore[attr-defined] + pc._RECENT_HITS.clear() # type: ignore[attr-defined] + pc._LAST_ADAPT_AT = None # type: ignore[attr-defined] # Low hit ratio pattern (~0.1) for _ in range(72): @@ -23,11 +23,11 @@ def test_ttl_adapts_down_and_up(capsys): out1 = capsys.readouterr().out assert "theme_preview_ttl_adapt" in out1, "expected adaptation log for low hit ratio" ttl_after_down = pc.TTL_SECONDS - assert ttl_after_down <= pc._TTL_BASE + assert ttl_after_down <= pc._TTL_BASE # type: ignore[attr-defined] # Force interval elapsed & high hit ratio pattern (~0.9) _force_interval_elapsed() - pc._RECENT_HITS.clear() + pc._RECENT_HITS.clear() # type: ignore[attr-defined] for _ in range(72): pc.record_request_hit(True) for _ in range(8): diff --git a/code/tests/test_random_rate_limit_headers.py b/code/tests/test_random_rate_limit_headers.py index 6fb2e30..6a18061 100644 --- a/code/tests/test_random_rate_limit_headers.py +++ b/code/tests/test_random_rate_limit_headers.py @@ -19,17 +19,17 @@ def _client_with_flags(window_s: int = 2, limit_random: int = 2, limit_build: in # Force fresh import so RATE_LIMIT_* constants reflect env sys.modules.pop('code.web.app', None) - from code.web import app as app_module + from code.web import app as app_module # type: ignore # Force override constants for deterministic test try: - app_module.RATE_LIMIT_ENABLED = True - app_module.RATE_LIMIT_WINDOW_S = window_s - app_module.RATE_LIMIT_RANDOM = limit_random - app_module.RATE_LIMIT_BUILD = limit_build - app_module.RATE_LIMIT_SUGGEST = limit_suggest + app_module.RATE_LIMIT_ENABLED = True # type: ignore[attr-defined] + app_module.RATE_LIMIT_WINDOW_S = window_s # type: ignore[attr-defined] + app_module.RATE_LIMIT_RANDOM = limit_random # type: ignore[attr-defined] + app_module.RATE_LIMIT_BUILD = limit_build # type: ignore[attr-defined] + app_module.RATE_LIMIT_SUGGEST = limit_suggest # type: ignore[attr-defined] # Reset in-memory counters if hasattr(app_module, '_RL_COUNTS'): - app_module._RL_COUNTS.clear() + app_module._RL_COUNTS.clear() # type: ignore[attr-defined] except Exception: pass return TestClient(app_module.app) diff --git a/code/tests/test_random_theme_stats_diagnostics.py b/code/tests/test_random_theme_stats_diagnostics.py index 5c71326..5602ba4 100644 --- a/code/tests/test_random_theme_stats_diagnostics.py +++ b/code/tests/test_random_theme_stats_diagnostics.py @@ -3,8 +3,8 @@ from pathlib import Path from fastapi.testclient import TestClient -from code.web import app as web_app -from code.web.app import app +from code.web import app as web_app # type: ignore +from code.web.app import app # type: ignore # Ensure project root on sys.path for absolute imports ROOT = Path(__file__).resolve().parents[2] diff --git a/code/tests/test_sampling_unit.py b/code/tests/test_sampling_unit.py index 711c856..2f09806 100644 --- a/code/tests/test_sampling_unit.py +++ b/code/tests/test_sampling_unit.py @@ -9,17 +9,17 @@ def setup_module(module): # ensure deterministic env weights def test_rarity_diminishing(): # Monkeypatch internal index - card_index._CARD_INDEX.clear() + card_index._CARD_INDEX.clear() # type: ignore theme = "Test Theme" - card_index._CARD_INDEX[theme] = [ + card_index._CARD_INDEX[theme] = [ # type: ignore {"name": "Mythic One", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"}, {"name": "Mythic Two", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"}, ] def no_build(): return None - sampling.maybe_build_index = no_build + sampling.maybe_build_index = no_build # type: ignore cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander=None) - rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] + rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] # type: ignore assert len(rarity_weights) >= 2 v1 = float(rarity_weights[0].split(":")[-1]) v2 = float(rarity_weights[1].split(":")[-1]) @@ -40,15 +40,15 @@ def test_commander_overlap_monotonic_diminishing(): def test_splash_off_color_penalty_applied(): - card_index._CARD_INDEX.clear() + card_index._CARD_INDEX.clear() # type: ignore theme = "Splash Theme" # Commander W U B R (4 colors) commander = {"name": "CommanderTest", "tags": [theme], "color_identity": "WUBR", "mana_cost": "", "rarity": "mythic"} # Card with single off-color G (W U B R G) splash_card = {"name": "CardSplash", "tags": [theme], "color_identity": "WUBRG", "mana_cost": "G", "rarity": "rare"} - card_index._CARD_INDEX[theme] = [commander, splash_card] - sampling.maybe_build_index = lambda: None + card_index._CARD_INDEX[theme] = [commander, splash_card] # type: ignore + sampling.maybe_build_index = lambda: None # type: ignore cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander="CommanderTest") splash = next((c for c in cards if c["name"] == "CardSplash"), None) assert splash is not None - assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) + assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) # type: ignore diff --git a/code/tests/test_scryfall_name_normalization.py b/code/tests/test_scryfall_name_normalization.py index f4a6834..cdd7c09 100644 --- a/code/tests/test_scryfall_name_normalization.py +++ b/code/tests/test_scryfall_name_normalization.py @@ -1,5 +1,5 @@ import re -from code.web.services.theme_preview import get_theme_preview +from code.web.services.theme_preview import get_theme_preview # type: ignore # We can't easily execute the JS normalizeCardName in Python, but we can ensure # server-delivered sample names that include appended synergy annotations are not diff --git a/code/tests/test_service_worker_offline.py b/code/tests/test_service_worker_offline.py index 080a6bb..291e3ca 100644 --- a/code/tests/test_service_worker_offline.py +++ b/code/tests/test_service_worker_offline.py @@ -10,7 +10,7 @@ fastapi = pytest.importorskip("fastapi") # skip if FastAPI missing def load_app_with_env(**env: str) -> types.ModuleType: for k, v in env.items(): os.environ[k] = v - import code.web.app as app_module + import code.web.app as app_module # type: ignore importlib.reload(app_module) return app_module diff --git a/code/tests/test_tag_index.py b/code/tests/test_tag_index.py deleted file mode 100644 index 2dd97e9..0000000 --- a/code/tests/test_tag_index.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Tests for tag index functionality.""" -import json -import time - -from code.tagging.tag_index import ( - TagIndex, - IndexStats, - get_tag_index, - clear_global_index, -) - - -class TestTagIndexBuild: - """Test index building operations.""" - - def test_build_index(self): - """Test that index builds successfully.""" - index = TagIndex() - stats = index.build() - - assert isinstance(stats, IndexStats) - assert stats.total_cards > 0 - assert stats.total_tags > 0 - assert stats.total_mappings > 0 - assert stats.build_time_seconds >= 0 - - def test_build_index_performance(self): - """Test that index builds in reasonable time.""" - index = TagIndex() - - start = time.perf_counter() - stats = index.build() - elapsed = time.perf_counter() - start - - # Should build in <5s for typical dataset - assert elapsed < 5.0 - assert stats.build_time_seconds < 5.0 - - def test_force_rebuild(self): - """Test that force_rebuild always rebuilds.""" - index = TagIndex() - - # Build once - stats1 = index.build() - time1 = stats1.indexed_at - - # Wait a bit - time.sleep(0.1) - - # Force rebuild - stats2 = index.build(force_rebuild=True) - time2 = stats2.indexed_at - - # Should have different timestamps - assert time2 > time1 - - -class TestSingleTagQueries: - """Test single tag lookup operations.""" - - def test_get_cards_with_tag(self): - """Test getting cards with a specific tag.""" - index = TagIndex() - index.build() - - # Get a tag that exists - all_tags = index.get_all_tags() - if all_tags: - tag = all_tags[0] - cards = index.get_cards_with_tag(tag) - - assert isinstance(cards, set) - assert len(cards) > 0 - - def test_get_cards_with_nonexistent_tag(self): - """Test querying for tag that doesn't exist.""" - index = TagIndex() - index.build() - - cards = index.get_cards_with_tag("ThisTagDoesNotExist12345") - - assert cards == set() - - def test_get_tags_for_card(self): - """Test getting tags for a specific card.""" - index = TagIndex() - index.build() - - # Get a card that exists - cards = index.get_cards_with_tag(index.get_all_tags()[0]) if index.get_all_tags() else set() - if cards: - card_name = list(cards)[0] - tags = index.get_tags_for_card(card_name) - - assert isinstance(tags, list) - assert len(tags) > 0 - - def test_get_tags_for_nonexistent_card(self): - """Test getting tags for card that doesn't exist.""" - index = TagIndex() - index.build() - - tags = index.get_tags_for_card("This Card Does Not Exist 12345") - - assert tags == [] - - -class TestMultiTagQueries: - """Test queries with multiple tags.""" - - def test_get_cards_with_all_tags(self): - """Test AND logic (cards must have all tags).""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if len(all_tags) >= 2: - # Pick two tags - tag1, tag2 = all_tags[0], all_tags[1] - - cards1 = index.get_cards_with_tag(tag1) - cards2 = index.get_cards_with_tag(tag2) - cards_both = index.get_cards_with_all_tags([tag1, tag2]) - - # Result should be subset of both - assert cards_both.issubset(cards1) - assert cards_both.issubset(cards2) - - # Result should be intersection - assert cards_both == (cards1 & cards2) - - def test_get_cards_with_any_tags(self): - """Test OR logic (cards need at least one tag).""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if len(all_tags) >= 2: - # Pick two tags - tag1, tag2 = all_tags[0], all_tags[1] - - cards1 = index.get_cards_with_tag(tag1) - cards2 = index.get_cards_with_tag(tag2) - cards_any = index.get_cards_with_any_tags([tag1, tag2]) - - # Result should be superset of both - assert cards1.issubset(cards_any) - assert cards2.issubset(cards_any) - - # Result should be union - assert cards_any == (cards1 | cards2) - - def test_get_cards_with_empty_tag_list(self): - """Test querying with empty tag list.""" - index = TagIndex() - index.build() - - cards_all = index.get_cards_with_all_tags([]) - cards_any = index.get_cards_with_any_tags([]) - - assert cards_all == set() - assert cards_any == set() - - def test_get_cards_with_nonexistent_tags(self): - """Test querying with tags that don't exist.""" - index = TagIndex() - index.build() - - fake_tags = ["FakeTag1", "FakeTag2"] - - cards_all = index.get_cards_with_all_tags(fake_tags) - cards_any = index.get_cards_with_any_tags(fake_tags) - - assert cards_all == set() - assert cards_any == set() - - -class TestIndexStats: - """Test index statistics and metadata.""" - - def test_get_stats(self): - """Test getting index statistics.""" - index = TagIndex() - - # Before building - assert index.get_stats() is None - - # After building - stats = index.build() - retrieved_stats = index.get_stats() - - assert retrieved_stats is not None - assert retrieved_stats.total_cards == stats.total_cards - assert retrieved_stats.total_tags == stats.total_tags - - def test_get_all_tags(self): - """Test getting list of all tags.""" - index = TagIndex() - index.build() - - tags = index.get_all_tags() - - assert isinstance(tags, list) - assert len(tags) > 0 - # Should be sorted - assert tags == sorted(tags) - - def test_get_tag_stats(self): - """Test getting stats for specific tag.""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if all_tags: - tag = all_tags[0] - stats = index.get_tag_stats(tag) - - assert "card_count" in stats - assert stats["card_count"] > 0 - - def test_get_popular_tags(self): - """Test getting most popular tags.""" - index = TagIndex() - index.build() - - popular = index.get_popular_tags(limit=10) - - assert isinstance(popular, list) - assert len(popular) <= 10 - - if len(popular) > 1: - # Should be sorted by count descending - counts = [count for _, count in popular] - assert counts == sorted(counts, reverse=True) - - -class TestCaching: - """Test index caching and persistence.""" - - def test_save_and_load_cache(self, tmp_path): - """Test that cache saves and loads correctly.""" - cache_path = tmp_path / ".tag_index_test.json" - - # Build and save - index1 = TagIndex(cache_path=cache_path) - stats1 = index1.build() - - assert cache_path.exists() - - # Load from cache - index2 = TagIndex(cache_path=cache_path) - stats2 = index2.build() # Should load from cache - - # Should have same data - assert stats2.total_cards == stats1.total_cards - assert stats2.total_tags == stats1.total_tags - assert stats2.indexed_at == stats1.indexed_at - - def test_cache_invalidation(self, tmp_path): - """Test that cache is rebuilt when all_cards changes.""" - cache_path = tmp_path / ".tag_index_test.json" - - # Build index - index = TagIndex(cache_path=cache_path) - stats1 = index.build() - - # Modify cache to simulate outdated mtime - with cache_path.open("r") as f: - cache_data = json.load(f) - - cache_data["stats"]["all_cards_mtime"] = 0 # Very old - - with cache_path.open("w") as f: - json.dump(cache_data, f) - - # Should rebuild (not use cache) - index2 = TagIndex(cache_path=cache_path) - stats2 = index2.build() - - # Should have new timestamp - assert stats2.indexed_at > stats1.indexed_at - - def test_clear_cache(self, tmp_path): - """Test cache clearing.""" - cache_path = tmp_path / ".tag_index_test.json" - - index = TagIndex(cache_path=cache_path) - index.build() - - assert cache_path.exists() - - index.clear_cache() - - assert not cache_path.exists() - - -class TestGlobalIndex: - """Test global index accessor.""" - - def test_get_tag_index(self): - """Test getting global index.""" - clear_global_index() - - index = get_tag_index() - - assert isinstance(index, TagIndex) - assert index.get_stats() is not None - - def test_get_tag_index_singleton(self): - """Test that global index is a singleton.""" - clear_global_index() - - index1 = get_tag_index() - index2 = get_tag_index() - - # Should be same instance - assert index1 is index2 - - def test_clear_global_index(self): - """Test clearing global index.""" - index1 = get_tag_index() - - clear_global_index() - - index2 = get_tag_index() - - # Should be different instance - assert index1 is not index2 - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_cards_with_no_tags(self): - """Test that cards without tags are handled.""" - index = TagIndex() - index.build() - - # Get stats - should handle cards with no tags gracefully - stats = index.get_stats() - assert stats is not None - - def test_special_characters_in_tags(self): - """Test tags with special characters.""" - index = TagIndex() - index.build() - - # Try querying with special chars (should not crash) - cards = index.get_cards_with_tag("Life & Death") - assert isinstance(cards, set) - - def test_case_sensitive_tags(self): - """Test that tag lookups are case-sensitive.""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if all_tags: - tag = all_tags[0] - - cards1 = index.get_cards_with_tag(tag) - cards2 = index.get_cards_with_tag(tag.upper()) - cards3 = index.get_cards_with_tag(tag.lower()) - - # Case matters - may get different results - # (depends on tag naming in data) - assert isinstance(cards1, set) - assert isinstance(cards2, set) - assert isinstance(cards3, set) - - def test_duplicate_tags_handled(self): - """Test that duplicate tags in query are handled.""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if all_tags: - tag = all_tags[0] - - # Query with duplicate tag - cards = index.get_cards_with_all_tags([tag, tag]) - cards_single = index.get_cards_with_tag(tag) - - # Should give same result as single tag - assert cards == cards_single - - -class TestPerformance: - """Test performance characteristics.""" - - def test_query_performance(self): - """Test that queries complete quickly.""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if all_tags: - tag = all_tags[0] - - # Measure query time - start = time.perf_counter() - for _ in range(100): - index.get_cards_with_tag(tag) - elapsed = time.perf_counter() - start - - avg_time_ms = (elapsed / 100) * 1000 - - # Should average <1ms per query - assert avg_time_ms < 1.0 - - def test_multi_tag_query_performance(self): - """Test multi-tag query performance.""" - index = TagIndex() - index.build() - - all_tags = index.get_all_tags() - if len(all_tags) >= 3: - tags = all_tags[:3] - - # Measure query time - start = time.perf_counter() - for _ in range(100): - index.get_cards_with_all_tags(tags) - elapsed = time.perf_counter() - start - - avg_time_ms = (elapsed / 100) * 1000 - - # Should still be very fast - assert avg_time_ms < 5.0 diff --git a/code/tests/test_tag_loader.py b/code/tests/test_tag_loader.py deleted file mode 100644 index dbe8102..0000000 --- a/code/tests/test_tag_loader.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Tests for batch tag loading from all_cards.""" -from code.tagging.tag_loader import ( - load_tags_for_cards, - load_tags_for_card, - get_cards_with_tag, - get_cards_with_all_tags, - clear_cache, - is_use_all_cards_enabled, -) - - -class TestBatchTagLoading: - """Test batch tag loading operations.""" - - def test_load_tags_for_multiple_cards(self): - """Test loading tags for multiple cards at once.""" - cards = ["Sol Ring", "Lightning Bolt", "Counterspell"] - result = load_tags_for_cards(cards) - - assert isinstance(result, dict) - assert len(result) == 3 - - # All requested cards should be in result (even if no tags) - for card in cards: - assert card in result - assert isinstance(result[card], list) - - def test_load_tags_for_empty_list(self): - """Test loading tags for empty list returns empty dict.""" - result = load_tags_for_cards([]) - assert result == {} - - def test_load_tags_for_single_card(self): - """Test single card convenience function.""" - tags = load_tags_for_card("Sol Ring") - - assert isinstance(tags, list) - # Sol Ring should have some tags (artifacts, ramp, etc) - # But we don't assert specific tags since data may vary - - def test_load_tags_for_nonexistent_card(self): - """Test loading tags for card that doesn't exist.""" - tags = load_tags_for_card("This Card Does Not Exist 12345") - - # Should return empty list, not fail - assert tags == [] - - def test_load_tags_batch_includes_missing_cards(self): - """Test batch loading includes missing cards with empty lists.""" - cards = ["Sol Ring", "Fake Card Name 999", "Lightning Bolt"] - result = load_tags_for_cards(cards) - - # All cards should be present - assert len(result) == 3 - assert "Fake Card Name 999" in result - assert result["Fake Card Name 999"] == [] - - def test_load_tags_handles_list_format(self): - """Test that tags in list format are parsed correctly.""" - # Pick a card likely to have tags - result = load_tags_for_cards(["Sol Ring"]) - - if "Sol Ring" in result and result["Sol Ring"]: - tags = result["Sol Ring"] - # Should be a list of strings - assert all(isinstance(tag, str) for tag in tags) - # Tags should be stripped of whitespace - assert all(tag == tag.strip() for tag in tags) - - def test_load_tags_handles_string_format(self): - """Test that tags in string format are parsed correctly.""" - # The loader should handle both list and string representations - # This is tested implicitly by loading any card - cards = ["Sol Ring", "Lightning Bolt"] - result = load_tags_for_cards(cards) - - for card in cards: - tags = result[card] - # All should be lists (even if empty) - assert isinstance(tags, list) - # No empty string tags - assert "" not in tags - assert all(tag.strip() for tag in tags) - - -class TestTagQueries: - """Test querying cards by tags.""" - - def test_get_cards_with_tag(self): - """Test getting all cards with a specific tag.""" - # Pick a common tag - cards = get_cards_with_tag("ramp", limit=10) - - assert isinstance(cards, list) - # Should have some cards (or none if tag doesn't exist) - # We don't assert specific count since data varies - - def test_get_cards_with_tag_limit(self): - """Test limit parameter works.""" - cards = get_cards_with_tag("ramp", limit=5) - - assert len(cards) <= 5 - - def test_get_cards_with_nonexistent_tag(self): - """Test querying with tag that doesn't exist.""" - cards = get_cards_with_tag("ThisTagDoesNotExist12345") - - # Should return empty list, not fail - assert cards == [] - - def test_get_cards_with_all_tags(self): - """Test getting cards that have multiple tags.""" - # Pick two tags that might overlap - cards = get_cards_with_all_tags(["artifacts", "ramp"], limit=10) - - assert isinstance(cards, list) - assert len(cards) <= 10 - - def test_get_cards_with_all_tags_no_matches(self): - """Test query with tags that likely have no overlap.""" - cards = get_cards_with_all_tags([ - "ThisTagDoesNotExist1", - "ThisTagDoesNotExist2" - ]) - - # Should return empty list - assert cards == [] - - -class TestCacheManagement: - """Test cache management functions.""" - - def test_clear_cache(self): - """Test that cache can be cleared without errors.""" - # Load some data - load_tags_for_card("Sol Ring") - - # Clear cache - clear_cache() - - # Should still work after clearing - tags = load_tags_for_card("Sol Ring") - assert isinstance(tags, list) - - def test_cache_persistence(self): - """Test that multiple calls use cached data.""" - # First call - result1 = load_tags_for_cards(["Sol Ring", "Lightning Bolt"]) - - # Second call (should use cache) - result2 = load_tags_for_cards(["Sol Ring", "Lightning Bolt"]) - - # Results should be identical - assert result1 == result2 - - -class TestFeatureFlag: - """Test feature flag functionality.""" - - def test_is_use_all_cards_enabled_default(self): - """Test that all_cards tag loading is enabled by default.""" - enabled = is_use_all_cards_enabled() - - # Default should be True - assert isinstance(enabled, bool) - # We don't assert True since env might override - - -class TestEdgeCases: - """Test edge cases and error handling.""" - - def test_load_tags_with_special_characters(self): - """Test loading tags for cards with special characters.""" - # Cards with apostrophes, commas, etc. - cards = [ - "Urza's Saga", - "Keeper of the Accord", - "Esper Sentinel" - ] - result = load_tags_for_cards(cards) - - # Should handle special characters - assert len(result) == 3 - for card in cards: - assert card in result - - def test_load_tags_preserves_card_name_case(self): - """Test that card names preserve their original case.""" - cards = ["Sol Ring", "LIGHTNING BOLT", "counterspell"] - result = load_tags_for_cards(cards) - - # Should have entries for provided names (case-sensitive lookup) - assert "Sol Ring" in result or len(result) >= 1 - # Note: exact case matching depends on all_cards data - - def test_load_tags_deduplicates(self): - """Test that duplicate tags are handled.""" - # Load tags for a card - tags = load_tags_for_card("Sol Ring") - - # If any tags present, check for no duplicates - if tags: - assert len(tags) == len(set(tags)) - - def test_large_batch_performance(self): - """Test that large batch loads complete in reasonable time.""" - import time - - # Create a batch of 100 common cards - cards = ["Sol Ring"] * 50 + ["Lightning Bolt"] * 50 - - start = time.perf_counter() - result = load_tags_for_cards(cards) - elapsed = time.perf_counter() - start - - # Should complete quickly (< 1 second for 100 cards) - assert elapsed < 1.0 - assert len(result) >= 1 # At least one card found - - -class TestFormatVariations: - """Test handling of different tag format variations.""" - - def test_empty_tags_handled(self): - """Test that cards with no tags return empty list.""" - # Pick a card that might have no tags (basic lands usually don't) - tags = load_tags_for_card("Plains") - - # Should be empty list, not None or error - assert tags == [] or isinstance(tags, list) - - def test_string_list_repr_parsed(self): - """Test parsing of string representations like \"['tag1', 'tag2']\".""" - # This is tested implicitly through load_tags_for_cards - # The loader handles multiple formats internally - cards = ["Sol Ring", "Lightning Bolt", "Counterspell"] - result = load_tags_for_cards(cards) - - # All results should be lists - for card, tags in result.items(): - assert isinstance(tags, list) - # No stray brackets or quotes - for tag in tags: - assert "[" not in tag - assert "]" not in tag - assert '"' not in tag - assert "'" not in tag or tag.count("'") > 1 # Allow apostrophes in words - - def test_comma_separated_parsed(self): - """Test parsing of comma-separated tag strings.""" - # The loader should handle comma-separated strings - # This is tested implicitly by loading any card - result = load_tags_for_cards(["Sol Ring"]) - - if result.get("Sol Ring"): - tags = result["Sol Ring"] - # Tags should be split properly (no commas in individual tags) - for tag in tags: - assert "," not in tag or tag.count(",") == 0 diff --git a/code/tests/test_theme_api_phase_e.py b/code/tests/test_theme_api_phase_e.py index e61252c..0afa5d8 100644 --- a/code/tests/test_theme_api_phase_e.py +++ b/code/tests/test_theme_api_phase_e.py @@ -2,7 +2,7 @@ import sys from pathlib import Path import pytest from fastapi.testclient import TestClient -from code.web.app import app +from code.web.app import app # type: ignore # Ensure project root on sys.path for absolute imports ROOT = Path(__file__).resolve().parents[2] diff --git a/code/tests/test_theme_catalog_generation.py b/code/tests/test_theme_catalog_generation.py index 9badfc2..81f6634 100644 --- a/code/tests/test_theme_catalog_generation.py +++ b/code/tests/test_theme_catalog_generation.py @@ -146,7 +146,7 @@ def test_generate_theme_catalog_basic(tmp_path: Path, fixed_now: datetime) -> No assert all(row['last_generated_at'] == result.generated_at for row in rows) assert all(row['version'] == result.version for row in rows) - expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) + expected_hash = new_catalog._compute_version_hash([row['theme'] for row in rows]) # type: ignore[attr-defined] assert result.version == expected_hash diff --git a/code/tests/test_theme_catalog_mapping_and_samples.py b/code/tests/test_theme_catalog_mapping_and_samples.py index 9cdd9c8..bc661cf 100644 --- a/code/tests/test_theme_catalog_mapping_and_samples.py +++ b/code/tests/test_theme_catalog_mapping_and_samples.py @@ -4,7 +4,7 @@ import os import importlib from pathlib import Path from starlette.testclient import TestClient -from code.type_definitions_theme_catalog import ThemeCatalog +from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore CATALOG_PATH = Path('config/themes/theme_list.json') diff --git a/code/tests/test_theme_catalog_schema_validation.py b/code/tests/test_theme_catalog_schema_validation.py index 3bff64c..eb8593b 100644 --- a/code/tests/test_theme_catalog_schema_validation.py +++ b/code/tests/test_theme_catalog_schema_validation.py @@ -8,7 +8,7 @@ def test_theme_list_json_validates_against_pydantic_and_fast_path(): raw = json.loads(p.read_text(encoding='utf-8')) # Pydantic validation - from code.type_definitions_theme_catalog import ThemeCatalog + from code.type_definitions_theme_catalog import ThemeCatalog # type: ignore catalog = ThemeCatalog(**raw) assert isinstance(catalog.themes, list) and len(catalog.themes) > 0 # Basic fields exist on entries diff --git a/code/tests/test_theme_enrichment.py b/code/tests/test_theme_enrichment.py deleted file mode 100644 index 8d4ba02..0000000 --- a/code/tests/test_theme_enrichment.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Tests for consolidated theme enrichment pipeline. - -These tests verify that the new consolidated pipeline produces the same results -as the old 7-script approach, but much faster. -""" -from __future__ import annotations - -from pathlib import Path -from typing import Any, Dict - -import pytest - -try: - import yaml -except ImportError: - yaml = None - -from code.tagging.theme_enrichment import ( - ThemeEnrichmentPipeline, - EnrichmentStats, - run_enrichment_pipeline, -) - - -# Skip all tests if PyYAML not available -pytestmark = pytest.mark.skipif(yaml is None, reason="PyYAML not installed") - - -@pytest.fixture -def temp_catalog_dir(tmp_path: Path) -> Path: - """Create temporary catalog directory with test themes.""" - catalog_dir = tmp_path / 'config' / 'themes' / 'catalog' - catalog_dir.mkdir(parents=True) - return catalog_dir - - -@pytest.fixture -def temp_root(tmp_path: Path, temp_catalog_dir: Path) -> Path: - """Create temporary project root.""" - # Create theme_list.json - theme_json = tmp_path / 'config' / 'themes' / 'theme_list.json' - theme_json.parent.mkdir(parents=True, exist_ok=True) - theme_json.write_text('{"themes": []}', encoding='utf-8') - return tmp_path - - -def write_theme(catalog_dir: Path, filename: str, data: Dict[str, Any]) -> Path: - """Helper to write a theme YAML file.""" - path = catalog_dir / filename - path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding='utf-8') - return path - - -def read_theme(path: Path) -> Dict[str, Any]: - """Helper to read a theme YAML file.""" - return yaml.safe_load(path.read_text(encoding='utf-8')) - - -class TestThemeEnrichmentPipeline: - """Tests for ThemeEnrichmentPipeline class.""" - - def test_init(self, temp_root: Path): - """Test pipeline initialization.""" - pipeline = ThemeEnrichmentPipeline(root=temp_root, min_examples=5) - - assert pipeline.root == temp_root - assert pipeline.min_examples == 5 - assert pipeline.catalog_dir == temp_root / 'config' / 'themes' / 'catalog' - assert len(pipeline.themes) == 0 - - def test_load_themes_empty_dir(self, temp_root: Path): - """Test loading themes from empty directory.""" - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - - assert len(pipeline.themes) == 0 - assert pipeline.stats.total_themes == 0 - - def test_load_themes_with_valid_files(self, temp_root: Path, temp_catalog_dir: Path): - """Test loading valid theme files.""" - write_theme(temp_catalog_dir, 'landfall.yml', { - 'display_name': 'Landfall', - 'synergies': ['Ramp', 'Tokens'], - 'example_commanders': [] - }) - write_theme(temp_catalog_dir, 'reanimate.yml', { - 'display_name': 'Reanimate', - 'synergies': ['Graveyard', 'Mill'], - 'example_commanders': ['Meren of Clan Nel Toth'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - - assert len(pipeline.themes) == 2 - assert pipeline.stats.total_themes == 2 - - def test_autofill_placeholders_empty_examples(self, temp_root: Path, temp_catalog_dir: Path): - """Test autofill adds placeholders to themes with no examples.""" - write_theme(temp_catalog_dir, 'tokens.yml', { - 'display_name': 'Tokens Matter', - 'synergies': ['Sacrifice', 'Aristocrats'], - 'example_commanders': [] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.autofill_placeholders() - - assert pipeline.stats.autofilled == 1 - theme = list(pipeline.themes.values())[0] - assert theme.modified - assert 'Tokens Matter Anchor' in theme.data['example_commanders'] - assert 'Sacrifice Anchor' in theme.data['example_commanders'] - assert 'Aristocrats Anchor' in theme.data['example_commanders'] - assert theme.data.get('editorial_quality') == 'draft' - - def test_autofill_skips_themes_with_examples(self, temp_root: Path, temp_catalog_dir: Path): - """Test autofill skips themes that already have examples.""" - write_theme(temp_catalog_dir, 'landfall.yml', { - 'display_name': 'Landfall', - 'synergies': ['Ramp'], - 'example_commanders': ['Tatyova, Benthic Druid'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.autofill_placeholders() - - assert pipeline.stats.autofilled == 0 - theme = list(pipeline.themes.values())[0] - assert not theme.modified - - def test_pad_examples_to_minimum(self, temp_root: Path, temp_catalog_dir: Path): - """Test padding adds placeholders to reach minimum threshold.""" - write_theme(temp_catalog_dir, 'ramp.yml', { - 'display_name': 'Ramp', - 'synergies': ['Landfall', 'BigSpells', 'Hydras'], - 'example_commanders': ['Ramp Anchor', 'Landfall Anchor'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root, min_examples=5) - pipeline.load_all_themes() - pipeline.pad_examples() - - assert pipeline.stats.padded == 1 - theme = list(pipeline.themes.values())[0] - assert theme.modified - assert len(theme.data['example_commanders']) == 5 - # Should add synergies first (3rd synergy), then letter suffixes - assert 'Hydras Anchor' in theme.data['example_commanders'] - # Should also have letter suffixes for remaining slots - assert any('Anchor B' in cmd or 'Anchor C' in cmd for cmd in theme.data['example_commanders']) - - def test_pad_skips_mixed_real_and_placeholder(self, temp_root: Path, temp_catalog_dir: Path): - """Test padding skips lists with both real and placeholder examples.""" - write_theme(temp_catalog_dir, 'tokens.yml', { - 'display_name': 'Tokens', - 'synergies': ['Sacrifice'], - 'example_commanders': ['Krenko, Mob Boss', 'Tokens Anchor'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root, min_examples=5) - pipeline.load_all_themes() - pipeline.pad_examples() - - assert pipeline.stats.padded == 0 - theme = list(pipeline.themes.values())[0] - assert not theme.modified - - def test_cleanup_removes_placeholders_when_real_present(self, temp_root: Path, temp_catalog_dir: Path): - """Test cleanup removes placeholders when real examples are present. - - Note: cleanup only removes entries ending with ' Anchor' (no suffix). - Purge step removes entries with ' Anchor' or ' Anchor X' pattern. - """ - write_theme(temp_catalog_dir, 'lifegain.yml', { - 'display_name': 'Lifegain', - 'synergies': [], - 'example_commanders': [ - 'Oloro, Ageless Ascetic', - 'Lifegain Anchor', # Will be removed - 'Trelasarra, Moon Dancer', - ] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.cleanup_placeholders() - - assert pipeline.stats.cleaned == 1 - theme = list(pipeline.themes.values())[0] - assert theme.modified - assert len(theme.data['example_commanders']) == 2 - assert 'Oloro, Ageless Ascetic' in theme.data['example_commanders'] - assert 'Trelasarra, Moon Dancer' in theme.data['example_commanders'] - assert 'Lifegain Anchor' not in theme.data['example_commanders'] - - def test_purge_removes_all_anchors(self, temp_root: Path, temp_catalog_dir: Path): - """Test purge removes all anchor placeholders (even if no real examples).""" - write_theme(temp_catalog_dir, 'counters.yml', { - 'display_name': 'Counters', - 'synergies': [], - 'example_commanders': [ - 'Counters Anchor', - 'Counters Anchor B', - 'Counters Anchor C' - ] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.purge_anchors() - - assert pipeline.stats.purged == 1 - theme = list(pipeline.themes.values())[0] - assert theme.modified - assert theme.data['example_commanders'] == [] - - def test_augment_from_catalog(self, temp_root: Path, temp_catalog_dir: Path): - """Test augmentation adds missing fields from catalog.""" - # Create catalog JSON - catalog_json = temp_root / 'config' / 'themes' / 'theme_list.json' - catalog_data = { - 'themes': [ - { - 'theme': 'Landfall', - 'description': 'Triggers from lands entering', - 'popularity_bucket': 'common', - 'popularity_hint': 'Very popular', - 'deck_archetype': 'Lands' - } - ] - } - import json - catalog_json.write_text(json.dumps(catalog_data), encoding='utf-8') - - write_theme(temp_catalog_dir, 'landfall.yml', { - 'display_name': 'Landfall', - 'synergies': ['Ramp'], - 'example_commanders': ['Tatyova, Benthic Druid'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.augment_from_catalog() - - assert pipeline.stats.augmented == 1 - theme = list(pipeline.themes.values())[0] - assert theme.modified - assert theme.data['description'] == 'Triggers from lands entering' - assert theme.data['popularity_bucket'] == 'common' - assert theme.data['popularity_hint'] == 'Very popular' - assert theme.data['deck_archetype'] == 'Lands' - - def test_validate_min_examples_warning(self, temp_root: Path, temp_catalog_dir: Path): - """Test validation warns about insufficient examples.""" - write_theme(temp_catalog_dir, 'ramp.yml', { - 'display_name': 'Ramp', - 'synergies': [], - 'example_commanders': ['Ramp Commander'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root, min_examples=5) - pipeline.load_all_themes() - pipeline.validate(enforce_min=False) - - assert pipeline.stats.lint_warnings > 0 - assert pipeline.stats.lint_errors == 0 - - def test_validate_min_examples_error(self, temp_root: Path, temp_catalog_dir: Path): - """Test validation errors on insufficient examples when enforced.""" - write_theme(temp_catalog_dir, 'ramp.yml', { - 'display_name': 'Ramp', - 'synergies': [], - 'example_commanders': ['Ramp Commander'] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root, min_examples=5) - pipeline.load_all_themes() - pipeline.validate(enforce_min=True) - - assert pipeline.stats.lint_errors > 0 - - def test_write_themes_dry_run(self, temp_root: Path, temp_catalog_dir: Path): - """Test dry run doesn't write files.""" - theme_path = write_theme(temp_catalog_dir, 'tokens.yml', { - 'display_name': 'Tokens', - 'synergies': [], - 'example_commanders': [] - }) - - original_content = theme_path.read_text(encoding='utf-8') - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.autofill_placeholders() - # Don't call write_all_themes() - - # File should be unchanged - assert theme_path.read_text(encoding='utf-8') == original_content - - def test_write_themes_saves_changes(self, temp_root: Path, temp_catalog_dir: Path): - """Test write_all_themes saves modified files.""" - theme_path = write_theme(temp_catalog_dir, 'tokens.yml', { - 'display_name': 'Tokens', - 'synergies': ['Sacrifice'], - 'example_commanders': [] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root) - pipeline.load_all_themes() - pipeline.autofill_placeholders() - pipeline.write_all_themes() - - # File should be updated - updated_data = read_theme(theme_path) - assert len(updated_data['example_commanders']) > 0 - assert 'Tokens Anchor' in updated_data['example_commanders'] - - def test_run_all_full_pipeline(self, temp_root: Path, temp_catalog_dir: Path): - """Test running the complete enrichment pipeline.""" - write_theme(temp_catalog_dir, 'landfall.yml', { - 'display_name': 'Landfall', - 'synergies': ['Ramp', 'Lands'], - 'example_commanders': [] - }) - write_theme(temp_catalog_dir, 'reanimate.yml', { - 'display_name': 'Reanimate', - 'synergies': ['Graveyard'], - 'example_commanders': [] - }) - - pipeline = ThemeEnrichmentPipeline(root=temp_root, min_examples=5) - stats = pipeline.run_all(write=True, enforce_min=False, strict_lint=False) - - assert stats.total_themes == 2 - assert stats.autofilled >= 2 - assert stats.padded >= 2 - - # Verify files were updated - landfall_data = read_theme(temp_catalog_dir / 'landfall.yml') - assert len(landfall_data['example_commanders']) >= 5 - assert landfall_data.get('editorial_quality') == 'draft' - - -def test_run_enrichment_pipeline_convenience_function(temp_root: Path, temp_catalog_dir: Path): - """Test the convenience function wrapper.""" - write_theme(temp_catalog_dir, 'tokens.yml', { - 'display_name': 'Tokens', - 'synergies': ['Sacrifice'], - 'example_commanders': [] - }) - - stats = run_enrichment_pipeline( - root=temp_root, - min_examples=3, - write=True, - enforce_min=False, - strict=False, - progress_callback=None, - ) - - assert isinstance(stats, EnrichmentStats) - assert stats.total_themes == 1 - assert stats.autofilled >= 1 - - # Verify file was written - tokens_data = read_theme(temp_catalog_dir / 'tokens.yml') - assert len(tokens_data['example_commanders']) >= 3 diff --git a/code/tests/test_theme_picker_gaps.py b/code/tests/test_theme_picker_gaps.py index 0146cce..6e7f5c9 100644 --- a/code/tests/test_theme_picker_gaps.py +++ b/code/tests/test_theme_picker_gaps.py @@ -36,7 +36,7 @@ from fastapi.testclient import TestClient def _get_app(): # local import to avoid heavy import cost if file unused - from code.web.app import app + from code.web.app import app # type: ignore return app @@ -115,13 +115,13 @@ def test_preview_cache_hit_timing(monkeypatch, client): r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12") assert r1.status_code == 200 # Monkeypatch theme_preview._now to freeze time so second call counts as hit - import code.web.services.theme_preview as tp + import code.web.services.theme_preview as tp # type: ignore orig_now = tp._now monkeypatch.setattr(tp, "_now", lambda: orig_now()) r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12") assert r2.status_code == 200 # Deterministic service-level verification: second direct function call should short-circuit via cache - import code.web.services.theme_preview as tp + import code.web.services.theme_preview as tp # type: ignore # Snapshot counters pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0) first_payload = tp.get_theme_preview(theme_id, limit=12) diff --git a/code/tests/test_theme_preview_additional.py b/code/tests/test_theme_preview_additional.py index 33aff75..f9a848f 100644 --- a/code/tests/test_theme_preview_additional.py +++ b/code/tests/test_theme_preview_additional.py @@ -16,7 +16,7 @@ def _new_client(prewarm: bool = False) -> TestClient: # Remove existing module (if any) so lifespan runs again if 'code.web.app' in list(importlib.sys.modules.keys()): importlib.sys.modules.pop('code.web.app') - from code.web.app import app + from code.web.app import app # type: ignore return TestClient(app) diff --git a/code/tests/test_theme_preview_ordering.py b/code/tests/test_theme_preview_ordering.py index f0143f5..5cbebdf 100644 --- a/code/tests/test_theme_preview_ordering.py +++ b/code/tests/test_theme_preview_ordering.py @@ -2,8 +2,8 @@ from __future__ import annotations import pytest -from code.web.services.theme_preview import get_theme_preview -from code.web.services.theme_catalog_loader import load_index, slugify, project_detail +from code.web.services.theme_preview import get_theme_preview # type: ignore +from code.web.services.theme_catalog_loader import load_index, slugify, project_detail # type: ignore @pytest.mark.parametrize("limit", [8, 12]) diff --git a/code/tests/test_theme_preview_p0_new.py b/code/tests/test_theme_preview_p0_new.py index a35956f..171893d 100644 --- a/code/tests/test_theme_preview_p0_new.py +++ b/code/tests/test_theme_preview_p0_new.py @@ -1,7 +1,7 @@ import os import time import json -from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache +from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore def test_colors_filter_constraint_green_subset(): diff --git a/code/tests/test_theme_spell_weighting.py b/code/tests/test_theme_spell_weighting.py index 637940a..e95d60b 100644 --- a/code/tests/test_theme_spell_weighting.py +++ b/code/tests/test_theme_spell_weighting.py @@ -47,10 +47,10 @@ class DummySpellBuilder(SpellAdditionMixin): def rng(self) -> DummyRNG: return self._rng - def get_theme_context(self) -> ThemeContext: + def get_theme_context(self) -> ThemeContext: # type: ignore[override] return self._theme_context - def add_card(self, name: str, **kwargs: Any) -> None: + def add_card(self, name: str, **kwargs: Any) -> None: # type: ignore[override] self.card_library[name] = {"Count": kwargs.get("count", 1)} self.added_cards.append(name) diff --git a/code/tests/test_web_new_deck_partner.py b/code/tests/test_web_new_deck_partner.py index 655f081..703dd9f 100644 --- a/code/tests/test_web_new_deck_partner.py +++ b/code/tests/test_web_new_deck_partner.py @@ -20,7 +20,7 @@ def _fresh_client() -> TestClient: from code.web.services.commander_catalog_loader import clear_commander_catalog_cache clear_commander_catalog_cache() - from code.web.app import app + from code.web.app import app # type: ignore client = TestClient(app) from code.web.services import tasks diff --git a/code/tests/test_web_tag_endpoints.py b/code/tests/test_web_tag_endpoints.py deleted file mode 100644 index 9a5c8c3..0000000 --- a/code/tests/test_web_tag_endpoints.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Tests for web tag search endpoints.""" -import pytest -from fastapi.testclient import TestClient - - -@pytest.fixture -def client(): - """Create a test client for the web app.""" - # Import here to avoid circular imports - from code.web.app import app - return TestClient(app) - - -def test_theme_autocomplete_basic(client): - """Test basic theme autocomplete functionality.""" - response = client.get("/commanders/theme-autocomplete?theme=life&limit=5") - - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - - content = response.text - assert "autocomplete-item" in content - assert "Life" in content # Should match tags starting with "life" - assert "tag-count" in content # Should show card counts - - -def test_theme_autocomplete_min_length(client): - """Test that theme autocomplete requires minimum 2 characters.""" - response = client.get("/commanders/theme-autocomplete?theme=a&limit=5") - - # Should fail validation - assert response.status_code == 422 - - -def test_theme_autocomplete_no_matches(client): - """Test theme autocomplete with query that has no matches.""" - response = client.get("/commanders/theme-autocomplete?theme=zzzzzzzzz&limit=5") - - assert response.status_code == 200 - content = response.text - assert "autocomplete-empty" in content or "No matching themes" in content - - -def test_theme_autocomplete_limit(client): - """Test that theme autocomplete respects limit parameter.""" - response = client.get("/commanders/theme-autocomplete?theme=a&limit=3") - - assert response.status_code in [200, 422] # May fail min_length validation - - # Try with valid length - response = client.get("/commanders/theme-autocomplete?theme=to&limit=3") - assert response.status_code == 200 - - # Count items (rough check - should have at most 3) - content = response.text - item_count = content.count('class="autocomplete-item"') - assert item_count <= 3 - - -def test_api_cards_by_tags_and_logic(client): - """Test card search with AND logic.""" - response = client.get("/api/cards/by-tags?tags=tokens&logic=AND&limit=10") - - assert response.status_code == 200 - data = response.json() - - assert "tags" in data - assert "logic" in data - assert data["logic"] == "AND" - assert "total_matches" in data - assert "cards" in data - assert isinstance(data["cards"], list) - - -def test_api_cards_by_tags_or_logic(client): - """Test card search with OR logic.""" - response = client.get("/api/cards/by-tags?tags=tokens,sacrifice&logic=OR&limit=10") - - assert response.status_code == 200 - data = response.json() - - assert data["logic"] == "OR" - assert "cards" in data - - -def test_api_cards_by_tags_invalid_logic(client): - """Test that invalid logic parameter returns error.""" - response = client.get("/api/cards/by-tags?tags=tokens&logic=INVALID&limit=10") - - assert response.status_code == 400 - data = response.json() - assert "error" in data - - -def test_api_cards_by_tags_empty_tags(client): - """Test that empty tags parameter returns error.""" - response = client.get("/api/cards/by-tags?tags=&logic=AND&limit=10") - - assert response.status_code == 400 - data = response.json() - assert "error" in data - - -def test_api_tags_search(client): - """Test tag search autocomplete endpoint.""" - response = client.get("/api/cards/tags/search?q=life&limit=10") - - assert response.status_code == 200 - data = response.json() - - assert "query" in data - assert data["query"] == "life" - assert "matches" in data - assert isinstance(data["matches"], list) - - # Check match structure - if data["matches"]: - match = data["matches"][0] - assert "tag" in match - assert "card_count" in match - assert match["tag"].lower().startswith("life") - - -def test_api_tags_search_min_length(client): - """Test that tag search requires minimum 2 characters.""" - response = client.get("/api/cards/tags/search?q=a&limit=10") - - # Should fail validation - assert response.status_code == 422 - - -def test_api_tags_popular(client): - """Test popular tags endpoint.""" - response = client.get("/api/cards/tags/popular?limit=20") - - assert response.status_code == 200 - data = response.json() - - assert "count" in data - assert "tags" in data - assert isinstance(data["tags"], list) - assert data["count"] == len(data["tags"]) - assert data["count"] <= 20 - - # Check tag structure - if data["tags"]: - tag = data["tags"][0] - assert "tag" in tag - assert "card_count" in tag - assert isinstance(tag["card_count"], int) - - # Tags should be sorted by card count (descending) - if len(data["tags"]) > 1: - assert data["tags"][0]["card_count"] >= data["tags"][1]["card_count"] - - -def test_api_tags_popular_limit(client): - """Test that popular tags endpoint respects limit.""" - response = client.get("/api/cards/tags/popular?limit=5") - - assert response.status_code == 200 - data = response.json() - - assert len(data["tags"]) <= 5 - - -def test_commanders_page_loads(client): - """Test that commanders page loads successfully.""" - response = client.get("/commanders") - - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - - content = response.text - # Should have the theme filter input - assert "commander-theme" in content - assert "theme-suggestions" in content - - -def test_commanders_page_with_theme_filter(client): - """Test commanders page with theme query parameter.""" - response = client.get("/commanders?theme=tokens") - - assert response.status_code == 200 - content = response.text - - # Should have the theme value in the input - assert 'value="tokens"' in content or "tokens" in content - - -@pytest.mark.skip(reason="Performance test - run manually") -def test_theme_autocomplete_performance(client): - """Test that theme autocomplete responds quickly.""" - import time - - start = time.time() - response = client.get("/commanders/theme-autocomplete?theme=to&limit=20") - elapsed = time.time() - start - - assert response.status_code == 200 - assert elapsed < 0.05 # Should respond in <50ms - - -@pytest.mark.skip(reason="Performance test - run manually") -def test_api_tags_search_performance(client): - """Test that tag search responds quickly.""" - import time - - start = time.time() - response = client.get("/api/cards/tags/search?q=to&limit=20") - elapsed = time.time() - start - - assert response.status_code == 200 - assert elapsed < 0.05 # Should respond in <50ms diff --git a/code/type_definitions_theme_catalog.py b/code/type_definitions_theme_catalog.py index dbcae13..da88ae0 100644 --- a/code/type_definitions_theme_catalog.py +++ b/code/type_definitions_theme_catalog.py @@ -87,7 +87,7 @@ class ThemeCatalog(BaseModel): def theme_names(self) -> List[str]: # convenience return [t.theme for t in self.themes] - def model_post_init(self, __context: Any) -> None: + def model_post_init(self, __context: Any) -> None: # type: ignore[override] # If only legacy 'provenance' provided, alias to metadata_info if self.metadata_info is None and self.provenance is not None: object.__setattr__(self, 'metadata_info', self.provenance) @@ -135,7 +135,7 @@ class ThemeYAMLFile(BaseModel): model_config = ConfigDict(extra='forbid') - def model_post_init(self, __context: Any) -> None: + def model_post_init(self, __context: Any) -> None: # type: ignore[override] if not self.metadata_info and self.provenance: object.__setattr__(self, 'metadata_info', self.provenance) if self.metadata_info and self.provenance: diff --git a/code/web/app.py b/code/web/app.py index 77f4f7c..afdfc49 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -19,12 +19,9 @@ from contextlib import asynccontextmanager from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics from tagging.multi_face_merger import load_merge_summary from .services.combo_utils import detect_all as _detect_all -from .services.theme_catalog_loader import prewarm_common_filters, load_index -from .services.commander_catalog_loader import load_commander_catalog -from .services.tasks import get_session, new_sid, set_session_value - -# Logger for app-level logging -logger = logging.getLogger(__name__) +from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore +from .services.commander_catalog_loader import load_commander_catalog # type: ignore +from .services.tasks import get_session, new_sid, set_session_value # type: ignore # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent @@ -56,30 +53,15 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue except Exception: pass try: - commanders_routes.prewarm_default_page() + commanders_routes.prewarm_default_page() # type: ignore[attr-defined] except Exception: pass # Warm preview card index once (updated Phase A: moved to card_index module) try: # local import to avoid cost if preview unused - from .services.card_index import maybe_build_index + from .services.card_index import maybe_build_index # type: ignore maybe_build_index() except Exception: pass - # Warm card browser theme catalog (fast CSV read) and theme index (slower card parsing) - try: - from .routes.card_browser import get_theme_catalog, get_theme_index - get_theme_catalog() # Fast: just reads CSV - get_theme_index() # Slower: parses cards for theme-to-card mapping - except Exception: - pass - # Warm CardSimilarity singleton (if card details enabled) - runs after theme index loads cards - try: - from code.settings import ENABLE_CARD_DETAILS - if ENABLE_CARD_DETAILS: - from .routes.card_browser import get_similarity - get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) - except Exception: - pass yield # (no shutdown tasks currently) @@ -89,7 +71,7 @@ app.add_middleware(GZipMiddleware, minimum_size=500) # Mount static if present if _STATIC_DIR.exists(): class CacheStatic(StaticFiles): - async def get_response(self, path, scope): + async def get_response(self, path, scope): # type: ignore[override] resp = await super().get_response(path, scope) try: # Add basic cache headers for static assets @@ -102,38 +84,12 @@ if _STATIC_DIR.exists(): # Jinja templates templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) -# Add custom Jinja2 filter for card image URLs -def card_image_url(card_name: str, size: str = "normal") -> str: - """ - Generate card image URL (uses local cache if available, falls back to Scryfall). - - For DFC cards (containing ' // '), extracts the front face name. - - Args: - card_name: Name of the card (may be "Front // Back" for DFCs) - size: Image size ('small' or 'normal') - - Returns: - URL for the card image - """ - from urllib.parse import quote - - # Extract front face name for DFCs (thumbnails always show front face) - display_name = card_name - if ' // ' in card_name: - display_name = card_name.split(' // ')[0].strip() - - # Use our API endpoint which handles cache lookup and fallback - return f"/api/images/{size}/{quote(display_name)}" - -templates.env.filters["card_image"] = card_image_url - # Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...}) # and reorder to the new signature TemplateResponse(request, name, {...}). # Prevents DeprecationWarning noise in tests without touching all call sites. _orig_template_response = templates.TemplateResponse -def _compat_template_response(*args, **kwargs): +def _compat_template_response(*args, **kwargs): # type: ignore[override] try: if args and isinstance(args[0], str): name = args[0] @@ -151,7 +107,7 @@ def _compat_template_response(*args, **kwargs): pass return _orig_template_response(*args, **kwargs) -templates.TemplateResponse = _compat_template_response +templates.TemplateResponse = _compat_template_response # type: ignore[assignment] # (Startup prewarm moved to lifespan handler _lifespan) @@ -172,10 +128,8 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False) ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) -WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider' ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True) ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True) -ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True) RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy) RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True) THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False) @@ -327,7 +281,7 @@ templates.env.globals.update({ # Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev' def _load_catalog_hash() -> str: try: # local import to avoid circular on early load - from .services.theme_catalog_loader import CATALOG_JSON + from .services.theme_catalog_loader import CATALOG_JSON # type: ignore if CATALOG_JSON.exists(): raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}") meta = raw.get("metadata_info") or {} @@ -869,12 +823,6 @@ async def home(request: Request) -> HTMLResponse: return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")}) -@app.get("/docs/components", response_class=HTMLResponse) -async def components_library(request: Request) -> HTMLResponse: - """M2 Component Library - showcase of standardized UI components""" - return templates.TemplateResponse("docs/components.html", {"request": request}) - - # Simple health check (hardened) @app.get("/healthz") async def healthz(): @@ -951,7 +899,7 @@ async def status_random_theme_stats(): if not SHOW_DIAGNOSTICS: raise HTTPException(status_code=404, detail="Not Found") try: - from deck_builder.random_entrypoint import get_theme_tag_stats + from deck_builder.random_entrypoint import get_theme_tag_stats # type: ignore stats = get_theme_tag_stats() return JSONResponse({"ok": True, "stats": stats}) @@ -1038,8 +986,8 @@ async def api_random_build(request: Request): except Exception: timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) # Import on-demand to avoid heavy costs at module import time - from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError - from deck_builder.random_entrypoint import RandomThemeNoMatchError + from deck_builder.random_entrypoint import build_random_deck, RandomConstraintsImpossibleError # type: ignore + from deck_builder.random_entrypoint import RandomThemeNoMatchError # type: ignore res = build_random_deck( theme=theme, @@ -1170,7 +1118,7 @@ async def api_random_full_build(request: Request): timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) # Build a full deck deterministically - from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError + from deck_builder.random_entrypoint import build_random_full_deck, RandomConstraintsImpossibleError # type: ignore res = build_random_full_deck( theme=theme, constraints=constraints, @@ -1394,7 +1342,7 @@ async def api_random_reroll(request: Request): except Exception: new_seed = None if new_seed is None: - from random_util import generate_seed + from random_util import generate_seed # type: ignore new_seed = int(generate_seed()) # Build with the new seed @@ -1405,7 +1353,7 @@ async def api_random_reroll(request: Request): timeout_s = max(0.1, float(RANDOM_TIMEOUT_MS) / 1000.0) attempts = body.get("attempts", int(RANDOM_MAX_ATTEMPTS)) - from deck_builder.random_entrypoint import build_random_full_deck + from deck_builder.random_entrypoint import build_random_full_deck # type: ignore res = build_random_full_deck( theme=theme, constraints=constraints, @@ -1786,10 +1734,10 @@ async def hx_random_reroll(request: Request): except Exception: new_seed = None if new_seed is None: - from random_util import generate_seed + from random_util import generate_seed # type: ignore new_seed = int(generate_seed()) # Import outside conditional to avoid UnboundLocalError when branch not taken - from deck_builder.random_entrypoint import build_random_full_deck + from deck_builder.random_entrypoint import build_random_full_deck # type: ignore try: t0 = time.time() _attempts = int(attempts_override) if attempts_override is not None else int(RANDOM_MAX_ATTEMPTS) @@ -1800,7 +1748,7 @@ async def hx_random_reroll(request: Request): _timeout_s = max(0.1, float(_timeout_ms) / 1000.0) if is_reroll_same: build_t0 = time.time() - from headless_runner import run as _run + from headless_runner import run as _run # type: ignore # Suppress builder's internal initial export to control artifact generation (matches full random path logic) try: import os as _os @@ -1813,18 +1761,18 @@ async def hx_random_reroll(request: Request): summary = None try: if hasattr(builder, 'build_deck_summary'): - summary = builder.build_deck_summary() + summary = builder.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None decklist = [] try: if hasattr(builder, 'deck_list_final'): - decklist = getattr(builder, 'deck_list_final') + decklist = getattr(builder, 'deck_list_final') # type: ignore[attr-defined] except Exception: decklist = [] # Controlled artifact export (single pass) - csv_path = getattr(builder, 'last_csv_path', None) - txt_path = getattr(builder, 'last_txt_path', None) + csv_path = getattr(builder, 'last_csv_path', None) # type: ignore[attr-defined] + txt_path = getattr(builder, 'last_txt_path', None) # type: ignore[attr-defined] compliance = None try: import os as _os @@ -1832,7 +1780,7 @@ async def hx_random_reroll(request: Request): # Perform exactly one export sequence now if not csv_path and hasattr(builder, 'export_decklist_csv'): try: - csv_path = builder.export_decklist_csv() + csv_path = builder.export_decklist_csv() # type: ignore[attr-defined] except Exception: csv_path = None if csv_path and isinstance(csv_path, str): @@ -1842,7 +1790,7 @@ async def hx_random_reroll(request: Request): try: base_name = _os.path.basename(base_path) + '.txt' if hasattr(builder, 'export_decklist_text'): - txt_path = builder.export_decklist_text(filename=base_name) + txt_path = builder.export_decklist_text(filename=base_name) # type: ignore[attr-defined] except Exception: # Fallback: if a txt already exists from a prior build reuse it if _os.path.isfile(base_path + '.txt'): @@ -1857,7 +1805,7 @@ async def hx_random_reroll(request: Request): else: try: if hasattr(builder, 'compute_and_print_compliance'): - compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) + compliance = builder.compute_and_print_compliance(base_stem=_os.path.basename(base_path)) # type: ignore[attr-defined] except Exception: compliance = None if summary: @@ -2051,7 +1999,7 @@ async def hx_random_reroll(request: Request): except Exception: _permalink = None resp = templates.TemplateResponse( - "partials/random_result.html", + "partials/random_result.html", # type: ignore { "request": request, "seed": int(res.seed), @@ -2246,14 +2194,6 @@ async def setup_status(): except Exception: return JSONResponse({"running": False, "phase": "error"}) - -# ============================================================================ -# Card Image Serving Endpoint - MOVED TO /routes/api.py -# ============================================================================ -# Image serving logic has been moved to code/web/routes/api.py -# The router is included below via: app.include_router(api_routes.router) - - # Routers from .routes import build as build_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402 @@ -2264,10 +2204,6 @@ from .routes import themes as themes_routes # noqa: E402 from .routes import commanders as commanders_routes # noqa: E402 from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402 from .routes import telemetry as telemetry_routes # noqa: E402 -from .routes import cards as cards_routes # noqa: E402 -from .routes import card_browser as card_browser_routes # noqa: E402 -from .routes import compare as compare_routes # noqa: E402 -from .routes import api as api_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2277,10 +2213,6 @@ app.include_router(themes_routes.router) app.include_router(commanders_routes.router) app.include_router(partner_suggestions_routes.router) app.include_router(telemetry_routes.router) -app.include_router(cards_routes.router) -app.include_router(card_browser_routes.router) -app.include_router(compare_routes.router) -app.include_router(api_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: @@ -2289,8 +2221,6 @@ except Exception: pass ## (Additional startup warmers consolidated into lifespan handler) -## Note: CardSimilarity uses lazy initialization pattern like AllCardsLoader -## First card detail page loads in ~200ms (singleton init), subsequent in ~60ms # --- Exception handling --- def _wants_html(request: Request) -> bool: @@ -2467,7 +2397,7 @@ async def logs_page( # Respect feature flag raise HTTPException(status_code=404, detail="Not Found") # Reuse status_logs logic - data = await status_logs(tail=tail, q=q, level=level) + data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type] lines: list[str] if isinstance(data, JSONResponse): payload = data.body diff --git a/code/web/routes/api.py b/code/web/routes/api.py deleted file mode 100644 index 157344b..0000000 --- a/code/web/routes/api.py +++ /dev/null @@ -1,299 +0,0 @@ -"""API endpoints for web services.""" - -from __future__ import annotations - -import logging -import threading -from pathlib import Path -from urllib.parse import quote_plus - -from fastapi import APIRouter, Query -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse - -from code.file_setup.image_cache import ImageCache - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api") - -# Global image cache instance -_image_cache = ImageCache() - - -@router.get("/images/status") -async def get_download_status(): - """ - Get current image download status. - - Returns: - JSON response with download status - """ - import json - - status_file = Path("card_files/images/.download_status.json") - - if not status_file.exists(): - # Check cache statistics if no download in progress - stats = _image_cache.cache_statistics() - return JSONResponse({ - "running": False, - "stats": stats - }) - - try: - with status_file.open('r', encoding='utf-8') as f: - status = json.load(f) - return JSONResponse(status) - except Exception as e: - logger.warning(f"Could not read status file: {e}") - return JSONResponse({ - "running": False, - "error": str(e) - }) - - -@router.get("/images/debug") -async def get_image_debug(): - """ - Debug endpoint to check image cache configuration. - - Returns: - JSON with debug information - """ - import os - from pathlib import Path - - base_dir = Path(_image_cache.base_dir) - - debug_info = { - "cache_enabled": _image_cache.is_enabled(), - "env_var": os.getenv("CACHE_CARD_IMAGES", "not set"), - "base_dir": str(base_dir), - "base_dir_exists": base_dir.exists(), - "small_dir": str(base_dir / "small"), - "small_dir_exists": (base_dir / "small").exists(), - "normal_dir": str(base_dir / "normal"), - "normal_dir_exists": (base_dir / "normal").exists(), - } - - # Count files if directories exist - if (base_dir / "small").exists(): - debug_info["small_count"] = len(list((base_dir / "small").glob("*.jpg"))) - if (base_dir / "normal").exists(): - debug_info["normal_count"] = len(list((base_dir / "normal").glob("*.jpg"))) - - # Test with a sample card name - test_card = "Lightning Bolt" - debug_info["test_card"] = test_card - test_path_small = _image_cache.get_image_path(test_card, "small") - test_path_normal = _image_cache.get_image_path(test_card, "normal") - debug_info["test_path_small"] = str(test_path_small) if test_path_small else None - debug_info["test_path_normal"] = str(test_path_normal) if test_path_normal else None - debug_info["test_exists_small"] = test_path_small.exists() if test_path_small else False - debug_info["test_exists_normal"] = test_path_normal.exists() if test_path_normal else False - - return JSONResponse(debug_info) - - -@router.get("/images/{size}/{card_name}") -async def get_card_image(size: str, card_name: str, face: str = Query(default="front")): - """ - Serve card image from cache or redirect to Scryfall API. - - Args: - size: Image size ('small' or 'normal') - card_name: Name of the card - face: Which face to show ('front' or 'back') for DFC cards - - Returns: - FileResponse if cached locally, RedirectResponse to Scryfall API otherwise - """ - # Validate size parameter - if size not in ["small", "normal"]: - size = "normal" - - # Check if caching is enabled - cache_enabled = _image_cache.is_enabled() - - # Check if image exists in cache - if cache_enabled: - image_path = None - - # For DFC cards, handle front/back faces differently - if " // " in card_name: - if face == "back": - # For back face, ONLY try the back face name - back_face = card_name.split(" // ")[1].strip() - logger.debug(f"DFC back face requested: {back_face}") - image_path = _image_cache.get_image_path(back_face, size) - else: - # For front face (or unspecified), try front face name - front_face = card_name.split(" // ")[0].strip() - logger.debug(f"DFC front face requested: {front_face}") - image_path = _image_cache.get_image_path(front_face, size) - else: - # Single-faced card, try exact name - image_path = _image_cache.get_image_path(card_name, size) - - if image_path and image_path.exists(): - logger.info(f"Serving cached image: {card_name} ({size}, {face})") - return FileResponse( - image_path, - media_type="image/jpeg", - headers={ - "Cache-Control": "public, max-age=31536000", # 1 year - } - ) - else: - logger.debug(f"No cached image found for: {card_name} (face: {face})") - - # Fallback to Scryfall API - # For back face requests of DFC cards, we need the full card name - scryfall_card_name = card_name - scryfall_params = f"fuzzy={quote_plus(scryfall_card_name)}&format=image&version={size}" - - # If this is a back face request, try to find the full DFC name - if face == "back": - try: - from code.services.all_cards_loader import AllCardsLoader - loader = AllCardsLoader() - df = loader.load() - - # Look for cards where this face name appears in the card_faces - # The card name format is "Front // Back" - matching = df[df['name'].str.contains(card_name, case=False, na=False, regex=False)] - if not matching.empty: - # Find DFC cards (containing ' // ') - dfc_matches = matching[matching['name'].str.contains(' // ', na=False, regex=False)] - if not dfc_matches.empty: - # Use the first matching DFC card's full name - full_name = dfc_matches.iloc[0]['name'] - scryfall_card_name = full_name - # Add face parameter to Scryfall request - scryfall_params = f"exact={quote_plus(full_name)}&format=image&version={size}&face=back" - except Exception as e: - logger.warning(f"Could not lookup full card name for back face '{card_name}': {e}") - - scryfall_url = f"https://api.scryfall.com/cards/named?{scryfall_params}" - return RedirectResponse(scryfall_url) - - -@router.post("/images/download") -async def download_images(): - """ - Start downloading card images in background. - - Returns: - JSON response with status - """ - if not _image_cache.is_enabled(): - return JSONResponse({ - "ok": False, - "message": "Image caching is disabled. Set CACHE_CARD_IMAGES=1 to enable." - }, status_code=400) - - # Write initial status - try: - status_dir = Path("card_files/images") - status_dir.mkdir(parents=True, exist_ok=True) - status_file = status_dir / ".download_status.json" - - import json - with status_file.open('w', encoding='utf-8') as f: - json.dump({ - "running": True, - "phase": "bulk_data", - "message": "Downloading Scryfall bulk data...", - "current": 0, - "total": 0, - "percentage": 0 - }, f) - except Exception as e: - logger.warning(f"Could not write initial status: {e}") - - # Start download in background thread - def _download_task(): - import json - status_file = Path("card_files/images/.download_status.json") - - try: - # Download bulk data first - logger.info("[IMAGE DOWNLOAD] Starting bulk data download...") - - def bulk_progress(downloaded: int, total: int): - """Progress callback for bulk data download.""" - try: - percentage = int(downloaded / total * 100) if total > 0 else 0 - with status_file.open('w', encoding='utf-8') as f: - json.dump({ - "running": True, - "phase": "bulk_data", - "message": f"Downloading bulk data: {percentage}%", - "current": downloaded, - "total": total, - "percentage": percentage - }, f) - except Exception as e: - logger.warning(f"Could not update bulk progress: {e}") - - _image_cache.download_bulk_data(progress_callback=bulk_progress) - - # Download images - logger.info("[IMAGE DOWNLOAD] Starting image downloads...") - - def image_progress(current: int, total: int, card_name: str): - """Progress callback for image downloads.""" - try: - percentage = int(current / total * 100) if total > 0 else 0 - with status_file.open('w', encoding='utf-8') as f: - json.dump({ - "running": True, - "phase": "images", - "message": f"Downloading images: {card_name}", - "current": current, - "total": total, - "percentage": percentage - }, f) - - # Log progress every 100 cards - if current % 100 == 0: - logger.info(f"[IMAGE DOWNLOAD] Progress: {current}/{total} ({percentage}%)") - - except Exception as e: - logger.warning(f"Could not update image progress: {e}") - - stats = _image_cache.download_images(progress_callback=image_progress) - - # Write completion status - with status_file.open('w', encoding='utf-8') as f: - json.dump({ - "running": False, - "phase": "complete", - "message": f"Download complete: {stats.get('downloaded', 0)} new images", - "stats": stats, - "percentage": 100 - }, f) - - logger.info(f"[IMAGE DOWNLOAD] Complete: {stats}") - - except Exception as e: - logger.error(f"[IMAGE DOWNLOAD] Failed: {e}", exc_info=True) - try: - with status_file.open('w', encoding='utf-8') as f: - json.dump({ - "running": False, - "phase": "error", - "message": f"Download failed: {str(e)}", - "percentage": 0 - }, f) - except Exception: - pass - - # Start background thread - thread = threading.Thread(target=_download_task, daemon=True) - thread.start() - - return JSONResponse({ - "ok": True, - "message": "Image download started in background" - }, status_code=202) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index c9c9090..676ae71 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1,8 +1,8 @@ from __future__ import annotations -from fastapi import APIRouter, Request, Form, Query, BackgroundTasks +from fastapi import APIRouter, Request, Form, Query from fastapi.responses import HTMLResponse, JSONResponse -from typing import Any, Dict, Iterable +from typing import Any, Iterable import json from ..app import ( ALLOW_MUST_HAVES, @@ -13,8 +13,6 @@ from ..app import ( _sanitize_theme, ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS, - WEB_IDEALS_UI, - ENABLE_BATCH_BUILD, ) from ..services.build_utils import ( step5_base_ctx, @@ -25,12 +23,11 @@ from ..services.build_utils import ( owned_set as owned_set_helper, builder_present_names, builder_display_map, - commander_hover_context, ) from ..app import templates from deck_builder import builder_constants as bc from ..services import orchestrator as orch -from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale +from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale # type: ignore from ..services.build_utils import owned_names as owned_names_helper from ..services.tasks import get_session, new_sid from html import escape as _esc @@ -119,7 +116,7 @@ def _available_cards_normalized() -> tuple[set[str], dict[str, str]]: from deck_builder.include_exclude_utils import normalize_punctuation except Exception: # Fallback: identity normalization - def normalize_punctuation(x: str) -> str: + def normalize_punctuation(x: str) -> str: # type: ignore return str(x).strip().casefold() norm_map: dict[str, str] = {} for name in names: @@ -470,7 +467,7 @@ def _background_options_from_commander_catalog() -> list[dict[str, Any]]: seen: set[str] = set() options: list[dict[str, Any]] = [] - for record in getattr(catalog, "entries", ()): + for record in getattr(catalog, "entries", ()): # type: ignore[attr-defined] if not getattr(record, "is_background", False): continue name = getattr(record, "display_name", None) @@ -1108,8 +1105,6 @@ async def build_index(request: Request) -> HTMLResponse: if q_commander: # Persist a human-friendly commander name into session for the wizard sess["commander"] = str(q_commander) - # Set flag to indicate this is a quick-build scenario - sess["quick_build"] = True except Exception: pass return_url = None @@ -1149,17 +1144,12 @@ async def build_index(request: Request) -> HTMLResponse: last_step = 2 else: last_step = 1 - # Only pass commander to template if coming from commander browser (?commander= query param) - # This prevents stale commander from being pre-filled on subsequent builds - # The query param only exists on initial navigation from commander browser - should_auto_fill = q_commander is not None - resp = templates.TemplateResponse( request, "build/index.html", { "sid": sid, - "commander": sess.get("commander") if should_auto_fill else None, + "commander": sess.get("commander"), "tags": sess.get("tags", []), "name": sess.get("custom_export_base"), "last_step": last_step, @@ -1334,42 +1324,6 @@ async def build_new_modal(request: Request) -> HTMLResponse: """Return the New Deck modal content (for an overlay).""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) - - # Clear build context to allow skip controls to work - # (Otherwise toggle endpoint thinks build is in progress) - if "build_ctx" in sess: - try: - del sess["build_ctx"] - except Exception: - pass - - # M2: Clear all skip preferences for true "New Deck" - skip_keys = [ - "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", - "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", - "skip_all_creatures", - "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", - "skip_all_spells", - "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", - "skip_protection", "skip_spell_fill", - "skip_post_adjust" - ] - for key in skip_keys: - sess.pop(key, None) - - # M2: Check if this is a quick-build scenario (from commander browser) - # Use the quick_build flag set by /build route when ?commander= param present - is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag - - # M2: Clear commander and form selections for fresh start (unless quick build) - if not is_quick_build: - commander_keys = [ - "commander", "partner", "background", "commander_mode", - "themes", "bracket" - ] - for key in commander_keys: - sess.pop(key, None) - theme_context = _custom_theme_context(request, sess) ctx = { "request": request, @@ -1379,10 +1333,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, - "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "form": { - "commander": sess.get("commander", ""), # Pre-fill for quick-build "prefer_combos": bool(sess.get("prefer_combos")), "combo_count": sess.get("combo_target_count"), "combo_balance": sess.get("combo_balance"), @@ -1390,15 +1341,6 @@ async def build_new_modal(request: Request) -> HTMLResponse: "use_owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - # Add ideal values from session (will be None on first load, triggering defaults) - "ramp": sess.get("ideals", {}).get("ramp"), - "lands": sess.get("ideals", {}).get("lands"), - "basic_lands": sess.get("ideals", {}).get("basic_lands"), - "creatures": sess.get("ideals", {}).get("creatures"), - "removal": sess.get("ideals", {}).get("removal"), - "wipes": sess.get("ideals", {}).get("wipes"), - "card_advantage": sess.get("ideals", {}).get("card_advantage"), - "protection": sess.get("ideals", {}).get("protection"), }, "tag_slot_html": None, } @@ -1505,14 +1447,20 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes merged_tags.append(token) ctx["tags"] = merged_tags - # Deduplicate recommended: remove any that are already in partner_tags - partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} existing_recommended = ctx.get("recommended") or [] - deduplicated_recommended = [ - tag for tag in existing_recommended - if str(tag).strip().casefold() not in partner_tags_lower - ] - ctx["recommended"] = deduplicated_recommended + merged_recommended: list[str] = [] + rec_seen: set[str] = set() + for source in (partner_tags, existing_recommended): + for tag in source: + token = str(tag).strip() + if not token: + continue + key = token.casefold() + if key in rec_seen: + continue + rec_seen.add(key) + merged_recommended.append(token) + ctx["recommended"] = merged_recommended reason_map = dict(ctx.get("recommended_reasons") or {}) for tag in partner_tags: @@ -1674,265 +1622,9 @@ async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> return resp -@router.post("/new/toggle-skip", response_class=JSONResponse) -async def build_new_toggle_skip( - request: Request, - skip_key: str = Form(...), - enabled: str = Form(...), -) -> JSONResponse: - """Toggle a skip configuration flag (wizard-only, before build starts). - - Enforces mutual exclusivity: - - skip_lands and skip_to_misc are mutually exclusive with individual land flags - - Individual land flags are mutually exclusive with each other - """ - sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") - if not sid: - return JSONResponse({"error": "No session ID"}, status_code=400) - - sess = get_session(sid) - - # Wizard-only: reject if build has started - if "build_ctx" in sess: - return JSONResponse({"error": "Cannot modify skip settings after build has started"}, status_code=400) - - # Validate skip_key - valid_keys = { - "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", - "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", - "skip_all_creatures", - "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", - "skip_all_spells", - "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", - "skip_protection", "skip_spell_fill", - "skip_post_adjust" - } - - if skip_key not in valid_keys: - return JSONResponse({"error": f"Invalid skip key: {skip_key}"}, status_code=400) - - # Parse enabled flag - enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} - - # Mutual exclusivity rules - land_group_flags = {"skip_lands", "skip_to_misc"} - individual_land_flags = {"skip_basics", "skip_staples", "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes"} - creature_specific_flags = {"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill"} - spell_specific_flags = {"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", "skip_protection", "skip_spell_fill"} - - # If enabling a flag, check for conflicts - if enabled_flag: - # Rule 1: skip_lands/skip_to_misc disables all individual land flags - if skip_key in land_group_flags: - for key in individual_land_flags: - sess[key] = False - - # Rule 2: Individual land flags disable skip_lands/skip_to_misc - elif skip_key in individual_land_flags: - for key in land_group_flags: - sess[key] = False - - # Rule 3: skip_all_creatures disables specific creature flags - elif skip_key == "skip_all_creatures": - for key in creature_specific_flags: - sess[key] = False - - # Rule 4: Specific creature flags disable skip_all_creatures - elif skip_key in creature_specific_flags: - sess["skip_all_creatures"] = False - - # Rule 5: skip_all_spells disables specific spell flags - elif skip_key == "skip_all_spells": - for key in spell_specific_flags: - sess[key] = False - - # Rule 6: Specific spell flags disable skip_all_spells - elif skip_key in spell_specific_flags: - sess["skip_all_spells"] = False - - # Set the requested flag - sess[skip_key] = enabled_flag - - # Auto-enable skip_post_adjust when any other skip is enabled - if enabled_flag and skip_key != "skip_post_adjust": - sess["skip_post_adjust"] = True - - # Auto-disable skip_post_adjust when all other skips are disabled - if not enabled_flag: - any_other_skip = any( - sess.get(k, False) for k in valid_keys - if k != "skip_post_adjust" and k != skip_key - ) - if not any_other_skip: - sess["skip_post_adjust"] = False - - return JSONResponse({ - "success": True, - "skip_key": skip_key, - "enabled": enabled_flag, - "skip_post_adjust": bool(sess.get("skip_post_adjust", False)) - }) - - -def _get_descriptive_stage_label(stage: Dict[str, Any], ctx: Dict[str, Any]) -> str: - """Generate a more descriptive label for Quick Build progress display.""" - key = stage.get("key", "") - base_label = stage.get("label", "") - - # Land stages - show what type of lands - land_types = { - "land1": "Basics", - "land2": "Staples", - "land3": "Fetches", - "land4": "Duals", - "land5": "Triomes", - "land6": "Kindred", - "land7": "Misc Utility", - "land8": "Final Lands" - } - if key in land_types: - return f"Lands: {land_types[key]}" - - # Creature stages - show associated theme - if "creatures" in key: - tags = ctx.get("tags", []) - if key == "creatures_all_theme": - if tags: - all_tags = " + ".join(tags[:3]) # Show up to 3 tags - return f"Creatures: All Themes ({all_tags})" - return "Creatures: All Themes" - elif key == "creatures_primary" and len(tags) >= 1: - return f"Creatures: {tags[0]}" - elif key == "creatures_secondary" and len(tags) >= 2: - return f"Creatures: {tags[1]}" - elif key == "creatures_tertiary" and len(tags) >= 3: - return f"Creatures: {tags[2]}" - # Let creatures_fill use default "Creatures: Fill" label - - # Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme - if key == "spells_fill": - return "Theme Spell Fill" - - # Default: return original label - return base_label - - -def _run_quick_build_stages(sid: str): - """Background task: Run all stages for Quick Build and update progress in session.""" - import logging - logger = logging.getLogger(__name__) - - logger.info(f"[Quick Build] Starting background task for sid={sid}") - - sess = get_session(sid) - logger.info(f"[Quick Build] Retrieved session: {sess is not None}") - - ctx = sess.get("build_ctx") - if not ctx: - logger.error(f"[Quick Build] No build_ctx found in session") - sess["quick_build_progress"] = { - "running": False, - "current_stage": "Error: No build context", - "completed_stages": [] - } - return - - logger.info(f"[Quick Build] build_ctx found with {len(ctx.get('stages', []))} stages") - - # CRITICAL: Inject session reference into context so skip config can be read - ctx["session"] = sess - logger.info("[Quick Build] Injected session reference into context") - - stages = ctx.get("stages", []) - res = None - - # Initialize progress tracking - sess["quick_build_progress"] = { - "running": True, - "current_stage": "Starting build..." - } - - try: - logger.info("[Quick Build] Starting stage loop") - - # Track which phase we're in for simplified progress display - current_phase = None - - while True: - current_idx = ctx.get("idx", 0) - if current_idx >= len(stages): - logger.info(f"[Quick Build] Reached end of stages (idx={current_idx})") - break - - current_stage = stages[current_idx] - stage_key = current_stage.get("key", "") - logger.info(f"[Quick Build] Stage {current_idx} key: {stage_key}") - - # Determine simplified phase label - if stage_key.startswith("creatures"): - new_phase = "Adding Creatures" - elif stage_key.startswith("spells") or stage_key in ["spells_ramp", "spells_removal", "spells_wipes", "spells_card_advantage", "spells_protection", "spells_fill"]: - new_phase = "Adding Spells" - elif stage_key.startswith("land"): - new_phase = "Adding Lands" - elif stage_key in ["post_spell_land_adjust", "reporting"]: - new_phase = "Doing Some Final Touches" - else: - new_phase = "Building Deck" - - # Only update progress if phase changed - if new_phase != current_phase: - current_phase = new_phase - sess["quick_build_progress"]["current_stage"] = current_phase - logger.info(f"[Quick Build] Phase: {current_phase}") - - # Run stage with show_skipped=False - res = orch.run_stage(ctx, rerun=False, show_skipped=False) - logger.info(f"[Quick Build] Stage {stage_key} completed, done={res.get('done')}") - - # Handle Multi-Copy package marking - try: - if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - - # Check if build is done (reporting stage marks done=True) - if res.get("done"): - break - - # run_stage() advances ctx["idx"] internally when stage completes successfully - # If stage is gated, it also advances the index, so we just continue the loop - - # Show summary generation message (stay here for a moment) - sess["quick_build_progress"]["current_stage"] = "Generating Summary" - import time - time.sleep(2) # Pause briefly so user sees this stage - - # Store final result for polling endpoint - sess["last_result"] = res or {} - sess["last_step"] = 5 - - # Small delay to show finishing message - import time - time.sleep(1.5) - - except Exception as e: - # Store error state - logger.exception(f"[Quick Build] Error during stage execution: {e}") - sess["quick_build_progress"]["current_stage"] = f"Error: {str(e)}" - finally: - # Mark build as complete - logger.info("[Quick Build] Background task completed") - sess["quick_build_progress"]["running"] = False - sess["quick_build_progress"]["current_stage"] = "Complete" - - @router.post("/new", response_class=HTMLResponse) async def build_new_submit( request: Request, - background_tasks: BackgroundTasks, name: str = Form("") , commander: str = Form(...), primary_tag: str | None = Form(None), @@ -1970,10 +1662,6 @@ async def build_new_submit( enforcement_mode: str = Form("warn"), allow_illegal: bool = Form(False), fuzzy_matching: bool = Form(True), - # Build count for multi-build - build_count: int = Form(1), - # Quick Build flag - quick_build: str | None = Form(None), ) -> HTMLResponse: """Handle New Deck modal submit and immediately start the build (skip separate review page).""" sid = request.cookies.get("sid") or new_sid() @@ -2045,7 +1733,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(suggested), "tag_slot_html": None, } @@ -2070,7 +1757,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(commander), "tag_slot_html": None, } @@ -2175,7 +1861,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, } @@ -2314,7 +1999,6 @@ async def build_new_submit( "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, } @@ -2502,146 +2186,20 @@ async def build_new_submit( sess["replace_mode"] = True # Centralized staged context creation sess["build_ctx"] = start_ctx_from_session(sess) - - # Validate and normalize build_count + res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) + # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue try: - build_count = max(1, min(10, int(build_count))) + if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" except Exception: - build_count = 1 - - # Check if this is a multi-build request (build_count > 1) - if build_count > 1: - # Multi-Build: Queue parallel builds and return batch progress page - from ..services.multi_build_orchestrator import queue_builds, run_batch_async - - # Create config dict from session for batch builds - batch_config = { - "commander": sess.get("commander"), - "tags": sess.get("tags", []), - "tag_mode": sess.get("tag_mode", "AND"), - "bracket": sess.get("bracket", 3), - "ideals": sess.get("ideals", {}), - "prefer_combos": sess.get("prefer_combos", False), - "combo_target_count": sess.get("combo_target_count"), - "combo_balance": sess.get("combo_balance"), - "multi_copy": sess.get("multi_copy"), - "use_owned_only": sess.get("use_owned_only", False), - "prefer_owned": sess.get("prefer_owned", False), - "swap_mdfc_basics": sess.get("swap_mdfc_basics", False), - "include_cards": sess.get("include_cards", []), - "exclude_cards": sess.get("exclude_cards", []), - "enforcement_mode": sess.get("enforcement_mode", "warn"), - "allow_illegal": sess.get("allow_illegal", False), - "fuzzy_matching": sess.get("fuzzy_matching", True), - "locks": list(sess.get("locks", [])), - } - - # Handle partner mechanics if present - if sess.get("partner_enabled"): - batch_config["partner_enabled"] = True - if sess.get("secondary_commander"): - batch_config["secondary_commander"] = sess["secondary_commander"] - if sess.get("background"): - batch_config["background"] = sess["background"] - if sess.get("partner_mode"): - batch_config["partner_mode"] = sess["partner_mode"] - if sess.get("combined_commander"): - batch_config["combined_commander"] = sess["combined_commander"] - - # Add color identity for synergy builder (needed for basic land allocation) - try: - tmp_builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - - # Handle partner mechanics if present - if sess.get("partner_enabled") and sess.get("secondary_commander"): - from deck_builder.partner_selection import apply_partner_inputs - combined_obj = apply_partner_inputs( - tmp_builder, - primary_name=sess["commander"], - secondary_name=sess.get("secondary_commander"), - background_name=sess.get("background"), - feature_enabled=True, - ) - if combined_obj and hasattr(combined_obj, "color_identity"): - batch_config["colors"] = list(combined_obj.color_identity) - else: - # Single commander - df = tmp_builder.load_commander_data() - row = df[df["name"] == sess["commander"]] - if not row.empty: - # Get colorIdentity from dataframe (it's a string like "RG" or "G") - color_str = row.iloc[0].get("colorIdentity", "") - if color_str: - batch_config["colors"] = list(color_str) # Convert "RG" to ['R', 'G'] - except Exception as e: - import logging - logging.getLogger(__name__).warning(f"[Batch] Failed to load color identity for {sess.get('commander')}: {e}") - pass # Not critical, synergy builder will skip basics if missing - - # Queue the batch - batch_id = queue_builds(batch_config, build_count, sid) - - # Start background task for parallel builds - background_tasks.add_task(run_batch_async, batch_id, sid) - - # Return batch progress template - progress_ctx = { - "request": request, - "batch_id": batch_id, - "build_count": build_count, - "completed": 0, - "current_build": 1, - "status": "Starting builds..." - } - resp = templates.TemplateResponse("build/_batch_progress.html", progress_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # Check if Quick Build was requested (single build only) - is_quick_build = (quick_build or "").strip() == "1" - - if is_quick_build: - # Quick Build: Start background task and return progress template immediately - ctx = sess["build_ctx"] - - # Initialize progress tracking with dynamic counting (total starts at 0) - sess["quick_build_progress"] = { - "running": True, - "total": 0, - "completed": 0, - "current_stage": "Starting build..." - } - - # Start background task to run all stages - background_tasks.add_task(_run_quick_build_stages, sid) - - # Return progress template immediately - progress_ctx = { - "request": request, - "progress_pct": 0, - "completed": 0, - "total": 0, - "current_stage": "Starting build..." - } - resp = templates.TemplateResponse("build/_quick_build_progress.html", progress_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - else: - # Normal build: Run first stage and wait for user input - res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) - # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue - try: - if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - status = "Build complete" if res.get("done") else "Stage complete" - sess["last_step"] = 5 - ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) - resp = templates.TemplateResponse("build/_step5.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp + pass + status = "Build complete" if res.get("done") else "Stage complete" + sess["last_step"] = 5 + ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) + resp = templates.TemplateResponse("build/_step5.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.get("/step1", response_class=HTMLResponse) @@ -2865,7 +2423,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo snap = h.get("snapshot") break if snap is not None: - orch._restore_builder(ctx["builder"], snap) + orch._restore_builder(ctx["builder"], snap) # type: ignore[attr-defined] ctx["idx"] = int(target_i) - 1 ctx["last_visible_idx"] = int(target_i) - 1 except Exception: @@ -2923,11 +2481,6 @@ async def build_step2_get(request: Request) -> HTMLResponse: if is_gc and (sel_br is None or int(sel_br) < 3): sel_br = 3 partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS) - - import logging - logger = logging.getLogger(__name__) - logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}") - context = { "request": request, "commander": {"name": commander}, @@ -2961,22 +2514,7 @@ async def build_step2_get(request: Request) -> HTMLResponse: ) partner_tags = context.pop("partner_theme_tags", None) if partner_tags: - import logging - logger = logging.getLogger(__name__) context["tags"] = partner_tags - # Deduplicate recommended tags: remove any that are already in partner_tags - partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} - original_recommended = context.get("recommended", []) - deduplicated_recommended = [ - tag for tag in original_recommended - if str(tag).strip().casefold() not in partner_tags_lower - ] - logger.info( - f"Step2: partner_tags={len(partner_tags)}, " - f"original_recommended={len(original_recommended)}, " - f"deduplicated_recommended={len(deduplicated_recommended)}" - ) - context["recommended"] = deduplicated_recommended resp = templates.TemplateResponse("build/_step2.html", context) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -3302,57 +2840,6 @@ async def build_step3_get(request: Request) -> HTMLResponse: sess["last_step"] = 3 defaults = orch.ideal_defaults() values = sess.get("ideals") or defaults - - # Check if any skip flags are enabled to show skeleton automation page - skip_flags = { - "skip_lands": "land selection", - "skip_to_misc": "land selection", - "skip_basics": "basic lands", - "skip_staples": "staple lands", - "skip_kindred": "kindred lands", - "skip_fetches": "fetch lands", - "skip_duals": "dual lands", - "skip_triomes": "triome lands", - "skip_all_creatures": "creature selection", - "skip_creature_primary": "primary creatures", - "skip_creature_secondary": "secondary creatures", - "skip_creature_fill": "creature fills", - "skip_all_spells": "spell selection", - "skip_ramp": "ramp spells", - "skip_removal": "removal spells", - "skip_wipes": "board wipes", - "skip_card_advantage": "card advantage spells", - "skip_protection": "protection spells", - "skip_spell_fill": "spell fills", - } - - active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)] - - if active_skips: - # Show skeleton automation page with auto-submit - automation_parts = [] - if any("land" in s for s in active_skips): - automation_parts.append("lands") - if any("creature" in s for s in active_skips): - automation_parts.append("creatures") - if any("spell" in s for s in active_skips): - automation_parts.append("spells") - - automation_message = f"Applying default values for {', '.join(automation_parts)}..." - - resp = templates.TemplateResponse( - "build/_step3_skeleton.html", - { - "request": request, - "defaults": defaults, - "commander": sess.get("commander"), - "automation_message": automation_message, - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # No skips enabled, show normal form resp = templates.TemplateResponse( "build/_step3.html", { @@ -3668,10 +3155,6 @@ async def build_step5_continue(request: Request) -> HTMLResponse: try: res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) status = "Build complete" if res.get("done") else "Stage complete" - # Clear commander from session after build completes - if res.get("done"): - sess.pop("commander", None) - sess.pop("commander_name", None) except Exception as e: sess["last_step"] = 5 err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}") @@ -3869,7 +3352,7 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse: if not ctx or not ctx.get("snapshot"): return await build_step5_get(request) try: - orch._restore_builder(ctx["builder"], ctx["snapshot"]) + orch._restore_builder(ctx["builder"], ctx["snapshot"]) # type: ignore[attr-defined] except Exception: return await build_step5_get(request) # Re-render step 5 with cleared added list @@ -3931,121 +3414,10 @@ async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLRe ctx["synergies"] = synergies ctx["summary_ready"] = True ctx["summary_token"] = active_token - - # Add commander hover context for color identity and theme tags - hover_meta = commander_hover_context( - commander_name=ctx.get("commander"), - deck_tags=sess.get("tags"), - summary=summary_data, - combined=ctx.get("combined_commander"), - ) - ctx.update(hover_meta) - response = templates.TemplateResponse("partials/deck_summary.html", ctx) response.set_cookie("sid", sid, httponly=True, samesite="lax") return response - -@router.get("/quick-progress") -def quick_build_progress(request: Request): - """Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5.""" - import logging - logger = logging.getLogger(__name__) - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - progress = sess.get("quick_build_progress") - logger.info(f"[Progress Poll] sid={sid}, progress={progress is not None}, running={progress.get('running') if progress else None}") - - if not progress or not progress.get("running"): - # Build complete - return Step 5 content that replaces the entire wizard container - res = sess.get("last_result") - if res and res.get("done"): - ctx = step5_ctx_from_result(request, sess, res) - # Return Step 5 which will replace the whole wizard div - response = templates.TemplateResponse("build/_step5.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - # Tell HTMX to target #wizard and swap outerHTML to replace the container - response.headers["HX-Retarget"] = "#wizard" - response.headers["HX-Reswap"] = "outerHTML" - return response - # Fallback if no result yet - return HTMLResponse('Build complete. Please refresh.') - - # Build still running - return progress content partial only (innerHTML swap) - current_stage = progress.get("current_stage", "Processing...") - - ctx = { - "request": request, - "current_stage": current_stage - } - response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - -@router.get("/batch-progress") -def batch_build_progress(request: Request, batch_id: str = Query(...)): - """Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison.""" - import logging - logger = logging.getLogger(__name__) - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - from ..services.build_cache import BuildCache - - batch_status = BuildCache.get_batch_status(sess, batch_id) - logger.info(f"[Batch Progress Poll] batch_id={batch_id}, status={batch_status}") - - if not batch_status: - return HTMLResponse('
Batch not found. Please refresh.
') - - if batch_status["status"] == "completed": - # All builds complete - redirect to comparison page - response = HTMLResponse(f'') - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - # Get config to determine color count for time estimate - config = BuildCache.get_batch_config(sess, batch_id) - commander_name = config.get("commander", "") if config else "" - - # Estimate time based on color count (from testing data) - time_estimate = "1-3 minutes" - if commander_name and config: - # Try to get commander's color identity - try: - from ..services import orchestrator as orch - cmd_data = orch.load_commander(commander_name) - if cmd_data and "colorIdentity" in cmd_data: - color_count = len(cmd_data.get("colorIdentity", [])) - if color_count <= 2: - time_estimate = "1-3 minutes" - elif color_count == 3: - time_estimate = "2-4 minutes" - else: # 4-5 colors - time_estimate = "3-5 minutes" - except Exception: - pass # Default to 1-3 if we can't determine - - # Build still running - return progress content partial only - ctx = { - "request": request, - "batch_id": batch_id, - "build_count": batch_status["count"], - "completed": batch_status["completed"], - "progress_pct": batch_status["progress_pct"], - "status": f"Building deck {batch_status['completed'] + 1} of {batch_status['count']}..." if batch_status['completed'] < batch_status['count'] else "Finalizing...", - "has_errors": batch_status["has_errors"], - "error_count": batch_status["error_count"], - "time_estimate": time_estimate - } - response = templates.TemplateResponse("build/_batch_progress_content.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") @@ -4293,7 +3665,7 @@ async def build_alternatives( try: if rng is not None: return rng.sample(seq, limit) if len(seq) >= limit else list(seq) - import random as _rnd + import random as _rnd # type: ignore return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq) except Exception: return list(seq[:limit]) @@ -4344,7 +3716,7 @@ async def build_alternatives( # Helper: map display names def _display_map_for(lower_pool: set[str]) -> dict[str, str]: try: - return builder_display_map(b, lower_pool) + return builder_display_map(b, lower_pool) # type: ignore[arg-type] except Exception: return {nm: nm for nm in lower_pool} @@ -4522,7 +3894,7 @@ async def build_alternatives( pass # Sort by priority like the builder try: - pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) + pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type] except Exception: pass # Exclusions and ownership (for non-random roles this stays before slicing) @@ -5020,13 +4392,13 @@ async def build_compliance_panel(request: Request) -> HTMLResponse: comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) + comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass if not comp: @@ -5151,11 +4523,11 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: # If missing, export once to establish base if not base_stem: try: - ctx["csv_path"] = b.export_decklist_csv() + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] import os as _os base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] # Also produce a text export for completeness - ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') + ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') # type: ignore[attr-defined] except Exception: base_stem = None # Add lock placeholders into the library before enforcement so user choices are present @@ -5200,7 +4572,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: pass # Run enforcement + re-exports (tops up to 100 internally) try: - rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') + rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') # type: ignore[attr-defined] except Exception as e: err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) @@ -5274,13 +4646,13 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse: comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: if comp: from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) + comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass try: diff --git a/code/web/routes/card_browser.py b/code/web/routes/card_browser.py deleted file mode 100644 index ed7c25f..0000000 --- a/code/web/routes/card_browser.py +++ /dev/null @@ -1,1358 +0,0 @@ -""" -Card browser web UI routes (HTML views with HTMX). - -Provides paginated card browsing with filters, search, and cursor-based pagination. -Complements the existing API routes in cards.py for tag-based card queries. -""" - -from __future__ import annotations - -import logging -from difflib import SequenceMatcher -from typing import TYPE_CHECKING - -import pandas as pd -from fastapi import APIRouter, Request, Query -from fastapi.responses import HTMLResponse -from ..app import templates - -# Import existing services -try: - from code.services.all_cards_loader import AllCardsLoader - from code.deck_builder.builder_utils import parse_theme_tags - from code.settings import ENABLE_CARD_DETAILS -except ImportError: - from services.all_cards_loader import AllCardsLoader - from deck_builder.builder_utils import parse_theme_tags - from settings import ENABLE_CARD_DETAILS - -if TYPE_CHECKING: - from code.web.services.card_similarity import CardSimilarity - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/cards", tags=["card-browser"]) - -# Cached loader instance and theme index -_loader: AllCardsLoader | None = None -_theme_index: dict[str, set[int]] | None = None # theme_lower -> set of card indices -_theme_catalog: list[str] | None = None # cached list of all theme names from catalog -_similarity: "CardSimilarity | None" = None # cached CardSimilarity instance - - -def get_loader() -> AllCardsLoader: - """Get cached AllCardsLoader instance.""" - global _loader - if _loader is None: - _loader = AllCardsLoader() - return _loader - - -def get_similarity() -> "CardSimilarity": - """ - Get cached CardSimilarity instance. - - CardSimilarity initialization is expensive (pre-computes tags for 29k cards, - loads cache with 277k entries). Cache it globally to avoid re-initialization - on every card detail page load. - - Returns: - Cached CardSimilarity instance - """ - global _similarity - if _similarity is None: - from code.web.services.card_similarity import CardSimilarity - loader = get_loader() - df = loader.load() - logger.info("Initializing CardSimilarity singleton (one-time cost)...") - _similarity = CardSimilarity(df) - logger.info("CardSimilarity singleton ready") - return _similarity - - -def get_theme_catalog() -> list[str]: - """ - Get cached list of all theme names from theme_catalog.csv. - - Reads from the catalog CSV which includes all themes from all_cards.parquet - (not just commander themes). Much faster than parsing themes from 26k+ cards. - Used for autocomplete suggestions. - - Returns ~900+ themes (as of latest generation). - """ - global _theme_catalog - if _theme_catalog is None: - import csv - from pathlib import Path - import os - - print("Loading theme catalog...", flush=True) - - # Try multiple possible paths (local dev vs Docker) - possible_paths = [ - Path(__file__).parent.parent.parent / "config" / "themes" / "theme_catalog.csv", # Local dev - Path("/app/config/themes/theme_catalog.csv"), # Docker - Path(os.environ.get("CONFIG_DIR", "/app/config")) / "themes" / "theme_catalog.csv", # Env var - ] - - themes = [] - loaded = False - - for catalog_path in possible_paths: - print(f"Checking path: {catalog_path} (exists: {catalog_path.exists()})", flush=True) - if catalog_path.exists(): - try: - with open(catalog_path, 'r', encoding='utf-8') as f: - # Skip comment lines starting with # - lines = [line for line in f if not line.strip().startswith('#')] - - # Parse CSV from non-comment lines - from io import StringIO - csv_content = StringIO(''.join(lines)) - reader = csv.DictReader(csv_content) - - for row in reader: - if 'theme' in row and row['theme']: - themes.append(row['theme']) - - _theme_catalog = themes - print(f"Loaded {len(themes)} themes from catalog: {catalog_path}", flush=True) - logger.info(f"Loaded {len(themes)} themes from catalog: {catalog_path}") - loaded = True - break - except Exception as e: - print(f"❌ Failed to load from {catalog_path}: {e}", flush=True) # Debug log - logger.warning(f"Failed to load theme catalog from {catalog_path}: {e}") - - if not loaded: - print("⚠️ No catalog found, falling back to parsing cards", flush=True) # Debug log - logger.warning("Failed to load theme catalog from all paths, falling back to parsing cards") - # Fallback: extract from theme index - theme_index = get_theme_index() - _theme_catalog = [theme.title() for theme in theme_index.keys()] - - return _theme_catalog - - -def get_theme_index() -> dict[str, set[int]]: - """ - Get cached theme-to-card-index mapping for fast lookups. - - Returns dict mapping lowercase theme names to sets of card indices. - Built once on first access and reused for all subsequent theme queries. - """ - global _theme_index - if _theme_index is None: - logger.info("Building theme index for fast lookups...") - _theme_index = {} - loader = get_loader() - df = loader.load() - - for idx, row in enumerate(df.itertuples()): - themes = parse_theme_tags(row.themeTags if hasattr(row, 'themeTags') else '') - for theme in themes: - theme_lower = theme.lower() - if theme_lower not in _theme_index: - _theme_index[theme_lower] = set() - _theme_index[theme_lower].add(idx) - - logger.info(f"Theme index built with {len(_theme_index)} unique themes") - - return _theme_index - - -@router.get("/", response_class=HTMLResponse) -async def card_browser_index( - request: Request, - search: str = Query("", description="Card name search query"), - themes: list[str] = Query([], description="Theme tag filters (AND logic)"), - color: str = Query("", description="Color identity filter"), - card_type: str = Query("", description="Card type filter"), - rarity: str = Query("", description="Rarity filter"), - sort: str = Query("name_asc", description="Sort order"), - cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16), - cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16), - power_min: int = Query(None, description="Minimum power filter", ge=0, le=99), - power_max: int = Query(None, description="Maximum power filter", ge=0, le=99), - tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99), - tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99), -): - """ - Main card browser page. - - Displays initial grid of cards with filters and search bar. - Uses HTMX for dynamic updates (pagination, filtering, search). - """ - try: - loader = get_loader() - df = loader.load() - - # Apply filters - filtered_df = df.copy() - - if search: - # Prioritize exact matches first, then word-count matches, then fuzzy - query_lower = search.lower().strip() - query_words = set(query_lower.split()) - - # 1. Check for exact match (case-insensitive) - # For double-faced cards, check both full name and name before " //" - exact_matches = [] - word_count_matches = [] - fuzzy_candidates = [] - fuzzy_indices = [] - - for idx, card_name in enumerate(filtered_df['name']): - card_lower = card_name.lower() - # For double-faced cards, get the front face name - front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower - - # Exact match (full name or front face) - if card_lower == query_lower or front_name == query_lower: - exact_matches.append(idx) - # Word count match (same number of words + high similarity) - elif len(query_lower.split()) == len(front_name.split()) and ( - query_lower in card_lower or any(word in card_lower for word in query_words) - ): - word_count_matches.append((idx, card_name)) - # Fuzzy candidate - elif query_lower in card_lower or any(word in card_lower for word in query_words): - fuzzy_candidates.append(card_name) - fuzzy_indices.append(idx) - - # Build final match list - final_matches = [] - - # If we have exact matches, ONLY return those (don't add fuzzy results) - if exact_matches: - final_matches = exact_matches - else: - # 2. Add word-count matches with fuzzy scoring - if word_count_matches: - scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) - for idx, name in word_count_matches] - scored_wc.sort(key=lambda x: -x[1]) # Sort by score desc - final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3]) - - # 3. Add fuzzy matches - if fuzzy_candidates: - scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name) - for i, name in enumerate(fuzzy_candidates)] - scored_fuzzy.sort(key=lambda x: -x[1]) # Sort by score desc - final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3]) - - # Apply matches - if final_matches: - # Remove duplicates while preserving order - seen = set() - unique_matches = [] - for idx in final_matches: - if idx not in seen: - seen.add(idx) - unique_matches.append(idx) - filtered_df = filtered_df.iloc[unique_matches] - else: - filtered_df = filtered_df.iloc[0:0] - - # Multi-select theme filtering (AND logic: card must have ALL selected themes) - if themes: - theme_index = get_theme_index() - - # For each theme, get matching card indices - all_theme_matches = [] - for theme in themes: - theme_lower = theme.lower().strip() - - # Try exact match first (instant lookup) - if theme_lower in theme_index: - # Direct index lookup - O(1) instead of O(n) - matching_indices = theme_index[theme_lower] - all_theme_matches.append(matching_indices) - else: - # Fuzzy match: check all themes in index for similarity - matching_indices = set() - for indexed_theme, card_indices in theme_index.items(): - if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5: - matching_indices.update(card_indices) - all_theme_matches.append(matching_indices) - - # Apply AND logic: card must be in ALL theme match sets - if all_theme_matches: - # Start with first theme's matches - intersection = all_theme_matches[0] - # Intersect with all other theme matches - for theme_matches in all_theme_matches[1:]: - intersection = intersection & theme_matches - - # Intersect with current filtered_df indices - current_indices = set(filtered_df.index) - valid_indices = intersection & current_indices - if valid_indices: - filtered_df = filtered_df.loc[list(valid_indices)] - else: - filtered_df = filtered_df.iloc[0:0] - - if color: - filtered_df = filtered_df[ - filtered_df['colorIdentity'] == color - ] - - if card_type: - filtered_df = filtered_df[ - filtered_df['type'].str.contains(card_type, case=False, na=False) - ] - - if rarity and 'rarity' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['rarity'].str.lower() == rarity.lower() - ] - - # CMC range filter - if cmc_min is not None and 'manaValue' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['manaValue'] >= cmc_min - ] - - if cmc_max is not None and 'manaValue' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['manaValue'] <= cmc_max - ] - - # Power range filter (only applies to cards with power values) - if power_min is not None and 'power' in filtered_df.columns: - # Filter: either no power (NaN) OR power >= min - filtered_df = filtered_df[ - filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min)) - ] - - if power_max is not None and 'power' in filtered_df.columns: - # Filter: either no power (NaN) OR power <= max - filtered_df = filtered_df[ - filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max)) - ] - - # Toughness range filter (only applies to cards with toughness values) - if tough_min is not None and 'toughness' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min)) - ] - - if tough_max is not None and 'toughness' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max)) - ] - - # Apply sorting - if sort == "name_desc": - # Name Z-A - filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) - filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( - lambda x: x.replace('_', ' ') if x.startswith('_') else x - ) - filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False) - filtered_df = filtered_df.drop('_sort_key', axis=1) - elif sort == "cmc_asc": - # CMC Low-High, then name - filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True]) - elif sort == "cmc_desc": - # CMC High-Low, then name - filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True]) - elif sort == "power_desc": - # Power High-Low (creatures first, then non-creatures) - # Convert power to numeric, NaN becomes -1 for sorting - filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1) - filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True]) - filtered_df = filtered_df.drop('_power_sort', axis=1) - elif sort == "edhrec_asc": - # EDHREC rank (low number = popular) - if 'edhrecRank' in filtered_df.columns: - # NaN goes to end (high value) - filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999) - filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True]) - filtered_df = filtered_df.drop('_edhrec_sort', axis=1) - else: - # Fallback to name sort - filtered_df = filtered_df.sort_values('name') - else: - # Default: Name A-Z (name_asc) - filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) - filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( - lambda x: x.replace('_', ' ') if x.startswith('_') else x - ) - filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower()) - filtered_df = filtered_df.drop('_sort_key', axis=1) - - total_cards = len(filtered_df) - - # Get first page (20 cards) - per_page = 20 - cards_page = filtered_df.head(per_page) - - # Convert to list of dicts - cards_list = cards_page.to_dict('records') - - # Parse theme tags and color identity for each card - for card in cards_list: - card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) - # Parse colorIdentity which can be: - # - "Colorless" -> [] (but mark as colorless) - # - "W" -> ['W'] - # - "B, R, U" -> ['B', 'R', 'U'] - # - "['W', 'U']" -> ['W', 'U'] - # - empty/None -> [] - raw_color = card.get('colorIdentity', '') - is_colorless = False - if raw_color and isinstance(raw_color, str): - if raw_color.lower() == 'colorless': - card['colorIdentity'] = [] - is_colorless = True - elif raw_color.startswith('['): - # Parse list-like strings e.g. "['W', 'U']" - card['colorIdentity'] = parse_theme_tags(raw_color) - elif ', ' in raw_color: - # Parse comma-separated e.g. "B, R, U" - card['colorIdentity'] = [c.strip() for c in raw_color.split(',')] - else: - # Single color e.g. "W" - card['colorIdentity'] = [raw_color.strip()] - elif not raw_color: - card['colorIdentity'] = [] - card['is_colorless'] = is_colorless - # TODO: Add owned card checking when integrated - card['is_owned'] = False - - # Get unique values for filters - # Build structured color identity list with proper names - unique_color_ids = df['colorIdentity'].dropna().unique().tolist() - - # Define color identity groups with proper names - color_groups = { - 'Colorless': ['Colorless'], - 'Mono-Color': ['W', 'U', 'B', 'R', 'G'], - 'Two-Color': [ - ('W, U', 'Azorius'), - ('U, B', 'Dimir'), - ('B, R', 'Rakdos'), - ('R, G', 'Gruul'), - ('G, W', 'Selesnya'), - ('W, B', 'Orzhov'), - ('U, R', 'Izzet'), - ('B, G', 'Golgari'), - ('R, W', 'Boros'), - ('G, U', 'Simic'), - ], - 'Three-Color': [ - ('B, G, U', 'Sultai'), - ('G, U, W', 'Bant'), - ('B, U, W', 'Esper'), - ('B, R, U', 'Grixis'), - ('B, G, R', 'Jund'), - ('G, R, W', 'Naya'), - ('B, G, W', 'Abzan'), - ('R, U, W', 'Jeskai'), - ('B, R, W', 'Mardu'), - ('G, R, U', 'Temur'), - ], - 'Four-Color': [ - ('B, G, R, U', 'Non-White'), - ('B, G, R, W', 'Non-Blue'), - ('B, G, U, W', 'Non-Red'), - ('B, R, U, W', 'Non-Green'), - ('G, R, U, W', 'Non-Black'), - ], - 'Five-Color': ['B, G, R, U, W'], - } - - # Flatten and filter to only include combinations present in data - all_colors = [] - for group_name, entries in color_groups.items(): - group_colors = [] - for entry in entries: - if isinstance(entry, tuple): - color_id, display_name = entry - if color_id in unique_color_ids: - group_colors.append((color_id, display_name)) - else: - color_id = entry - if color_id in unique_color_ids: - group_colors.append((color_id, color_id)) - if group_colors: - all_colors.append((group_name, group_colors)) - - all_types = sorted( - set( - df['type'].dropna().str.extract(r'([A-Za-z]+)', expand=False).dropna().unique().tolist() - ) - )[:20] # Limit to top 20 types - - all_rarities = [] - if 'rarity' in df.columns: - all_rarities = sorted(df['rarity'].dropna().unique().tolist()) - - # Calculate pagination info - per_page = 20 - total_filtered = len(filtered_df) - total_pages = (total_filtered + per_page - 1) // per_page # Ceiling division - current_page = 1 # Always page 1 on initial load (cursor-based makes exact page tricky) - - # Determine if there's a next page - has_next = total_cards > per_page - last_card_name = cards_list[-1]['name'] if cards_list else "" - - return templates.TemplateResponse( - "browse/cards/index.html", - { - "request": request, - "cards": cards_list, - "total_cards": len(df), # Original unfiltered count - "filtered_count": total_filtered, # After filters applied - "has_next": has_next, - "last_card": last_card_name, - "search": search, - "themes": themes, - "color": color, - "card_type": card_type, - "rarity": rarity, - "sort": sort, - "cmc_min": cmc_min, - "cmc_max": cmc_max, - "power_min": power_min, - "power_max": power_max, - "tough_min": tough_min, - "tough_max": tough_max, - "all_colors": all_colors, - "all_types": all_types, - "all_rarities": all_rarities, - "per_page": per_page, - "current_page": current_page, - "total_pages": total_pages, - "enable_card_details": ENABLE_CARD_DETAILS, - }, - ) - - except FileNotFoundError as e: - logger.error(f"Card data not found: {e}") - return templates.TemplateResponse( - "browse/cards/index.html", - { - "request": request, - "cards": [], - "total_cards": 0, - "has_next": False, - "last_card": "", - "search": "", - "color": "", - "card_type": "", - "rarity": "", - "all_colors": [], - "all_types": [], - "all_rarities": [], - "per_page": 20, - "error": "Card data not available. Please run setup to generate all_cards.parquet.", - "enable_card_details": ENABLE_CARD_DETAILS, - }, - ) - except Exception as e: - logger.error(f"Error loading card browser: {e}", exc_info=True) - return templates.TemplateResponse( - "browse/cards/index.html", - { - "request": request, - "cards": [], - "total_cards": 0, - "has_next": False, - "last_card": "", - "search": "", - "color": "", - "card_type": "", - "rarity": "", - "all_colors": [], - "all_types": [], - "all_rarities": [], - "per_page": 20, - "error": f"Error loading cards: {str(e)}", - "enable_card_details": ENABLE_CARD_DETAILS, - }, - ) - - -@router.get("/grid", response_class=HTMLResponse) -async def card_browser_grid( - request: Request, - cursor: str = Query("", description="Last card name from previous page"), - search: str = Query("", description="Card name search query"), - themes: list[str] = Query([], description="Theme tag filters (AND logic)"), - color: str = Query("", description="Color identity filter"), - card_type: str = Query("", description="Card type filter"), - rarity: str = Query("", description="Rarity filter"), - sort: str = Query("name_asc", description="Sort order"), - cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16), - cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16), - power_min: int = Query(None, description="Minimum power filter", ge=0, le=99), - power_max: int = Query(None, description="Maximum power filter", ge=0, le=99), - tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99), - tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99), -): - """ - HTMX endpoint for paginated card grid. - - Returns only the grid partial HTML for seamless pagination. - Uses cursor-based pagination (last_card_name) for performance. - """ - try: - loader = get_loader() - df = loader.load() - - # Apply filters - filtered_df = df.copy() - - if search: - # Prioritize exact matches first, then word-count matches, then fuzzy - query_lower = search.lower().strip() - query_words = set(query_lower.split()) - - # 1. Check for exact match (case-insensitive) - # For double-faced cards, check both full name and name before " //" - exact_matches = [] - word_count_matches = [] - fuzzy_candidates = [] - fuzzy_indices = [] - - for idx, card_name in enumerate(filtered_df['name']): - card_lower = card_name.lower() - # For double-faced cards, get the front face name - front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower - - # Exact match (full name or front face) - if card_lower == query_lower or front_name == query_lower: - exact_matches.append(idx) - # Word count match (same number of words + high similarity) - elif len(query_lower.split()) == len(front_name.split()) and ( - query_lower in card_lower or any(word in card_lower for word in query_words) - ): - word_count_matches.append((idx, card_name)) - # Fuzzy candidate - elif query_lower in card_lower or any(word in card_lower for word in query_words): - fuzzy_candidates.append(card_name) - fuzzy_indices.append(idx) - - # Build final match list - final_matches = [] - - # If we have exact matches, ONLY return those (don't add fuzzy results) - if exact_matches: - final_matches = exact_matches - else: - # 2. Add word-count matches with fuzzy scoring - if word_count_matches: - scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) - for idx, name in word_count_matches] - scored_wc.sort(key=lambda x: -x[1]) # Sort by score desc - final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3]) - - # 3. Add fuzzy matches - if fuzzy_candidates: - scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name) - for i, name in enumerate(fuzzy_candidates)] - scored_fuzzy.sort(key=lambda x: -x[1]) # Sort by score desc - final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3]) - - # Apply matches - if final_matches: - # Remove duplicates while preserving order - seen = set() - unique_matches = [] - for idx in final_matches: - if idx not in seen: - seen.add(idx) - unique_matches.append(idx) - filtered_df = filtered_df.iloc[unique_matches] - else: - filtered_df = filtered_df.iloc[0:0] - - # Multi-select theme filtering (AND logic: card must have ALL selected themes) - if themes: - theme_index = get_theme_index() - - # For each theme, get matching card indices - all_theme_matches = [] - for theme in themes: - theme_lower = theme.lower().strip() - - # Try exact match first (instant lookup) - if theme_lower in theme_index: - # Direct index lookup - O(1) instead of O(n) - matching_indices = theme_index[theme_lower] - all_theme_matches.append(matching_indices) - else: - # Fuzzy match: check all themes in index for similarity - matching_indices = set() - for indexed_theme, card_indices in theme_index.items(): - if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5: - matching_indices.update(card_indices) - all_theme_matches.append(matching_indices) - - # Apply AND logic: card must be in ALL theme match sets - if all_theme_matches: - # Start with first theme's matches - intersection = all_theme_matches[0] - # Intersect with all other theme matches - for theme_matches in all_theme_matches[1:]: - intersection = intersection & theme_matches - - # Intersect with current filtered_df indices - current_indices = set(filtered_df.index) - valid_indices = intersection & current_indices - if valid_indices: - filtered_df = filtered_df.loc[list(valid_indices)] - else: - filtered_df = filtered_df.iloc[0:0] - - if color: - filtered_df = filtered_df[ - filtered_df['colorIdentity'] == color - ] - - if card_type: - filtered_df = filtered_df[ - filtered_df['type'].str.contains(card_type, case=False, na=False) - ] - - if rarity and 'rarity' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['rarity'].str.lower() == rarity.lower() - ] - - # CMC range filter (grid endpoint) - if cmc_min is not None and 'manaValue' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['manaValue'] >= cmc_min - ] - - if cmc_max is not None and 'manaValue' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['manaValue'] <= cmc_max - ] - - # Power range filter (grid endpoint) - if power_min is not None and 'power' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min)) - ] - - if power_max is not None and 'power' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max)) - ] - - # Toughness range filter (grid endpoint) - if tough_min is not None and 'toughness' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min)) - ] - - if tough_max is not None and 'toughness' in filtered_df.columns: - filtered_df = filtered_df[ - filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max)) - ] - - # Apply sorting (same logic as main endpoint) - if sort == "name_desc": - filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) - filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( - lambda x: x.replace('_', ' ') if x.startswith('_') else x - ) - filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False) - filtered_df = filtered_df.drop('_sort_key', axis=1) - elif sort == "cmc_asc": - filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True]) - elif sort == "cmc_desc": - filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True]) - elif sort == "power_desc": - filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1) - filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True]) - filtered_df = filtered_df.drop('_power_sort', axis=1) - elif sort == "edhrec_asc": - if 'edhrecRank' in filtered_df.columns: - filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999) - filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True]) - filtered_df = filtered_df.drop('_edhrec_sort', axis=1) - else: - filtered_df = filtered_df.sort_values('name') - else: - # Default: Name A-Z - filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False) - filtered_df['_sort_key'] = filtered_df['_sort_key'].apply( - lambda x: x.replace('_', ' ') if x.startswith('_') else x - ) - filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower()) - filtered_df = filtered_df.drop('_sort_key', axis=1) - - # Cursor-based pagination - # Cursor is the card name - skip all cards until we find it, then take next batch - if cursor: - try: - # Find the position of the cursor card in the sorted dataframe - cursor_position = filtered_df[filtered_df['name'] == cursor].index - if len(cursor_position) > 0: - # Get the iloc position (row number, not index label) - cursor_iloc = filtered_df.index.get_loc(cursor_position[0]) - # Skip past the cursor card (take everything after it) - filtered_df = filtered_df.iloc[cursor_iloc + 1:] - except (KeyError, IndexError): - # Cursor card not found - might have been filtered out, just proceed - pass - - per_page = 20 - cards_page = filtered_df.head(per_page) - cards_list = cards_page.to_dict('records') - - # Parse theme tags and color identity - for card in cards_list: - card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) - # Parse colorIdentity which can be: - # - "Colorless" -> [] (but mark as colorless) - # - "W" -> ['W'] - # - "B, R, U" -> ['B', 'R', 'U'] - # - "['W', 'U']" -> ['W', 'U'] - # - empty/None -> [] - raw_color = card.get('colorIdentity', '') - is_colorless = False - if raw_color and isinstance(raw_color, str): - if raw_color.lower() == 'colorless': - card['colorIdentity'] = [] - is_colorless = True - elif raw_color.startswith('['): - # Parse list-like strings e.g. "['W', 'U']" - card['colorIdentity'] = parse_theme_tags(raw_color) - elif ', ' in raw_color: - # Parse comma-separated e.g. "B, R, U" - card['colorIdentity'] = [c.strip() for c in raw_color.split(',')] - else: - # Single color e.g. "W" - card['colorIdentity'] = [raw_color.strip()] - elif not raw_color: - card['colorIdentity'] = [] - card['is_colorless'] = is_colorless - card['is_owned'] = False # TODO: Add owned card checking - - has_next = len(filtered_df) > per_page - last_card_name = cards_list[-1]['name'] if cards_list else "" - - return templates.TemplateResponse( - "browse/cards/_card_grid.html", - { - "request": request, - "cards": cards_list, - "has_next": has_next, - "last_card": last_card_name, - "search": search, - "themes": themes, - "color": color, - "card_type": card_type, - "rarity": rarity, - "sort": sort, - "cmc_min": cmc_min, - "cmc_max": cmc_max, - "power_min": power_min, - "power_max": power_max, - "tough_min": tough_min, - "tough_max": tough_max, - "enable_card_details": ENABLE_CARD_DETAILS, - }, - ) - - except Exception as e: - logger.error(f"Error loading card grid: {e}", exc_info=True) - return HTMLResponse( - f'
Error loading cards: {str(e)}
', - status_code=500, - ) - - -def _fuzzy_theme_match_score(query: str, theme: str) -> float: - """ - Calculate fuzzy match score between query and theme name. - Handles typos in the middle of words. - - Returns score from 0.0 to 1.0, higher is better match. - """ - query_lower = query.lower() - theme_lower = theme.lower() - - # Use sequence matcher for proper fuzzy matching (handles typos) - base_score = SequenceMatcher(None, query_lower, theme_lower).ratio() - - # Bonus for substring match - substring_bonus = 0.0 - if theme_lower.startswith(query_lower): - substring_bonus = 0.3 # Strong bonus for prefix - elif query_lower in theme_lower: - substring_bonus = 0.2 # Moderate bonus for substring - - # Word overlap bonus (for multi-word themes) - query_words = set(query_lower.split()) - theme_words = set(theme_lower.split()) - word_overlap = 0.0 - if query_words and theme_words: - overlap_ratio = len(query_words & theme_words) / len(query_words) - word_overlap = overlap_ratio * 0.2 - - # Combine scores - return min(1.0, base_score + substring_bonus + word_overlap) - - -@router.get("/search", response_class=HTMLResponse) -async def card_browser_search( - request: Request, - q: str = Query("", description="Search query"), -): - """ - Live search autocomplete endpoint. - - Returns matching card names for autocomplete suggestions. - """ - try: - if not q or len(q) < 2: - return HTMLResponse("") - - loader = get_loader() - df = loader.load() - - # Search by card name (case-insensitive) - matches = df[df['name'].str.contains(q, case=False, na=False)] - matches = matches.sort_values('name').head(10) - - card_names = matches['name'].tolist() - - # Return as simple HTML list - html = "" - - return HTMLResponse(html) - - except Exception as e: - logger.error(f"Error in card search: {e}", exc_info=True) - return HTMLResponse("") - - -def _normalize_search_text(value: str | None) -> str: - """Normalize search text for fuzzy matching (lowercase, alphanumeric only).""" - if not value: - return "" - # Keep letters, numbers, spaces; convert to lowercase - import re - tokens = re.findall(r"[a-z0-9]+", value.lower()) - return " ".join(tokens) if tokens else "" - - -def _fuzzy_card_name_score(query: str, card_name: str) -> float: - """ - Calculate fuzzy match score between query and card name. - - Uses multiple scoring methods similar to commanders.py: - - Base sequence matching - - Partial ratio (substring matching) - - Token matching - - Word count matching bonus - - Substring bonuses - - Returns score from 0.0 to 1.0, higher is better match. - """ - normalized_query = _normalize_search_text(query) - normalized_card = _normalize_search_text(card_name) - - if not normalized_query or not normalized_card: - return 0.0 - - # Base sequence matching - base_score = SequenceMatcher(None, normalized_query, normalized_card).ratio() - - # Partial ratio - best matching substring - query_len = len(normalized_query) - if query_len <= len(normalized_card): - best_partial = 0.0 - for i in range(len(normalized_card) - query_len + 1): - substr = normalized_card[i:i + query_len] - ratio = SequenceMatcher(None, normalized_query, substr).ratio() - if ratio > best_partial: - best_partial = ratio - else: - best_partial = base_score - - # Token matching - query_tokens = normalized_query.split() - card_tokens = normalized_card.split() - - if query_tokens and card_tokens: - # Average token score - token_scores = [] - for q_token in query_tokens: - best_token_match = max( - (SequenceMatcher(None, q_token, c_token).ratio() for c_token in card_tokens), - default=0.0 - ) - token_scores.append(best_token_match) - token_avg = sum(token_scores) / len(token_scores) if token_scores else 0.0 - - # Word count bonus: prioritize same number of words - # "peer parker" (2 words) should match "peter parker" (2 words) over "peter parker amazing" (3 words) - word_count_bonus = 0.0 - if len(query_tokens) == len(card_tokens): - word_count_bonus = 0.15 # Significant bonus for same word count - else: - token_avg = 0.0 - word_count_bonus = 0.0 - - # Substring bonuses - substring_bonus = 0.0 - if normalized_card.startswith(normalized_query): - substring_bonus = 1.0 - elif normalized_query in normalized_card: - substring_bonus = 0.9 - elif query_tokens and all(token in card_tokens for token in query_tokens): - substring_bonus = 0.85 - - # Combine scores with word count bonus - base_result = max(base_score, best_partial, token_avg, substring_bonus) - return min(1.0, base_result + word_count_bonus) # Cap at 1.0 - - - -@router.get("/search-autocomplete", response_class=HTMLResponse) -async def card_search_autocomplete( - request: Request, - q: str = Query(..., min_length=2, description="Card name search query"), - limit: int = Query(10, ge=1, le=50), -) -> HTMLResponse: - """ - HTMX endpoint for card name autocomplete with fuzzy matching. - - Similar to commanders theme autocomplete, returns HTML suggestions - with keyboard navigation support. - """ - try: - loader = get_loader() - df = loader.load() - - # Quick filter: prioritize exact match, then word count match, then fuzzy - query_lower = q.lower() - query_words = set(query_lower.split()) - query_word_count = len(query_lower.split()) - - # Fast categorization - exact_matches = [] - word_count_candidates = [] - fuzzy_candidates = [] - - for card_name in df['name'].unique(): - card_lower = card_name.lower() - - # Exact match - if card_lower == query_lower: - exact_matches.append(card_name) - # Same word count with substring/word overlap - elif len(card_lower.split()) == query_word_count and ( - query_lower in card_lower or any(word in card_lower for word in query_words) - ): - word_count_candidates.append(card_name) - # Fuzzy candidate - elif query_lower in card_lower or any(word in card_lower for word in query_words): - fuzzy_candidates.append(card_name) - - # Build final scored list - scored_cards: list[tuple[float, str, int]] = [] # (score, name, priority) - - # 1. Exact matches (priority 0 = highest) - for card_name in exact_matches[:limit]: # Take top N exact matches - scored_cards.append((1.0, card_name, 0)) - - # 2. Word count matches (priority 1) - if len(scored_cards) < limit and word_count_candidates: - # Limit word count candidates before fuzzy scoring - if len(word_count_candidates) > 200: - word_count_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower())) - word_count_candidates = word_count_candidates[:200] - - for card_name in word_count_candidates: - score = _fuzzy_card_name_score(q, card_name) - if score >= 0.3: - scored_cards.append((score, card_name, 1)) - - # 3. Fuzzy matches (priority 2) - if len(scored_cards) < limit and fuzzy_candidates: - # Limit fuzzy candidates before scoring - if len(fuzzy_candidates) > 200: - fuzzy_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower())) - fuzzy_candidates = fuzzy_candidates[:200] - - for card_name in fuzzy_candidates: - score = _fuzzy_card_name_score(q, card_name) - if score >= 0.3: - scored_cards.append((score, card_name, 2)) - - # Sort by priority first, then score desc, then name asc - scored_cards.sort(key=lambda x: (x[2], -x[0], x[1].lower())) - - # Take top matches - top_matches = scored_cards[:limit] - - # Generate HTML suggestions with ARIA attributes - html_parts = [] - for score, card_name, priority in top_matches: - # Escape HTML special characters - safe_name = card_name.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') - html_parts.append( - f'
' - f'{safe_name}
' - ) - - html = "\n".join(html_parts) if html_parts else '
No matching cards
' - - return HTMLResponse(content=html) - - except Exception as e: - logger.error(f"Error in card autocomplete: {e}", exc_info=True) - return HTMLResponse(content=f'
Error: {str(e)}
') - - -@router.get("/theme-autocomplete", response_class=HTMLResponse) -async def card_theme_autocomplete( - request: Request, - q: str = Query(..., min_length=2, description="Theme search query"), - limit: int = Query(10, ge=1, le=20), -) -> HTMLResponse: - """ - HTMX endpoint for theme tag autocomplete with fuzzy matching. - - Uses theme catalog for instant lookups (no card parsing required). - """ - try: - # Use cached theme catalog (loaded from CSV, not parsed from cards) - all_themes = get_theme_catalog() - - # Fuzzy match themes using helper function - scored_themes: list[tuple[float, str]] = [] - - # Only check against theme names from catalog (~575 themes) - for theme in all_themes: - score = _fuzzy_theme_match_score(q, theme) - # Only include if score is reasonable (0.5+ = 50%+ match) - if score >= 0.5: - scored_themes.append((score, theme)) - - # Sort by score (desc), then alphabetically - scored_themes.sort(key=lambda x: (-x[0], x[1].lower())) - top_matches = scored_themes[:limit] - - # Generate HTML suggestions - html_parts = [] - for score, theme in top_matches: - safe_theme = theme.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') - html_parts.append( - f'
' - f'{safe_theme}
' - ) - - html = "\n".join(html_parts) if html_parts else '
No matching themes
' - - return HTMLResponse(content=html) - - except Exception as e: - logger.error(f"Error in theme autocomplete: {e}", exc_info=True) - return HTMLResponse(content=f'
Error: {str(e)}
') - - -@router.get("/{card_name:path}", response_class=HTMLResponse) -async def card_detail(request: Request, card_name: str): - """ - Display detailed information about a single card with similar cards. - - Args: - card_name: URL-encoded card name (using :path to capture names with / like DFCs) - - Returns: - HTML page with card details and similar cards section - """ - try: - from urllib.parse import unquote - - # Decode URL-encoded card name - card_name = unquote(card_name) - - # Load card data - loader = get_loader() - df = loader.load() - - # Find the card - card_row = df[df['name'] == card_name] - - if card_row.empty: - # Card not found - return 404 page - return templates.TemplateResponse( - "error.html", - { - "request": request, - "error_code": 404, - "error_message": f"Card not found: {card_name}", - "back_link": "/cards", - "back_text": "Back to Card Browser" - }, - status_code=404 - ) - - # Get card data as dict - card = card_row.iloc[0].to_dict() - - # Parse theme tags using helper function - card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', '')) - - # Calculate similar cards using cached singleton - similarity = get_similarity() - similar_cards = similarity.find_similar( - card_name, - threshold=0.8, # Start at 80% - limit=5, # Show 3-5 cards - min_results=3, # Target minimum 3 - adaptive=True # Enable adaptive thresholds (80% → 60%) - ) - - # Enrich similar cards with full data - for similar in similar_cards: - similar_row = df[df['name'] == similar['name']] - if not similar_row.empty: - similar_data = similar_row.iloc[0].to_dict() - - # Parse theme tags before updating (so we have the list, not string) - theme_tags_parsed = parse_theme_tags(similar_data.get('themeTags', '')) - - similar.update(similar_data) - - # Set the parsed tags list (not the string version from df) - similar['themeTags'] = theme_tags_parsed - - # Log card detail page access - if similar_cards: - threshold_pct = similar_cards[0].get('threshold_used', 0) * 100 - logger.info( - f"Card detail page for '{card_name}': found {len(similar_cards)} similar cards " - f"(threshold: {threshold_pct:.0f}%)" - ) - else: - logger.info(f"Card detail page for '{card_name}': no similar cards found") - - # Get main card's theme tags for overlap highlighting - main_card_tags = card.get('themeTags_parsed', []) - - return templates.TemplateResponse( - "browse/cards/detail.html", - { - "request": request, - "card": card, - "similar_cards": similar_cards, - "main_card_tags": main_card_tags, - } - ) - - except Exception as e: - logger.error(f"Error loading card detail for '{card_name}': {e}", exc_info=True) - return templates.TemplateResponse( - "error.html", - { - "request": request, - "error_code": 500, - "error_message": f"Error loading card details: {str(e)}", - "back_link": "/cards", - "back_text": "Back to Card Browser" - }, - status_code=500 - ) - - -@router.get("/{card_name:path}/similar") -async def get_similar_cards_partial(request: Request, card_name: str): - """ - HTMX endpoint: Returns just the similar cards section for a given card. - Used for refreshing similar cards without reloading the entire page. - - Note: Uses :path to capture DFC names with // in them - """ - try: - from urllib.parse import unquote - - # Decode URL-encoded card name - card_name = unquote(card_name) - - # Load cards data - loader = get_loader() - df = loader.load() - - # Get main card for theme tags - card_row = df[df['name'] == card_name] - if card_row.empty: - return templates.TemplateResponse( - "browse/cards/_similar_cards.html", - { - "request": request, - "similar_cards": [], - "main_card_tags": [], - } - ) - - card = card_row.iloc[0].to_dict() - main_card_tags = parse_theme_tags(card.get('themeTags', '')) - - # Calculate similar cards - similarity = get_similarity() - similar_cards = similarity.find_similar( - card_name, - threshold=0.8, - limit=5, - min_results=3, - adaptive=True - ) - - # Enrich similar cards with full data - for similar in similar_cards: - similar_row = df[df['name'] == similar['name']] - if not similar_row.empty: - similar_data = similar_row.iloc[0].to_dict() - theme_tags_parsed = parse_theme_tags(similar_data.get('themeTags', '')) - similar.update(similar_data) - similar['themeTags'] = theme_tags_parsed - - logger.info(f"Similar cards refresh for '{card_name}': {len(similar_cards)} cards") - - return templates.TemplateResponse( - "browse/cards/_similar_cards.html", - { - "request": request, - "card": card, - "similar_cards": similar_cards, - "main_card_tags": main_card_tags, - } - ) - - except Exception as e: - logger.error(f"Error loading similar cards for '{card_name}': {e}", exc_info=True) - # Try to get card data for error case too - try: - loader = get_loader() - df = loader.load() - card_row = df[df['name'] == card_name] - card = card_row.iloc[0].to_dict() if not card_row.empty else {"name": card_name} - except Exception: - card = {"name": card_name} - - return templates.TemplateResponse( - "browse/cards/_similar_cards.html", - { - "request": request, - "card": card, - "similar_cards": [], - "main_card_tags": [], - } - ) - diff --git a/code/web/routes/cards.py b/code/web/routes/cards.py deleted file mode 100644 index 28f8a7b..0000000 --- a/code/web/routes/cards.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Card browsing and tag search API endpoints.""" -from __future__ import annotations - -from typing import Optional -from fastapi import APIRouter, Query -from fastapi.responses import JSONResponse - -# Import tag index from M3 -try: - from code.tagging.tag_index import get_tag_index -except ImportError: - from tagging.tag_index import get_tag_index - -# Import all cards loader -try: - from code.services.all_cards_loader import AllCardsLoader -except ImportError: - from services.all_cards_loader import AllCardsLoader - -router = APIRouter(prefix="/api/cards", tags=["cards"]) - -# Cache for all_cards loader -_all_cards_loader: Optional[AllCardsLoader] = None - - -def _get_all_cards_loader() -> AllCardsLoader: - """Get cached AllCardsLoader instance.""" - global _all_cards_loader - if _all_cards_loader is None: - _all_cards_loader = AllCardsLoader() - return _all_cards_loader - - -@router.get("/by-tags") -async def search_by_tags( - tags: str = Query(..., description="Comma-separated list of theme tags"), - logic: str = Query("AND", description="Search logic: AND (intersection) or OR (union)"), - limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"), -) -> JSONResponse: - """Search for cards by theme tags. - - Examples: - /api/cards/by-tags?tags=tokens&logic=AND - /api/cards/by-tags?tags=tokens,sacrifice&logic=AND - /api/cards/by-tags?tags=lifegain,lifelink&logic=OR - - Args: - tags: Comma-separated theme tags to search for - logic: "AND" for cards with all tags, "OR" for cards with any tag - limit: Maximum results to return - - Returns: - JSON with matching cards and metadata - """ - try: - # Parse tags - tag_list = [t.strip() for t in tags.split(",") if t.strip()] - if not tag_list: - return JSONResponse( - status_code=400, - content={"error": "No valid tags provided"} - ) - - # Get tag index and find matching cards - tag_index = get_tag_index() - - if logic.upper() == "AND": - card_names = tag_index.get_cards_with_all_tags(tag_list) - elif logic.upper() == "OR": - card_names = tag_index.get_cards_with_any_tags(tag_list) - else: - return JSONResponse( - status_code=400, - content={"error": f"Invalid logic: {logic}. Use AND or OR."} - ) - - # Load full card data - all_cards = _get_all_cards_loader().load() - matching_cards = all_cards[all_cards["name"].isin(card_names)] - - # Limit results - matching_cards = matching_cards.head(limit) - - # Convert to dict - results = matching_cards.to_dict("records") - - return JSONResponse(content={ - "tags": tag_list, - "logic": logic.upper(), - "total_matches": len(card_names), - "returned": len(results), - "limit": limit, - "cards": results - }) - - except Exception as e: - return JSONResponse( - status_code=500, - content={"error": f"Search failed: {str(e)}"} - ) - - -@router.get("/tags/search") -async def search_tags( - q: str = Query(..., min_length=2, description="Tag prefix to search for"), - limit: int = Query(10, ge=1, le=50, description="Maximum number of suggestions"), -) -> JSONResponse: - """Autocomplete search for theme tags. - - Examples: - /api/cards/tags/search?q=life - /api/cards/tags/search?q=token&limit=5 - - Args: - q: Tag prefix (minimum 2 characters) - limit: Maximum suggestions to return - - Returns: - JSON with matching tags sorted by popularity - """ - try: - tag_index = get_tag_index() - - # Get all tags with counts - get_popular_tags returns all tags when given a high limit - all_tags_with_counts = tag_index.get_popular_tags(limit=10000) - - # Filter by prefix (case-insensitive) - prefix_lower = q.lower() - matches = [ - (tag, count) - for tag, count in all_tags_with_counts - if tag.lower().startswith(prefix_lower) - ] - - # Already sorted by popularity from get_popular_tags - # Limit results - matches = matches[:limit] - - return JSONResponse(content={ - "query": q, - "matches": [ - {"tag": tag, "card_count": count} - for tag, count in matches - ] - }) - - except Exception as e: - return JSONResponse( - status_code=500, - content={"error": f"Tag search failed: {str(e)}"} - ) - - -@router.get("/tags/popular") -async def get_popular_tags( - limit: int = Query(50, ge=1, le=200, description="Number of popular tags to return"), -) -> JSONResponse: - """Get the most popular theme tags by card count. - - Examples: - /api/cards/tags/popular - /api/cards/tags/popular?limit=20 - - Args: - limit: Maximum tags to return - - Returns: - JSON with popular tags sorted by card count - """ - try: - tag_index = get_tag_index() - popular = tag_index.get_popular_tags(limit=limit) - - return JSONResponse(content={ - "count": len(popular), - "tags": [ - {"tag": tag, "card_count": count} - for tag, count in popular - ] - }) - - except Exception as e: - return JSONResponse( - status_code=500, - content={"error": f"Failed to get popular tags: {str(e)}"} - ) diff --git a/code/web/routes/commanders.py b/code/web/routes/commanders.py index 7b0fad0..88053b5 100644 --- a/code/web/routes/commanders.py +++ b/code/web/routes/commanders.py @@ -526,52 +526,6 @@ def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, Commander return info -@router.get("/theme-autocomplete", response_class=HTMLResponse) -async def theme_autocomplete( - request: Request, - theme: str = Query(..., min_length=2, description="Theme prefix to search for"), - limit: int = Query(20, ge=1, le=50), -) -> HTMLResponse: - """HTMX endpoint for theme tag autocomplete.""" - try: - # Import tag_index - try: - from code.tagging.tag_index import get_tag_index - except ImportError: - from tagging.tag_index import get_tag_index - - tag_index = get_tag_index() - - # Get all tags with counts - get_popular_tags returns all tags when given a high limit - all_tags_with_counts = tag_index.get_popular_tags(limit=10000) - - # Filter by prefix (case-insensitive) - prefix_lower = theme.lower() - matches = [ - (tag, count) - for tag, count in all_tags_with_counts - if tag.lower().startswith(prefix_lower) - ] - - # Already sorted by popularity from get_popular_tags - matches = matches[:limit] - - # Generate HTML suggestions with ARIA attributes - html_parts = [] - for tag, count in matches: - html_parts.append( - f'
' - f'{tag} ({count})
' - ) - - html = "\n".join(html_parts) if html_parts else '
No matching themes
' - - return HTMLResponse(content=html) - - except Exception as e: - return HTMLResponse(content=f'
Error: {str(e)}
') - - @router.get("/", response_class=HTMLResponse) async def commanders_index( request: Request, diff --git a/code/web/routes/compare.py b/code/web/routes/compare.py deleted file mode 100644 index 6dea835..0000000 --- a/code/web/routes/compare.py +++ /dev/null @@ -1,730 +0,0 @@ -""" -Comparison Routes - Side-by-side deck comparison for batch builds. -""" - -from __future__ import annotations -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse -from typing import Any, Dict, List -from ..app import templates -from ..services.build_cache import BuildCache -from ..services.tasks import get_session, new_sid -from ..services.synergy_builder import analyze_and_build_synergy_deck -from code.logging_util import get_logger -import time - -logger = get_logger(__name__) -router = APIRouter() - - -def _is_guaranteed_card(card_name: str) -> bool: - """ - Check if a card is guaranteed/staple (should be filtered from interesting variance). - - Filters: - - Basic lands (Plains, Island, Swamp, Mountain, Forest, Wastes, Snow-Covered variants) - - Staple lands (Command Tower, Reliquary Tower, etc.) - - Kindred lands - - Generic fetch lands - - Args: - card_name: Card name to check - - Returns: - True if card should be filtered from "Most Common Cards" - """ - try: - from code.deck_builder import builder_constants as bc - - # Basic lands - basic_lands = set(getattr(bc, 'BASIC_LANDS', [])) - if card_name in basic_lands: - return True - - # Snow-covered basics - if card_name.startswith('Snow-Covered '): - base_name = card_name.replace('Snow-Covered ', '') - if base_name in basic_lands: - return True - - # Staple lands (keys from STAPLE_LAND_CONDITIONS) - staple_conditions = getattr(bc, 'STAPLE_LAND_CONDITIONS', {}) - if card_name in staple_conditions: - return True - - # Kindred lands - kindred_lands = set(getattr(bc, 'KINDRED_LAND_NAMES', [])) - if card_name in kindred_lands: - return True - - # Generic fetch lands - generic_fetches = set(getattr(bc, 'GENERIC_FETCH_LANDS', [])) - if card_name in generic_fetches: - return True - - # Color-specific fetch lands - color_fetches = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) - for fetch_list in color_fetches.values(): - if card_name in fetch_list: - return True - - return False - except Exception as e: - logger.debug(f"Error checking guaranteed card status for {card_name}: {e}") - return False - - -@router.get("/compare/{batch_id}", response_class=HTMLResponse) -async def compare_batch(request: Request, batch_id: str) -> HTMLResponse: - """Main comparison view for batch builds.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - batch_status = BuildCache.get_batch_status(sess, batch_id) - if not batch_status: - return templates.TemplateResponse("error.html", { - "request": request, - "error": f"Batch {batch_id} not found. It may have expired.", - "back_link": "/build" - }) - - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - - if not builds: - return templates.TemplateResponse("error.html", { - "request": request, - "error": "No completed builds found in this batch.", - "back_link": "/build" - }) - - # Calculate card overlap statistics - overlap_stats = _calculate_overlap(builds) - - # Prepare deck summaries - summaries = [] - for build in builds: - summary = _build_summary(build["result"], build["index"]) - summaries.append(summary) - - ctx = { - "request": request, - "batch_id": batch_id, - "batch_status": batch_status, - "config": config, - "builds": summaries, - "overlap_stats": overlap_stats, - "build_count": len(summaries), - "synergy_exported": BuildCache.is_synergy_exported(sess, batch_id) - } - - resp = templates.TemplateResponse("compare/index.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -def _calculate_overlap(builds: List[Dict[str, Any]]) -> Dict[str, Any]: - """ - Calculate card overlap statistics across builds. - - Args: - builds: List of build result dicts - - Returns: - Dict with overlap statistics - """ - from collections import Counter - - # Collect all cards with their appearance counts - card_counts: Counter = Counter() - total_builds = len(builds) - - # Collect include cards (must-includes) from first build as they should be in all - include_cards_set = set() - if builds: - first_result = builds[0].get("result", {}) - first_summary = first_result.get("summary", {}) - if isinstance(first_summary, dict): - include_exclude = first_summary.get("include_exclude_summary", {}) - if isinstance(include_exclude, dict): - includes = include_exclude.get("include_cards", []) - if isinstance(includes, list): - include_cards_set = set(includes) - - for build in builds: - result = build.get("result", {}) - summary = result.get("summary", {}) - if not isinstance(summary, dict): - continue - - type_breakdown = summary.get("type_breakdown", {}) - if not isinstance(type_breakdown, dict): - continue - - # Track unique cards per build (from type_breakdown cards dict) - unique_cards = set() - type_cards = type_breakdown.get("cards", {}) - if isinstance(type_cards, dict): - for card_list in type_cards.values(): - if isinstance(card_list, list): - for card in card_list: - if isinstance(card, dict): - card_name = card.get("name") - if card_name: - unique_cards.add(card_name) - - # Increment counter for each unique card - for card_name in unique_cards: - card_counts[card_name] += 1 - - # Calculate statistics - total_unique_cards = len(card_counts) - cards_in_all = sum(1 for count in card_counts.values() if count == total_builds) - cards_in_most = sum(1 for count in card_counts.values() if count >= total_builds * 0.8) - cards_in_some = sum(1 for count in card_counts.values() if total_builds * 0.2 < count < total_builds * 0.8) - cards_in_few = sum(1 for count in card_counts.values() if count <= total_builds * 0.2) - - # Most common cards - filter out guaranteed/staple cards to highlight interesting variance - # Filter before taking top 20 to show random selections rather than guaranteed hits - filtered_counts = { - name: count for name, count in card_counts.items() - if not _is_guaranteed_card(name) and name not in include_cards_set - } - most_common = Counter(filtered_counts).most_common(20) - - return { - "total_unique_cards": total_unique_cards, - "cards_in_all": cards_in_all, - "cards_in_most": cards_in_most, - "cards_in_some": cards_in_some, - "cards_in_few": cards_in_few, - "most_common": most_common, - "total_builds": total_builds - } - - -def _build_summary(result: Dict[str, Any], index: int) -> Dict[str, Any]: - """ - Create a summary of a single build for comparison display. - - Args: - result: Build result from orchestrator - index: Build index - - Returns: - Summary dict - """ - # Get summary from result - summary = result.get("summary", {}) - if not isinstance(summary, dict): - summary = {} - - # Get type breakdown which contains card counts - type_breakdown = summary.get("type_breakdown", {}) - if not isinstance(type_breakdown, dict): - type_breakdown = {} - - # Get counts directly from type breakdown - counts = type_breakdown.get("counts", {}) - - # Use standardized keys from type breakdown - creatures = counts.get("Creature", 0) - lands = counts.get("Land", 0) - artifacts = counts.get("Artifact", 0) - enchantments = counts.get("Enchantment", 0) - instants = counts.get("Instant", 0) - sorceries = counts.get("Sorcery", 0) - planeswalkers = counts.get("Planeswalker", 0) - - # Get total from type breakdown - total_cards = type_breakdown.get("total", 0) - - # Get all cards from type breakdown cards dict - all_cards = [] - type_cards = type_breakdown.get("cards", {}) - if isinstance(type_cards, dict): - for card_list in type_cards.values(): - if isinstance(card_list, list): - all_cards.extend(card_list) - - return { - "index": index, - "build_number": index + 1, - "total_cards": total_cards, - "creatures": creatures, - "lands": lands, - "artifacts": artifacts, - "enchantments": enchantments, - "instants": instants, - "sorceries": sorceries, - "planeswalkers": planeswalkers, - "cards": all_cards, - "result": result - } - - -@router.post("/compare/{batch_id}/export") -async def export_batch(request: Request, batch_id: str): - """ - Export all decks in a batch as a ZIP archive. - - Args: - request: FastAPI request object - batch_id: Batch identifier - - Returns: - ZIP file with all deck CSV/TXT files + summary JSON - """ - import zipfile - import io - import json - from pathlib import Path - from fastapi.responses import StreamingResponse - from datetime import datetime - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - batch_status = BuildCache.get_batch_status(sess, batch_id) - if not batch_status: - return {"error": f"Batch {batch_id} not found"} - - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - - if not builds: - return {"error": "No completed builds found in this batch"} - - # Create ZIP in memory - zip_buffer = io.BytesIO() - - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: - # Collect all deck files - commander_name = config.get("commander", "Unknown").replace("/", "-") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - for i, build in enumerate(builds): - result = build.get("result", {}) - csv_path = result.get("csv_path") - txt_path = result.get("txt_path") - - # Add CSV file - if csv_path and Path(csv_path).exists(): - filename = f"Build_{i+1}_{commander_name}.csv" - with open(csv_path, 'rb') as f: - zip_file.writestr(filename, f.read()) - - # Add TXT file - if txt_path and Path(txt_path).exists(): - filename = f"Build_{i+1}_{commander_name}.txt" - with open(txt_path, 'rb') as f: - zip_file.writestr(filename, f.read()) - - # Add batch summary JSON - summary_data = { - "batch_id": batch_id, - "commander": config.get("commander"), - "themes": config.get("tags", []), - "bracket": config.get("bracket"), - "build_count": len(builds), - "exported_at": timestamp, - "builds": [ - { - "build_number": i + 1, - "csv_file": f"Build_{i+1}_{commander_name}.csv", - "txt_file": f"Build_{i+1}_{commander_name}.txt" - } - for i in range(len(builds)) - ] - } - zip_file.writestr("batch_summary.json", json.dumps(summary_data, indent=2)) - - # Prepare response - zip_buffer.seek(0) - zip_filename = f"{commander_name}_Batch_{timestamp}.zip" - - return StreamingResponse( - iter([zip_buffer.getvalue()]), - media_type="application/zip", - headers={ - "Content-Disposition": f'attachment; filename="{zip_filename}"' - } - ) - - -@router.post("/compare/{batch_id}/rebuild") -async def rebuild_batch(request: Request, batch_id: str): - """ - Rebuild the same configuration with the same build count. - Creates a new batch with identical settings and redirects to batch progress. - - Args: - request: FastAPI request object - batch_id: Original batch identifier - - Returns: - Redirect to new batch progress page - """ - from fastapi.responses import RedirectResponse - from ..services.multi_build_orchestrator import MultiBuildOrchestrator - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get original config and build count - config = BuildCache.get_batch_config(sess, batch_id) - batch_status = BuildCache.get_batch_status(sess, batch_id) - - if not config or not batch_status: - return RedirectResponse(url="/build", status_code=302) - - # Get build count from original batch - build_count = batch_status.get("total_builds", 1) - - # Create new batch with same config - orchestrator = MultiBuildOrchestrator() - new_batch_id = orchestrator.queue_builds(config, build_count, sid) - - # Start builds in background - import asyncio - asyncio.create_task(orchestrator.run_batch_parallel(new_batch_id)) - - # Redirect to new batch progress - response = RedirectResponse(url=f"/build/batch/{new_batch_id}/progress", status_code=302) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - -@router.post("/compare/{batch_id}/build-synergy") -async def build_synergy_deck(request: Request, batch_id: str) -> HTMLResponse: - """ - Build a synergy deck from batch builds. - - Analyzes all builds in the batch and creates an optimized "best-of" deck - by scoring cards based on frequency, EDHREC rank, and theme alignment. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - batch_status = BuildCache.get_batch_status(sess, batch_id) - - if not builds or not config or not batch_status: - return HTMLResponse( - content=f'
Batch {batch_id} not found or has no builds
', - status_code=404 - ) - - start_time = time.time() - - try: - # Analyze and build synergy deck - synergy_deck = analyze_and_build_synergy_deck(builds, config) - - elapsed_ms = int((time.time() - start_time) * 1000) - - logger.info( - f"[Synergy] Built deck for batch {batch_id}: " - f"{synergy_deck['total_cards']} cards, " - f"avg_score={synergy_deck['avg_score']}, " - f"elapsed={elapsed_ms}ms" - ) - - # Prepare cards_by_category for template - cards_by_category = { - category: [ - { - "name": card.name, - "frequency": card.frequency, - "synergy_score": card.synergy_score, - "appearance_count": card.appearance_count, - "role": card.role, - "tags": card.tags, - "type_line": card.type_line, - "count": card.count - } - for card in cards - ] - for category, cards in synergy_deck["by_category"].items() - } - - # Render preview template - return templates.TemplateResponse("compare/_synergy_preview.html", { - "request": request, - "batch_id": batch_id, - "synergy_deck": { - "total_cards": synergy_deck["total_cards"], - "avg_frequency": synergy_deck["avg_frequency"], - "avg_score": synergy_deck["avg_score"], - "high_frequency_count": synergy_deck["high_frequency_count"], - "cards_by_category": cards_by_category - }, - "total_builds": len(builds), - "build_time_ms": elapsed_ms - }) - - except Exception as e: - logger.error(f"[Synergy] Error building synergy deck: {e}", exc_info=True) - return HTMLResponse( - content=f'
Failed to build synergy deck: {str(e)}
', - status_code=500 - ) - - -@router.post("/compare/{batch_id}/export-synergy") -async def export_synergy_deck(request: Request, batch_id: str): - """ - Export the synergy deck as CSV and TXT files in a ZIP archive. - - Args: - request: FastAPI request object - batch_id: Batch identifier - - Returns: - ZIP file with synergy deck CSV/TXT files - """ - import io - import csv - import zipfile - import json - from fastapi.responses import StreamingResponse - from datetime import datetime - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Get batch data - batch_status = BuildCache.get_batch_status(sess, batch_id) - if not batch_status: - return {"error": f"Batch {batch_id} not found"} - - builds = BuildCache.get_batch_builds(sess, batch_id) - config = BuildCache.get_batch_config(sess, batch_id) - - if not builds: - return {"error": "No completed builds found in this batch"} - - # Build synergy deck (reuse the existing logic) - from code.web.services.synergy_builder import analyze_and_build_synergy_deck - - try: - synergy_deck = analyze_and_build_synergy_deck( - builds=builds, - config=config - ) - except Exception as e: - logger.error(f"[Export Synergy] Error building synergy deck: {e}", exc_info=True) - return {"error": f"Failed to build synergy deck: {str(e)}"} - - # Prepare file names - commander_name = config.get("commander", "Unknown").replace("/", "-").replace(" ", "") - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - base_filename = f"{commander_name}_Synergy_{timestamp}" - - # Prepare deck_files directory - from pathlib import Path - deck_files_dir = Path("deck_files") - deck_files_dir.mkdir(parents=True, exist_ok=True) - - # Create CSV content - csv_buffer = io.StringIO() - csv_writer = csv.writer(csv_buffer) - - # CSV Header - csv_writer.writerow([ - "Name", "Count", "Category", "Role", "Frequency", "Synergy Score", - "Appearance Count", "Tags", "Type" - ]) - - # CSV Rows - sort by category - category_order = ["Land", "Creature", "Artifact", "Enchantment", "Instant", "Sorcery", "Planeswalker", "Battle"] - by_category = synergy_deck.get("by_category", {}) - - for category in category_order: - cards = by_category.get(category, []) - for card in cards: - csv_writer.writerow([ - card.name, - card.count, - card.category, - card.role, - f"{card.frequency:.2%}", - f"{card.synergy_score:.2f}", - card.appearance_count, - "|".join(card.tags) if card.tags else "", - card.type_line - ]) - - csv_content = csv_buffer.getvalue() - - # Create TXT content (Moxfield/EDHREC format) - txt_buffer = io.StringIO() - - # TXT Header - txt_buffer.write(f"# Synergy Deck - {commander_name}\n") - txt_buffer.write(f"# Commander: {config.get('commander', 'Unknown')}\n") - txt_buffer.write(f"# Colors: {', '.join(config.get('colors', []))}\n") - txt_buffer.write(f"# Themes: {', '.join(config.get('tags', []))}\n") - txt_buffer.write(f"# Generated from {len(builds)} builds\n") - txt_buffer.write(f"# Total Cards: {synergy_deck['total_cards']}\n") - txt_buffer.write(f"# Avg Frequency: {synergy_deck['avg_frequency']:.1%}\n") - txt_buffer.write(f"# Avg Synergy Score: {synergy_deck['avg_score']:.2f}\n") - txt_buffer.write("\n") - - # TXT Card list - for category in category_order: - cards = by_category.get(category, []) - if not cards: - continue - - for card in cards: - line = f"{card.count} {card.name}" - if card.count > 1: - # Show count prominently for multi-copy cards - txt_buffer.write(f"{line}\n") - else: - txt_buffer.write(f"1 {card.name}\n") - - txt_content = txt_buffer.getvalue() - - # Save CSV and TXT to deck_files directory - csv_path = deck_files_dir / f"{base_filename}.csv" - txt_path = deck_files_dir / f"{base_filename}.txt" - summary_path = deck_files_dir / f"{base_filename}.summary.json" - compliance_path = deck_files_dir / f"{base_filename}_compliance.json" - - try: - csv_path.write_text(csv_content, encoding='utf-8') - txt_path.write_text(txt_content, encoding='utf-8') - - # Create summary JSON (similar to individual builds) - summary_data = { - "commander": config.get("commander", "Unknown"), - "tags": config.get("tags", []), - "colors": config.get("colors", []), - "bracket_level": config.get("bracket"), - "csv": str(csv_path), - "txt": str(txt_path), - "synergy_stats": { - "total_cards": synergy_deck["total_cards"], - "unique_cards": synergy_deck.get("unique_cards", len(synergy_deck["cards"])), - "avg_frequency": synergy_deck["avg_frequency"], - "avg_score": synergy_deck["avg_score"], - "high_frequency_count": synergy_deck["high_frequency_count"], - "source_builds": len(builds) - }, - "exported_at": timestamp - } - summary_path.write_text(json.dumps(summary_data, indent=2), encoding='utf-8') - - # Create compliance JSON (basic compliance for synergy deck) - compliance_data = { - "overall": "N/A", - "message": "Synergy deck - compliance checking not applicable", - "deck_size": synergy_deck["total_cards"], - "commander": config.get("commander", "Unknown"), - "source": "synergy_builder", - "build_count": len(builds) - } - compliance_path.write_text(json.dumps(compliance_data, indent=2), encoding='utf-8') - - logger.info(f"[Export Synergy] Saved synergy deck to {csv_path} and {txt_path}") - except Exception as e: - logger.error(f"[Export Synergy] Failed to save files to disk: {e}", exc_info=True) - - # Delete batch build files to avoid clutter - deleted_files = [] - for build in builds: - result = build.get("result", {}) - csv_file = result.get("csv_path") - txt_file = result.get("txt_path") - summary_file = result.get("summary_path") - - # Delete CSV file - if csv_file: - csv_p = Path(csv_file) - if csv_p.exists(): - try: - csv_p.unlink() - deleted_files.append(csv_p.name) - except Exception as e: - logger.warning(f"[Export Synergy] Failed to delete {csv_file}: {e}") - - # Delete TXT file - if txt_file: - txt_p = Path(txt_file) - if txt_p.exists(): - try: - txt_p.unlink() - deleted_files.append(txt_p.name) - except Exception as e: - logger.warning(f"[Export Synergy] Failed to delete {txt_file}: {e}") - - # Delete summary JSON file - if summary_file: - summary_p = Path(summary_file) - if summary_p.exists(): - try: - summary_p.unlink() - deleted_files.append(summary_p.name) - except Exception as e: - logger.warning(f"[Export Synergy] Failed to delete {summary_file}: {e}") - - if deleted_files: - logger.info(f"[Export Synergy] Cleaned up {len(deleted_files)} batch build files") - - # Mark batch as having synergy exported (to disable batch export button) - BuildCache.mark_synergy_exported(sess, batch_id) - - # Create ZIP in memory for download - zip_buffer = io.BytesIO() - - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: - # Add CSV to ZIP - zip_file.writestr(f"{base_filename}.csv", csv_content) - - # Add TXT to ZIP - zip_file.writestr(f"{base_filename}.txt", txt_content) - - # Add summary JSON to ZIP - summary_json = json.dumps(summary_data, indent=2) - zip_file.writestr(f"{base_filename}.summary.json", summary_json) - - # Add compliance JSON to ZIP - compliance_json = json.dumps(compliance_data, indent=2) - zip_file.writestr(f"{base_filename}_compliance.json", compliance_json) - - # Add metadata JSON (export-specific info) - metadata = { - "batch_id": batch_id, - "commander": config.get("commander"), - "themes": config.get("tags", []), - "colors": config.get("colors", []), - "bracket": config.get("bracket"), - "build_count": len(builds), - "exported_at": timestamp, - "synergy_stats": { - "total_cards": synergy_deck["total_cards"], - "avg_frequency": synergy_deck["avg_frequency"], - "avg_score": synergy_deck["avg_score"], - "high_frequency_count": synergy_deck["high_frequency_count"] - }, - "cleaned_up_files": len(deleted_files) - } - zip_file.writestr("synergy_metadata.json", json.dumps(metadata, indent=2)) - - # Prepare response - zip_buffer.seek(0) - zip_filename = f"{base_filename}.zip" - - return StreamingResponse( - iter([zip_buffer.getvalue()]), - media_type="application/zip", - headers={ - "Content-Disposition": f'attachment; filename="{zip_filename}"' - } - ) diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 9b4f290..957936b 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -425,7 +425,7 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s mt_val = str(int(mt)) except Exception: mt_val = "0" - options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) + options.append({"name": it.get("name"), "label": label, "mtime": mt_val}) # type: ignore[arg-type] diffs = None metaA: Dict[str, str] = {} diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py index dc711d4..7920920 100644 --- a/code/web/routes/setup.py +++ b/code/web/routes/setup.py @@ -3,11 +3,12 @@ from __future__ import annotations import threading from typing import Optional from fastapi import APIRouter, Request +from fastapi import Body from pathlib import Path import json as _json from fastapi.responses import HTMLResponse, JSONResponse from ..app import templates -from ..services.orchestrator import _ensure_setup_ready +from ..services.orchestrator import _ensure_setup_ready # type: ignore router = APIRouter(prefix="/setup") @@ -20,23 +21,18 @@ def _kickoff_setup_async(force: bool = False): """ def runner(): try: - print(f"[SETUP THREAD] Starting setup/tagging (force={force})...") - _ensure_setup_ready(print, force=force) - print("[SETUP THREAD] Setup/tagging completed successfully") + _ensure_setup_ready(print, force=force) # type: ignore[arg-type] except Exception as e: # pragma: no cover - background best effort try: - import traceback - print(f"[SETUP THREAD] Setup thread failed: {e}") - print(f"[SETUP THREAD] Traceback:\n{traceback.format_exc()}") + print(f"Setup thread failed: {e}") except Exception: pass t = threading.Thread(target=runner, daemon=True) t.start() - print(f"[SETUP] Background thread started (force={force})") @router.get("/running", response_class=HTMLResponse) -async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: +async def setup_running(request: Request, start: Optional[int] = 0, next: Optional[str] = None, force: Optional[bool] = None) -> HTMLResponse: # type: ignore[override] # Optionally start the setup/tagging in the background if requested try: if start and int(start) != 0: @@ -58,16 +54,8 @@ async def setup_running(request: Request, start: Optional[int] = 0, next: Option @router.post("/start") -async def setup_start(request: Request): - """POST endpoint for setup/tagging. Accepts JSON body {"force": true/false} or query string ?force=1""" - force = False +async def setup_start(request: Request, force: bool = Body(False)): # accept JSON body {"force": true} try: - # Try to parse JSON body first - try: - body = await request.json() - force = bool(body.get('force', False)) - except Exception: - pass # Allow query string override as well (?force=1) try: q_force = request.query_params.get('force') @@ -120,86 +108,6 @@ async def setup_start_get(request: Request): return JSONResponse({"ok": False}, status_code=500) -@router.post("/download-github") -async def download_github(): - """Download pre-tagged database from GitHub similarity-cache-data branch.""" - import urllib.request - import urllib.error - import shutil - from pathlib import Path - - try: - # GitHub raw URLs for the similarity-cache-data branch - base_url = "https://raw.githubusercontent.com/mwisnowski/mtg_python_deckbuilder/similarity-cache-data" - - files_to_download = [ - ("card_files/processed/all_cards.parquet", "card_files/processed/all_cards.parquet"), - ("card_files/processed/commander_cards.parquet", "card_files/processed/commander_cards.parquet"), - ("card_files/processed/.tagging_complete.json", "card_files/processed/.tagging_complete.json"), - ("card_files/similarity_cache.parquet", "card_files/similarity_cache.parquet"), - ("card_files/similarity_cache_metadata.json", "card_files/similarity_cache_metadata.json"), - ] - - downloaded = [] - failed = [] - - for remote_path, local_path in files_to_download: - url = f"{base_url}/{remote_path}" - dest = Path(local_path) - dest.parent.mkdir(parents=True, exist_ok=True) - - try: - print(f"[DOWNLOAD] Fetching {url}...") - with urllib.request.urlopen(url, timeout=60) as response: - with dest.open('wb') as out_file: - shutil.copyfileobj(response, out_file) - downloaded.append(local_path) - print(f"[DOWNLOAD] Saved to {local_path}") - except urllib.error.HTTPError as e: - if e.code == 404: - print(f"[DOWNLOAD] File not found (404): {remote_path}") - failed.append(f"{remote_path} (not yet available)") - else: - print(f"[DOWNLOAD] HTTP error {e.code}: {remote_path}") - failed.append(f"{remote_path} (HTTP {e.code})") - except Exception as e: - print(f"[DOWNLOAD] Failed to download {remote_path}: {e}") - failed.append(f"{remote_path} ({str(e)[:50]})") - - if downloaded: - msg = f"Downloaded {len(downloaded)} file(s) from GitHub" - if failed: - msg += f" ({len(failed)} unavailable)" - return JSONResponse({ - "ok": True, - "message": msg, - "files": downloaded, - "failed": failed - }) - else: - # No files downloaded - likely the branch doesn't exist yet - return JSONResponse({ - "ok": False, - "message": "Files not available yet. Run the 'Build Similarity Cache' workflow on GitHub first, or use 'Run Setup/Tagging' to build locally.", - "failed": failed - }, status_code=404) - - except Exception as e: - print(f"[DOWNLOAD] Error: {e}") - return JSONResponse({ - "ok": False, - "message": f"Download failed: {str(e)}" - }, status_code=500) - - @router.get("/", response_class=HTMLResponse) async def setup_index(request: Request) -> HTMLResponse: - import code.settings as settings - from code.file_setup.image_cache import ImageCache - - image_cache = ImageCache() - return templates.TemplateResponse("setup/index.html", { - "request": request, - "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES, - "image_cache_enabled": image_cache.is_enabled() - }) + return templates.TemplateResponse("setup/index.html", {"request": request}) diff --git a/code/web/routes/themes.py b/code/web/routes/themes.py index 4917aa7..32cb279 100644 --- a/code/web/routes/themes.py +++ b/code/web/routes/themes.py @@ -7,7 +7,7 @@ from typing import Optional, Dict, Any from fastapi import APIRouter, Request, HTTPException, Query from fastapi import BackgroundTasks -from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment +from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore from fastapi.responses import JSONResponse, HTMLResponse from fastapi.templating import Jinja2Templates from ..services.theme_catalog_loader import ( @@ -17,10 +17,10 @@ from ..services.theme_catalog_loader import ( filter_slugs_fast, summaries_for_slugs, ) -from ..services.theme_preview import get_theme_preview -from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters -from ..services.theme_preview import preview_metrics -from ..services import theme_preview as _theme_preview_mod # for error counters +from ..services.theme_preview import get_theme_preview # type: ignore +from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters # type: ignore +from ..services.theme_preview import preview_metrics # type: ignore +from ..services import theme_preview as _theme_preview_mod # type: ignore # for error counters import os from fastapi import Body @@ -36,7 +36,7 @@ router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status # Reuse the main app's template environment so nav globals stay consistent. try: # circular-safe import: app defines templates before importing this router - from ..app import templates as _templates + from ..app import templates as _templates # type: ignore except Exception: # Fallback (tests/minimal contexts) _templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates')) @@ -131,7 +131,7 @@ async def theme_suggest( # Optional rate limit using app helper if available rl_result = None try: - from ..app import rate_limit_check + from ..app import rate_limit_check # type: ignore rl_result = rate_limit_check(request, "suggest") except HTTPException as http_ex: # propagate 429 with headers raise http_ex @@ -231,7 +231,7 @@ async def theme_status(): yaml_file_count = 0 if yaml_catalog_exists: try: - yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) + yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type] except Exception: yaml_file_count = -1 tagged_time = _load_tag_flag_time() @@ -291,6 +291,28 @@ def _diag_enabled() -> bool: return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"} +@router.get("/picker", response_class=HTMLResponse) +async def theme_picker_page(request: Request): + """Render the theme picker shell. + + Dynamic data (list, detail) loads via fragment endpoints. We still inject + known archetype list for the filter select so it is populated on initial load. + """ + archetypes: list[str] = [] + try: + idx = load_index() + archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type] + except Exception: + archetypes = [] + return _templates.TemplateResponse( + "themes/picker.html", + { + "request": request, + "archetypes": archetypes, + "theme_picker_diagnostics": _diag_enabled(), + }, + ) + @router.get("/metrics") async def theme_metrics(): if not _diag_enabled(): @@ -547,7 +569,7 @@ async def theme_yaml(theme_id: str): raise HTTPException(status_code=404, detail="yaml_not_found") # Reconstruct minimal YAML (we have dict already) import yaml as _yaml # local import to keep top-level lean - text = _yaml.safe_dump(y, sort_keys=False) + text = _yaml.safe_dump(y, sort_keys=False) # type: ignore headers = {"Content-Type": "text/plain; charset=utf-8"} return HTMLResponse(text, headers=headers) @@ -631,7 +653,7 @@ async def api_theme_search( prefix: list[dict[str, Any]] = [] substr: list[dict[str, Any]] = [] seen: set[str] = set() - themes_iter = list(idx.catalog.themes) + themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined] # Phase 1 + 2: exact / prefix for t in themes_iter: name = t.theme @@ -724,9 +746,89 @@ async def api_theme_preview( return JSONResponse({"ok": True, "preview": payload}) +@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse) +async def theme_preview_fragment( + theme_id: str, + limit: int = Query(12, ge=1, le=30), + colors: str | None = None, + commander: str | None = None, + suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"), + minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale – used in detail page collapsible preview)"), + request: Request = None, +): + """Return HTML fragment for theme preview with caching headers. - -@router.get("/fragment/list", response_class=HTMLResponse) + Adds ETag and Last-Modified headers (no strong caching – enables conditional GET / 304). + ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander). + """ + try: + payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander) + except KeyError: + return HTMLResponse("
Theme not found.
", status_code=404) + # Load example commanders (authoritative list) from catalog detail for legality instead of inferring + example_commanders: list[str] = [] + synergy_commanders: list[str] = [] + try: + idx = load_index() + slug = slugify(theme_id) + entry = idx.slug_to_entry.get(slug) + if entry: + detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False) + example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)] + synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)] + # De-duplicate any overlap with example commanders while preserving order + seen = set(example_commanders) + for c in synergy_commanders_raw: + if c not in seen: + synergy_commanders.append(c) + seen.add(c) + except Exception: + example_commanders = [] + synergy_commanders = [] + # Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift) + import hashlib + import json as _json + import time as _time + try: + idx = load_index() + catalog_tag = idx.etag + except Exception: + catalog_tag = "unknown" + hash_src = _json.dumps({ + "theme": theme_id, + "limit": limit, + "commander": commander, + "sample": payload.get("sample", [])[:3], # small slice for stability & speed + "v": 1, + }, sort_keys=True).encode("utf-8") + etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}" + # Conditional request support + if request is not None: + inm = request.headers.get("if-none-match") + if inm and inm == etag: + # 304 Not Modified – FastAPI HTMLResponse with empty body & headers + resp = HTMLResponse(status_code=304, content="") + resp.headers["ETag"] = etag + from email.utils import formatdate as _fmtdate + resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True) + resp.headers["Cache-Control"] = "no-cache" + return resp + ctx = { + "request": request, + "preview": payload, + "example_commanders": example_commanders, + "synergy_commanders": synergy_commanders, + "theme_id": theme_id, + "etag": etag, + "suppress_curated": suppress_curated, + "minimal": minimal, + } + resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx) + resp.headers["ETag"] = etag + from email.utils import formatdate as _fmtdate + resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True) + resp.headers["Cache-Control"] = "no-cache" + return resp # --- Preview Export Endpoints (CSV / JSON) --- diff --git a/code/web/services/build_cache.py b/code/web/services/build_cache.py deleted file mode 100644 index 1511cba..0000000 --- a/code/web/services/build_cache.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Build Cache - Session-based storage for multi-build batch results. - -Stores completed deck builds in session for comparison view. -""" - -from __future__ import annotations -from typing import Any, Dict, List, Optional -import time -import uuid - - -class BuildCache: - """Manages storage and retrieval of batch build results in session.""" - - @staticmethod - def create_batch(sess: Dict[str, Any], config: Dict[str, Any], count: int) -> str: - """ - Create a new batch build entry in session. - - Args: - sess: Session dictionary - config: Deck configuration (commander, themes, ideals, etc.) - count: Number of builds in batch - - Returns: - batch_id: Unique identifier for this batch - """ - batch_id = f"batch_{uuid.uuid4().hex[:12]}" - - if "batch_builds" not in sess: - sess["batch_builds"] = {} - - sess["batch_builds"][batch_id] = { - "batch_id": batch_id, - "config": config, - "count": count, - "completed": 0, - "builds": [], - "started_at": time.time(), - "completed_at": None, - "status": "running", # running, completed, error - "errors": [] - } - - return batch_id - - @staticmethod - def store_build(sess: Dict[str, Any], batch_id: str, build_index: int, result: Dict[str, Any]) -> None: - """ - Store a completed build result in the batch. - - Args: - sess: Session dictionary - batch_id: Batch identifier - build_index: Index of this build (0-based) - result: Deck build result from orchestrator - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - raise ValueError(f"Batch {batch_id} not found in session") - - batch = sess["batch_builds"][batch_id] - - # Ensure builds list has enough slots - while len(batch["builds"]) <= build_index: - batch["builds"].append(None) - - # Store build result with minimal data for comparison - batch["builds"][build_index] = { - "index": build_index, - "result": result, - "completed_at": time.time() - } - - batch["completed"] += 1 - - # Mark batch as completed if all builds done - if batch["completed"] >= batch["count"]: - batch["status"] = "completed" - batch["completed_at"] = time.time() - - @staticmethod - def store_build_error(sess: Dict[str, Any], batch_id: str, build_index: int, error: str) -> None: - """ - Store an error for a failed build. - - Args: - sess: Session dictionary - batch_id: Batch identifier - build_index: Index of this build (0-based) - error: Error message - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - raise ValueError(f"Batch {batch_id} not found in session") - - batch = sess["batch_builds"][batch_id] - - batch["errors"].append({ - "build_index": build_index, - "error": error, - "timestamp": time.time() - }) - - batch["completed"] += 1 - - # Mark batch as completed if all builds done (even with errors) - if batch["completed"] >= batch["count"]: - batch["status"] = "completed" if not batch["errors"] else "error" - batch["completed_at"] = time.time() - - @staticmethod - def get_batch_status(sess: Dict[str, Any], batch_id: str) -> Optional[Dict[str, Any]]: - """ - Get current status of a batch build. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - Status dict with progress info, or None if not found - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return None - - batch = sess["batch_builds"][batch_id] - - return { - "batch_id": batch_id, - "status": batch["status"], - "count": batch["count"], - "completed": batch["completed"], - "progress_pct": int((batch["completed"] / batch["count"]) * 100) if batch["count"] > 0 else 0, - "has_errors": len(batch["errors"]) > 0, - "error_count": len(batch["errors"]), - "elapsed_time": time.time() - batch["started_at"] - } - - @staticmethod - def get_batch_builds(sess: Dict[str, Any], batch_id: str) -> Optional[List[Dict[str, Any]]]: - """ - Get all completed builds for a batch. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - List of build results, or None if batch not found - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return None - - batch = sess["batch_builds"][batch_id] - return [b for b in batch["builds"] if b is not None] - - @staticmethod - def get_batch_config(sess: Dict[str, Any], batch_id: str) -> Optional[Dict[str, Any]]: - """ - Get the original configuration for a batch. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - Config dict, or None if batch not found - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return None - - return sess["batch_builds"][batch_id]["config"] - - @staticmethod - def clear_batch(sess: Dict[str, Any], batch_id: str) -> bool: - """ - Remove a batch from session. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - True if batch was found and removed, False otherwise - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return False - - del sess["batch_builds"][batch_id] - return True - - @staticmethod - def list_batches(sess: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - List all batches in session with summary info. - - Args: - sess: Session dictionary - - Returns: - List of batch summary dicts - """ - if "batch_builds" not in sess: - return [] - - summaries = [] - for batch_id, batch in sess["batch_builds"].items(): - summaries.append({ - "batch_id": batch_id, - "status": batch["status"], - "count": batch["count"], - "completed": batch["completed"], - "commander": batch["config"].get("commander", "Unknown"), - "started_at": batch["started_at"], - "completed_at": batch.get("completed_at") - }) - - # Sort by start time, most recent first - summaries.sort(key=lambda x: x["started_at"], reverse=True) - return summaries - - @staticmethod - def mark_synergy_exported(sess: Dict[str, Any], batch_id: str) -> bool: - """ - Mark a batch as having its synergy deck exported (disables batch export). - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - True if batch was found and marked, False otherwise - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return False - - sess["batch_builds"][batch_id]["synergy_exported"] = True - sess["batch_builds"][batch_id]["synergy_exported_at"] = time.time() - return True - - @staticmethod - def is_synergy_exported(sess: Dict[str, Any], batch_id: str) -> bool: - """ - Check if a batch's synergy deck has been exported. - - Args: - sess: Session dictionary - batch_id: Batch identifier - - Returns: - True if synergy has been exported, False otherwise - """ - if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: - return False - - return sess["batch_builds"][batch_id].get("synergy_exported", False) diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 8c11c56..ee97e43 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -175,8 +175,6 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s ctx["partner_mode"] = sess.get("partner_mode") ctx["combined_commander"] = sess.get("combined_commander") ctx["partner_warnings"] = list(sess.get("partner_warnings", []) or []) - # M2: Attach session reference to context for skip controls - ctx["session"] = sess return ctx @@ -202,7 +200,7 @@ def commander_hover_context( from .summary_utils import format_theme_label, format_theme_list except Exception: # Fallbacks in the unlikely event of circular import issues - def format_theme_label(value: Any) -> str: + def format_theme_label(value: Any) -> str: # type: ignore[redef] text = str(value or "").strip().replace("_", " ") if not text: return "" @@ -214,10 +212,10 @@ def commander_hover_context( parts.append(chunk[:1].upper() + chunk[1:].lower()) return " ".join(parts) - def format_theme_list(values: Iterable[Any]) -> list[str]: + def format_theme_list(values: Iterable[Any]) -> list[str]: # type: ignore[redef] seen: set[str] = set() result: list[str] = [] - for raw in values or []: + for raw in values or []: # type: ignore[arg-type] label = format_theme_label(raw) if not label or len(label) <= 1: continue @@ -310,48 +308,19 @@ def commander_hover_context( raw_color_identity = combined_info.get("color_identity") if combined_info else None commander_color_identity: list[str] = [] - - # If we have a combined commander (partner/background), use its color identity if isinstance(raw_color_identity, (list, tuple, set)): for item in raw_color_identity: token = str(item).strip().upper() if token: commander_color_identity.append(token) - - # For regular commanders (no partner/background), look up from commander catalog first - if not commander_color_identity and not has_combined and commander_name: - try: - from .commander_catalog_loader import find_commander_record - record = find_commander_record(commander_name) - if record and hasattr(record, 'color_identity'): - raw_ci = record.color_identity - if isinstance(raw_ci, (list, tuple, set)): - for item in raw_ci: - token = str(item).strip().upper() - if token: - commander_color_identity.append(token) - except Exception: - pass - - # Fallback: check summary.colors if we still don't have color identity - if not commander_color_identity and not has_combined and isinstance(summary, dict): - summary_colors = summary.get("colors") - if isinstance(summary_colors, (list, tuple, set)): - for item in summary_colors: - token = str(item).strip().upper() - if token: - commander_color_identity.append(token) commander_color_label = "" if has_combined: commander_color_label = str(combined_info.get("color_label") or "").strip() if not commander_color_label and commander_color_identity: commander_color_label = " / ".join(commander_color_identity) - # M5: Set colorless label for ANY commander with empty color identity (not just partner/combined) - if not commander_color_label and (has_combined or commander_name): - # Empty color_identity list means colorless - if not commander_color_identity: - commander_color_label = "Colorless (C)" + if has_combined and not commander_color_label: + commander_color_label = "Colorless (C)" commander_color_code = str(combined_info.get("color_code") or "").strip() if has_combined else "" commander_partner_mode = str(combined_info.get("partner_mode") or "").strip() if has_combined else "" @@ -420,7 +389,7 @@ def step5_ctx_from_result( else: entry = {} try: - entry.update(vars(item)) + entry.update(vars(item)) # type: ignore[arg-type] except Exception: pass # Preserve common attributes when vars() empty diff --git a/code/web/services/card_index.py b/code/web/services/card_index.py index eac6e7b..2c1941d 100644 --- a/code/web/services/card_index.py +++ b/code/web/services/card_index.py @@ -4,21 +4,30 @@ Phase A refactor: Provides a thin API for building and querying the in-memory card index keyed by tag/theme. Future enhancements may introduce a persistent cache layer or precomputed artifact. -M4: Updated to load from all_cards.parquet instead of CSV shards. - Public API: maybe_build_index() -> None get_tag_pool(tag: str) -> list[dict] lookup_commander(name: str) -> dict | None -The index is rebuilt lazily when the Parquet file mtime changes. +The index is rebuilt lazily when any of the CSV shard files change mtime. """ from __future__ import annotations from pathlib import Path +import csv +import os from typing import Any, Dict, List, Optional -# M4: No longer need CSV file glob, we load from Parquet +CARD_FILES_GLOB = [ + Path("csv_files/blue_cards.csv"), + Path("csv_files/white_cards.csv"), + Path("csv_files/black_cards.csv"), + Path("csv_files/red_cards.csv"), + Path("csv_files/green_cards.csv"), + Path("csv_files/colorless_cards.csv"), + Path("csv_files/cards.csv"), # fallback large file last +] + THEME_TAGS_COL = "themeTags" NAME_COL = "name" COLOR_IDENTITY_COL = "colorIdentity" @@ -44,63 +53,75 @@ def _normalize_rarity(raw: str) -> str: r = (raw or "").strip().lower() return _RARITY_NORM.get(r, r) +def _resolve_card_files() -> List[Path]: + """Return base card file list + any extra test files supplied via env. + + Environment variable: CARD_INDEX_EXTRA_CSV can contain a comma or semicolon + separated list of additional CSV paths (used by tests to inject synthetic + edge cases without polluting production shards). + """ + files: List[Path] = list(CARD_FILES_GLOB) + extra = os.getenv("CARD_INDEX_EXTRA_CSV") + if extra: + for part in extra.replace(";", ",").split(","): + p = part.strip() + if not p: + continue + path_obj = Path(p) + # Include even if missing; maybe created later in test before build + files.append(path_obj) + return files + def maybe_build_index() -> None: - """Rebuild the index if the Parquet file mtime changed. + """Rebuild the index if any card CSV mtime changed. - M4: Loads from all_cards.parquet instead of CSV files. + Incorporates any extra CSVs specified via CARD_INDEX_EXTRA_CSV. """ global _CARD_INDEX, _CARD_INDEX_MTIME - - try: - from path_util import get_processed_cards_path - from deck_builder import builder_utils as bu - - parquet_path = Path(get_processed_cards_path()) - if not parquet_path.exists(): - return - - latest = parquet_path.stat().st_mtime - if _CARD_INDEX and _CARD_INDEX_MTIME and latest <= _CARD_INDEX_MTIME: - return - - # Load from Parquet - df = bu._load_all_cards_parquet() - if df.empty or THEME_TAGS_COL not in df.columns: - return - - new_index: Dict[str, List[Dict[str, Any]]] = {} - - for _, row in df.iterrows(): - name = row.get(NAME_COL) or row.get("faceName") or "" - tags = row.get(THEME_TAGS_COL) - - # Handle tags (already a list after our conversion in builder_utils) - if not tags or not isinstance(tags, list): - continue - - color_id = str(row.get(COLOR_IDENTITY_COL) or "").strip() - mana_cost = str(row.get(MANA_COST_COL) or "").strip() - rarity = _normalize_rarity(str(row.get(RARITY_COL) or "")) - - for tg in tags: - if not tg: + latest = 0.0 + card_files = _resolve_card_files() + for p in card_files: + if p.exists(): + mt = p.stat().st_mtime + if mt > latest: + latest = mt + if _CARD_INDEX and _CARD_INDEX_MTIME and latest <= _CARD_INDEX_MTIME: + return + new_index: Dict[str, List[Dict[str, Any]]] = {} + for p in card_files: + if not p.exists(): + continue + try: + with p.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + if not reader.fieldnames or THEME_TAGS_COL not in reader.fieldnames: continue - new_index.setdefault(tg, []).append({ - "name": name, - "color_identity": color_id, - "tags": tags, - "mana_cost": mana_cost, - "rarity": rarity, - "color_identity_list": [c.strip() for c in color_id.split(',') if c.strip()], - "pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}], - }) - - _CARD_INDEX = new_index - _CARD_INDEX_MTIME = latest - except Exception: - # Defensive: if anything fails, leave index unchanged - pass + for row in reader: + name = row.get(NAME_COL) or row.get("faceName") or "" + tags_raw = row.get(THEME_TAGS_COL) or "" + tags = [t.strip(" '[]") for t in tags_raw.split(',') if t.strip()] if tags_raw else [] + if not tags: + continue + color_id = (row.get(COLOR_IDENTITY_COL) or "").strip() + mana_cost = (row.get(MANA_COST_COL) or "").strip() + rarity = _normalize_rarity(row.get(RARITY_COL) or "") + for tg in tags: + if not tg: + continue + new_index.setdefault(tg, []).append({ + "name": name, + "color_identity": color_id, + "tags": tags, + "mana_cost": mana_cost, + "rarity": rarity, + "color_identity_list": list(color_id) if color_id else [], + "pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}], + }) + except Exception: + continue + _CARD_INDEX = new_index + _CARD_INDEX_MTIME = latest def get_tag_pool(tag: str) -> List[Dict[str, Any]]: return _CARD_INDEX.get(tag, []) diff --git a/code/web/services/card_similarity.py b/code/web/services/card_similarity.py deleted file mode 100644 index 589d86d..0000000 --- a/code/web/services/card_similarity.py +++ /dev/null @@ -1,487 +0,0 @@ -""" -Card similarity service using Jaccard index on theme tags. - -Provides similarity scoring between cards based on theme tag overlap. -Used for "Similar Cards" feature in card browser. - -Supports persistent caching for improved performance (2-6s → <500ms). - -Uses "signature tags" approach: compares top 5 most frequent tags instead -of all tags, significantly improving performance and quality. -""" - -import ast -import logging -import random -from pathlib import Path -from typing import Optional - -import pandas as pd - -from code.web.services.similarity_cache import SimilarityCache, get_cache - -logger = logging.getLogger(__name__) - - -class CardSimilarity: - """Calculate card similarity using theme tag overlap (Jaccard index) with caching.""" - - def __init__(self, cards_df: Optional[pd.DataFrame] = None, cache: Optional[SimilarityCache] = None): - """ - Initialize similarity calculator. - - Args: - cards_df: DataFrame with card data. If None, loads from processed all_cards.parquet - cache: SimilarityCache instance. If None, uses global singleton - """ - if cards_df is None: - # Load from processed directory (M4 Parquet migration) - from path_util import get_processed_cards_path - parquet_path = get_processed_cards_path() - logger.info(f"Loading cards from {parquet_path}") - self.cards_df = pd.read_parquet(parquet_path) - else: - self.cards_df = cards_df - - # Initialize cache - self.cache = cache if cache is not None else get_cache() - - # Load theme frequencies from catalog - self.theme_frequencies = self._load_theme_frequencies() - - # Pre-compute cleaned tags (with exclusions) for all cards (one-time cost, huge speedup) - # This removes "Historics Matter" and "Legends Matter" from all cards - self.cleaned_tags_cache = self._precompute_cleaned_tags() - - # Pre-compute card metadata (EDHREC rank) for fast lookups - self._card_metadata = self._precompute_card_metadata() - - # Inverted index (tag -> set of card names) - built lazily on first use - self._tag_to_cards_index = None - - logger.info( - f"Initialized CardSimilarity with {len(self.cards_df)} cards " - f"and {len(self.theme_frequencies)} theme frequencies " - f"(cache: {'enabled' if self.cache.enabled else 'disabled'})" - ) - - def _load_theme_frequencies(self) -> dict[str, int]: - """ - Load theme frequencies from theme_catalog.csv. - - Returns: - Dict mapping theme name to card_count (higher = more common) - """ - catalog_path = Path(__file__).parents[3] / "config" / "themes" / "theme_catalog.csv" - - try: - # Read CSV, skipping comment line - df = pd.read_csv(catalog_path, comment="#") - - # Create dict mapping theme -> card_count - # Higher card_count = more common/frequent theme - frequencies = dict(zip(df["theme"], df["card_count"])) - - logger.info(f"Loaded {len(frequencies)} theme frequencies from catalog") - return frequencies - - except Exception as e: - logger.warning(f"Failed to load theme frequencies: {e}, using empty dict") - return {} - - def _precompute_cleaned_tags(self) -> dict[str, set[str]]: - """ - Pre-compute cleaned tags for all cards. - - Removes overly common tags like "Historics Matter" and "Legends Matter" - that don't provide meaningful similarity. This is done once during - initialization to avoid recalculating for every comparison. - - Returns: - Dict mapping card name -> cleaned tags (full set minus exclusions) - """ - logger.info("Pre-computing cleaned tags for all cards...") - excluded_tags = {"Historics Matter", "Legends Matter"} - cleaned = {} - - for _, row in self.cards_df.iterrows(): - card_name = row["name"] - tags = self.parse_theme_tags(row["themeTags"]) - - if tags: - # Remove excluded tags - cleaned_tags = tags - excluded_tags - if cleaned_tags: # Only store if card has tags after exclusion - cleaned[card_name] = cleaned_tags - - logger.info(f"Pre-computed {len(cleaned)} card tag sets") - return cleaned - - def _precompute_card_metadata(self) -> dict[str, dict]: - """ - Pre-compute card metadata (EDHREC rank, etc.) for fast lookups. - - Returns: - Dict mapping card name -> metadata dict - """ - logger.info("Pre-computing card metadata...") - metadata = {} - - for _, row in self.cards_df.iterrows(): - card_name = row["name"] - edhrec_rank = row.get("edhrecRank") - # Convert to float, use inf for NaN/None - edhrec_rank = float(edhrec_rank) if pd.notna(edhrec_rank) else float('inf') - - metadata[card_name] = { - "edhrecRank": edhrec_rank, - } - - logger.info(f"Pre-computed metadata for {len(metadata)} cards") - return metadata - - def _build_tag_index(self) -> None: - """ - Build inverted index: tag -> set of card names that have this tag. - - This allows fast candidate filtering - instead of checking all 29k cards, - we only check cards that share at least one tag with the target. - - Performance impact: Reduces 29k comparisons to typically 100-2000 comparisons. - """ - logger.info("Building inverted tag index...") - index = {} - - for card_name, tags in self.cleaned_tags_cache.items(): - for tag in tags: - if tag not in index: - index[tag] = set() - index[tag].add(card_name) - - self._tag_to_cards_index = index - - # Log statistics - avg_cards_per_tag = sum(len(cards) for cards in index.values()) / len(index) if index else 0 - logger.info( - f"Built tag index: {len(index)} unique tags, " - f"avg {avg_cards_per_tag:.1f} cards per tag" - ) - - def get_signature_tags( - self, - card_tags: set[str], - top_n: int = 5, - random_n: Optional[int] = None, - seed: Optional[int] = None, - ) -> set[str]: - """ - Get signature tags for similarity comparison. - - Takes the most frequent (popular) tags PLUS random tags for diversity. - This balances defining characteristics with discovery of niche synergies. - - Excludes overly common tags like "Historics Matter" and "Legends Matter" - that appear on most legendary cards and don't provide meaningful similarity. - - Args: - card_tags: Full set of card theme tags - top_n: Number of most frequent tags to use (default 5) - random_n: Number of random tags to add. If None, auto-scales: - - 6-10 tags: 1 random - - 11-15 tags: 2 random - - 16+ tags: 3 random - seed: Random seed for reproducibility (default: None) - - Returns: - Set of signature tags (top_n most frequent + random_n random) - """ - # Exclude overly common tags that don't provide meaningful similarity - excluded_tags = {"Historics Matter", "Legends Matter"} - card_tags = card_tags - excluded_tags - - if len(card_tags) <= top_n: - return card_tags # Use all if card has few tags - - # Auto-scale random_n based on total tag count if not specified - if random_n is None: - tag_count = len(card_tags) - if tag_count >= 16: - random_n = 3 - elif tag_count >= 11: - random_n = 2 - elif tag_count >= 6: - random_n = 1 - else: - random_n = 0 # Very few tags, no random needed - - # Sort tags by frequency (higher card_count = more common = higher priority) - sorted_tags = sorted( - card_tags, - key=lambda t: -self.theme_frequencies.get(t, 0), # Negate for descending order - ) - - # Take top N most frequent tags - signature = set(sorted_tags[:top_n]) - - # Add random tags from remaining tags - remaining_tags = card_tags - signature - if remaining_tags and random_n > 0: - if seed is not None: - random.seed(seed) - - # Sample min(random_n, len(remaining_tags)) to avoid errors - sample_size = min(random_n, len(remaining_tags)) - random_tags = set(random.sample(list(remaining_tags), sample_size)) - - signature = signature | random_tags - - return signature - - @staticmethod - def parse_theme_tags(tags: str | list) -> set[str]: - """ - Parse theme tags from string or list format. - - Args: - tags: Theme tags as string representation of list or actual list - - Returns: - Set of theme tag strings - """ - # M4: Handle both scalar NA (CSV) and array values (Parquet) - if pd.isna(tags) if isinstance(tags, (str, float, int, type(None))) else False: - return set() - - # M4: Handle numpy arrays from Parquet files - if hasattr(tags, '__len__') and not isinstance(tags, str): - # Parquet format - convert array-like to list - return set(list(tags)) if len(tags) > 0 else set() - - if isinstance(tags, str): - # Handle string representation of list: "['tag1', 'tag2']" - try: - parsed = ast.literal_eval(tags) - if isinstance(parsed, list): - return set(parsed) - return set() - except (ValueError, SyntaxError): - # If parsing fails, return empty set - logger.warning(f"Failed to parse theme tags: {tags[:100]}") - return set() - - return set() - - @staticmethod - def calculate_similarity(tags_a: set[str], tags_b: set[str]) -> float: - """ - Calculate Jaccard similarity between two sets of theme tags. - - Jaccard index = intersection / union - - Args: - tags_a: First set of theme tags - tags_b: Second set of theme tags - - Returns: - Similarity score from 0.0 (no overlap) to 1.0 (identical) - """ - if not tags_a or not tags_b: - return 0.0 - - intersection = len(tags_a & tags_b) - union = len(tags_a | tags_b) - - if union == 0: - return 0.0 - - return intersection / union - - def get_card_tags(self, card_name: str) -> Optional[set[str]]: - """ - Get theme tags for a specific card. - - Args: - card_name: Name of the card - - Returns: - Set of theme tags, or None if card not found - """ - card_row = self.cards_df[self.cards_df["name"] == card_name] - - if card_row.empty: - return None - - tags = card_row.iloc[0]["themeTags"] - return self.parse_theme_tags(tags) - - def find_similar( - self, - card_name: str, - threshold: float = 0.8, - limit: int = 10, - min_results: int = 3, - adaptive: bool = True, - use_cache: bool = True, - ) -> list[dict]: - """ - Find cards with similar theme tags. - - Uses adaptive threshold scaling to ensure minimum number of results. - Tries 80% → 60% thresholds until min_results is met (skips 70% for performance). - - Checks cache first for pre-computed results, falls back to real-time calculation. - - Args: - card_name: Name of the target card - threshold: Starting similarity threshold (0.0-1.0), default 0.8 (80%) - limit: Maximum number of results, default 10 - min_results: Minimum desired results for adaptive scaling, default 3 - adaptive: Enable adaptive threshold scaling, default True - use_cache: Check cache first before calculating, default True - - Returns: - List of dicts with keys: name, similarity, themeTags, edhrecRank, threshold_used - Sorted by similarity descending, then by EDHREC rank ascending (more popular first) - Returns empty list if card not found or has no tags - """ - # Check cache first - if use_cache and self.cache.enabled: - cached_results = self.cache.get_similar(card_name, limit=limit, randomize=True) - if cached_results is not None: - logger.info(f"Cache HIT for '{card_name}' ({len(cached_results)} results, randomized)") - return cached_results - else: - logger.info(f"Cache MISS for '{card_name}', calculating...") - - # Get target card tags - target_tags = self.get_card_tags(card_name) - - if target_tags is None: - logger.warning(f"Card not found: {card_name}") - return [] - - if not target_tags: - logger.info(f"Card has no theme tags: {card_name}") - return [] - - # Get signature tags for TARGET card only (top 5 most frequent + 1-3 random) - # This focuses the search on the target's defining characteristics - # with some diversity from random tags - - # Use card name hash as seed for reproducible randomness per card - card_seed = hash(card_name) % (2**31) - target_signature = self.get_signature_tags( - target_tags, - top_n=5, - seed=card_seed - ) - - logger.debug( - f"Target '{card_name}': {len(target_tags)} tags → " - f"{len(target_signature)} signature tags" - ) - - # Try adaptive thresholds if enabled - thresholds_to_try = [threshold] - if adaptive: - # Build list of thresholds to try: 80% → 60% → 50% (skip 70% for performance) - thresholds_to_try = [] - if threshold >= 0.8: - thresholds_to_try.append(0.8) - if threshold >= 0.6: - thresholds_to_try.append(0.6) - if threshold >= 0.5: - thresholds_to_try.append(0.5) - - # Remove duplicates and sort descending - thresholds_to_try = sorted(set(thresholds_to_try), reverse=True) - - results = [] - threshold_used = threshold - - for current_threshold in thresholds_to_try: - # Use inverted index for fast candidate filtering - # Instead of checking all 29k cards, only check cards that share at least one signature tag - results = [] - - # Build inverted index on first use (lazily) - if self._tag_to_cards_index is None: - self._build_tag_index() - - # Get candidate cards that share at least one signature tag - # This drastically reduces the number of cards we need to check - candidate_cards = set() - for tag in target_signature: - if tag in self._tag_to_cards_index: - candidate_cards.update(self._tag_to_cards_index[tag]) - - # Remove the target card itself - candidate_cards.discard(card_name) - - if not candidate_cards: - continue # No candidates at all, try lower threshold - - # Now calculate scores only for candidates (vectorized where possible) - # Pre-filter candidates by checking if they meet minimum overlap requirement - min_overlap = int(len(target_signature) * current_threshold) - - for candidate_name in candidate_cards: - candidate_tags = self.cleaned_tags_cache.get(candidate_name) - - if not candidate_tags: - continue - - # Fast overlap check using set intersection - overlap = target_signature & candidate_tags - overlap_count = len(overlap) - - # Quick filter: skip if overlap too small - if overlap_count < min_overlap: - continue - - # Calculate exact containment score - containment_score = overlap_count / len(target_signature) - - if containment_score >= current_threshold: - # Get EDHREC rank efficiently from card metadata - edhrec_rank = self._card_metadata.get(candidate_name, {}).get('edhrecRank', float('inf')) - - results.append({ - "name": candidate_name, - "similarity": containment_score, - "themeTags": list(candidate_tags), - "edhrecRank": edhrec_rank, - }) - - # Sort by similarity descending, then by EDHREC rank ascending (lower is better) - # Unranked cards (inf) will appear last - results.sort(key=lambda x: (-x["similarity"], x["edhrecRank"])) - - # Check if we have enough results - if len(results) >= min_results or not adaptive: - threshold_used = current_threshold - break - - # Log that we're trying a lower threshold - logger.info( - f"Found {len(results)} results at {current_threshold:.0%} " - f"for '{card_name}', trying lower threshold..." - ) - - # Add threshold_used to results - for result in results: - result["threshold_used"] = threshold_used - - logger.info( - f"Found {len(results)} similar cards for '{card_name}' " - f"at {threshold_used:.0%} threshold" - ) - - final_results = results[:limit] - - # Cache the results for future lookups - if use_cache and self.cache.enabled and final_results: - self.cache.set_similar(card_name, final_results) - logger.debug(f"Cached {len(final_results)} results for '{card_name}'") - - return final_results diff --git a/code/web/services/commander_catalog_loader.py b/code/web/services/commander_catalog_loader.py index 8176163..e293e91 100644 --- a/code/web/services/commander_catalog_loader.py +++ b/code/web/services/commander_catalog_loader.py @@ -2,14 +2,14 @@ Responsibilities ================ -- Read and normalize commander data from all_cards.parquet (M4 migration). +- Read and normalize `commander_cards.csv` (shared with the deck builder). - Produce deterministic commander records with rich metadata (slug, colors, partner/background flags, theme tags, Scryfall image URLs). - Cache the parsed catalog and invalidate on file timestamp changes. -M4: Updated to load from all_cards.parquet instead of commander_cards.csv. -The loader uses pandas to filter commanders (isCommander == True) from the -unified Parquet data source. +The loader operates without pandas to keep the web layer light-weight and to +simplify unit testing. It honors the `CSV_FILES_DIR` environment variable via +`path_util.csv_dir()` just like the CLI builder. """ from __future__ import annotations @@ -18,10 +18,12 @@ from dataclasses import dataclass from pathlib import Path from typing import Dict, Iterable, List, Mapping, Optional, Tuple import ast +import csv import os import re from urllib.parse import quote +from path_util import csv_dir from deck_builder.partner_background_utils import analyze_partner_background __all__ = [ @@ -202,11 +204,9 @@ def find_commander_record(name: str | None) -> CommanderRecord | None: def _resolve_commander_path(source_path: str | os.PathLike[str] | None) -> Path: - """M4: Resolve Parquet path instead of commander_cards.csv.""" if source_path is not None: return Path(source_path).resolve() - from path_util import get_processed_cards_path - return Path(get_processed_cards_path()).resolve() + return (Path(csv_dir()) / "commander_cards.csv").resolve() def _is_cache_valid(path: Path, cached: CommanderCatalog) -> bool: @@ -221,31 +221,24 @@ def _is_cache_valid(path: Path, cached: CommanderCatalog) -> bool: def _build_catalog(path: Path) -> CommanderCatalog: - """M4: Load commanders from Parquet instead of CSV.""" if not path.exists(): - raise FileNotFoundError(f"Commander Parquet not found at {path}") + raise FileNotFoundError(f"Commander CSV not found at {path}") entries: List[CommanderRecord] = [] used_slugs: set[str] = set() - # Load commanders from Parquet (isCommander == True) - from deck_builder import builder_utils as bu - df = bu._load_all_cards_parquet() - if df.empty or 'isCommander' not in df.columns: - raise ValueError("Parquet missing isCommander column") - - commanders_df = df[df['isCommander']].copy() + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + if reader.fieldnames is None: + raise ValueError("Commander CSV missing header row") - # Convert DataFrame rows to CommanderRecords - for _, row in commanders_df.iterrows(): - try: - # Convert row to dict for _row_to_record - row_dict = row.to_dict() - record = _row_to_record(row_dict, used_slugs) - except Exception: - continue - entries.append(record) - used_slugs.add(record.slug) + for index, row in enumerate(reader): + try: + record = _row_to_record(row, used_slugs) + except Exception: + continue + entries.append(record) + used_slugs.add(record.slug) stat_result = path.stat() mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000)) diff --git a/code/web/services/multi_build_orchestrator.py b/code/web/services/multi_build_orchestrator.py deleted file mode 100644 index 65fcf1b..0000000 --- a/code/web/services/multi_build_orchestrator.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -Multi-Build Orchestrator - Parallel execution of identical deck builds. - -Runs the same deck configuration N times in parallel to analyze variance. -""" - -from __future__ import annotations -from typing import Any, Dict -from concurrent.futures import ThreadPoolExecutor -from .build_cache import BuildCache -from .tasks import get_session -from ..services import orchestrator as orch -from code.logging_util import get_logger - -logger = get_logger(__name__) - - -class MultiBuildOrchestrator: - """Manages parallel execution of multiple identical deck builds.""" - - def __init__(self, max_parallel: int = 5): - """ - Initialize orchestrator. - - Args: - max_parallel: Maximum number of builds to run concurrently (default 5) - """ - self.max_parallel = max_parallel - - def run_batch_parallel(self, batch_id: str, sid: str) -> None: - """ - Run a batch of builds in parallel (blocking call). - - This should be called from a background task. - - Args: - batch_id: Batch identifier - sid: Session ID - """ - logger.info(f"[Multi-Build] Starting parallel batch {batch_id} for session {sid}") - - sess = get_session(sid) - batch_status = BuildCache.get_batch_status(sess, batch_id) - - if not batch_status: - logger.error(f"[Multi-Build] Batch {batch_id} not found in session") - return - - count = batch_status["count"] - config = BuildCache.get_batch_config(sess, batch_id) - - if not config: - logger.error(f"[Multi-Build] Config not found for batch {batch_id}") - return - - logger.info(f"[Multi-Build] Running {count} builds in parallel (max {self.max_parallel} concurrent)") - - # Use ThreadPoolExecutor for parallel execution - # Each build runs in its own thread to avoid blocking - with ThreadPoolExecutor(max_workers=min(count, self.max_parallel)) as executor: - futures = [] - - for i in range(count): - future = executor.submit(self._run_single_build, batch_id, i, config, sid) - futures.append(future) - - # Wait for all builds to complete - for i, future in enumerate(futures): - try: - future.result() # This will raise if the build failed - logger.info(f"[Multi-Build] Build {i+1}/{count} completed successfully") - except Exception as e: - logger.error(f"[Multi-Build] Build {i+1}/{count} failed: {e}") - # Error already stored in _run_single_build - - logger.info(f"[Multi-Build] Batch {batch_id} completed") - - def _run_single_build(self, batch_id: str, build_index: int, config: Dict[str, Any], sid: str) -> None: - """ - Run a single build and store the result. - - Args: - batch_id: Batch identifier - build_index: Index of this build (0-based) - config: Deck configuration - sid: Session ID - """ - try: - logger.info(f"[Multi-Build] Build {build_index}: Starting for batch {batch_id}") - - # Get a fresh session reference for this thread - sess = get_session(sid) - - logger.debug(f"[Multi-Build] Build {build_index}: Creating build context") - - # Create a temporary build context for this specific build - # We need to ensure each build has isolated state - build_ctx = self._create_build_context(config, sess, build_index) - - logger.debug(f"[Multi-Build] Build {build_index}: Running all stages") - - # Run all stages to completion - result = self._run_all_stages(build_ctx, build_index) - - logger.debug(f"[Multi-Build] Build {build_index}: Storing result") - - # Store the result - BuildCache.store_build(sess, batch_id, build_index, result) - - logger.info(f"[Multi-Build] Build {build_index}: Completed, stored in batch {batch_id}") - - except Exception as e: - logger.exception(f"[Multi-Build] Build {build_index}: Error - {e}") - sess = get_session(sid) - BuildCache.store_build_error(sess, batch_id, build_index, str(e)) - - def _create_build_context(self, config: Dict[str, Any], sess: Dict[str, Any], build_index: int) -> Dict[str, Any]: - """ - Create a build context from configuration. - - Args: - config: Deck configuration - sess: Session dictionary - build_index: Index of this build - - Returns: - Build context dict ready for orchestrator - """ - # Import here to avoid circular dependencies - from .build_utils import start_ctx_from_session - - # Create a temporary session-like dict with the config - temp_sess = { - "commander": config.get("commander"), - "tags": config.get("tags", []), - "tag_mode": config.get("tag_mode", "AND"), - "bracket": config.get("bracket", 3), - "ideals": config.get("ideals", {}), - "prefer_combos": config.get("prefer_combos", False), - "combo_target_count": config.get("combo_target_count"), - "combo_balance": config.get("combo_balance"), - "multi_copy": config.get("multi_copy"), - "use_owned_only": config.get("use_owned_only", False), - "prefer_owned": config.get("prefer_owned", False), - "swap_mdfc_basics": config.get("swap_mdfc_basics", False), - "include_cards": config.get("include_cards", []), - "exclude_cards": config.get("exclude_cards", []), - "enforcement_mode": config.get("enforcement_mode", "warn"), - "allow_illegal": config.get("allow_illegal", False), - "fuzzy_matching": config.get("fuzzy_matching", True), - "locks": set(config.get("locks", [])), - "replace_mode": True, - # Add build index to context for debugging - "batch_build_index": build_index - } - - # Handle partner mechanics if present - if config.get("partner_enabled"): - temp_sess["partner_enabled"] = True - if config.get("secondary_commander"): - temp_sess["secondary_commander"] = config["secondary_commander"] - if config.get("background"): - temp_sess["background"] = config["background"] - if config.get("partner_mode"): - temp_sess["partner_mode"] = config["partner_mode"] - if config.get("combined_commander"): - temp_sess["combined_commander"] = config["combined_commander"] - - # Generate build context using existing utility - ctx = start_ctx_from_session(temp_sess) - - return ctx - - def _run_all_stages(self, ctx: Dict[str, Any], build_index: int = 0) -> Dict[str, Any]: - """ - Run all build stages to completion. - - Args: - ctx: Build context - build_index: Index of this build for logging - - Returns: - Final result dict from orchestrator - """ - stages = ctx.get("stages", []) - result = None - - logger.debug(f"[Multi-Build] Build {build_index}: Starting stage loop ({len(stages)} stages)") - - iteration = 0 - max_iterations = 100 # Safety limit to prevent infinite loops - - while iteration < max_iterations: - current_idx = ctx.get("idx", 0) - if current_idx >= len(stages): - logger.debug(f"[Multi-Build] Build {build_index}: All stages completed (idx={current_idx}/{len(stages)})") - break - - stage_name = stages[current_idx].get("name", f"Stage {current_idx}") if current_idx < len(stages) else "Unknown" - logger.debug(f"[Multi-Build] Build {build_index}: Running stage {current_idx}/{len(stages)}: {stage_name}") - - # Run stage with show_skipped=False for clean output - result = orch.run_stage(ctx, rerun=False, show_skipped=False) - - # Check if build is done - if result.get("done"): - logger.debug(f"[Multi-Build] Build {build_index}: Build marked as done after stage {stage_name}") - break - - iteration += 1 - - if iteration >= max_iterations: - logger.warning(f"[Multi-Build] Build {build_index}: Hit max iterations ({max_iterations}), possible infinite loop. Last stage: {stage_name}") - - logger.debug(f"[Multi-Build] Build {build_index}: Stage loop completed after {iteration} iterations") - return result or {} - - -# Global orchestrator instance -_orchestrator = MultiBuildOrchestrator(max_parallel=5) - - -def queue_builds(config: Dict[str, Any], count: int, sid: str) -> str: - """ - Queue a batch of builds for parallel execution. - - Args: - config: Deck configuration - count: Number of builds to run - sid: Session ID - - Returns: - batch_id: Unique identifier for this batch - """ - sess = get_session(sid) - batch_id = BuildCache.create_batch(sess, config, count) - return batch_id - - -def run_batch_async(batch_id: str, sid: str) -> None: - """ - Run a batch of builds in parallel (blocking call for background task). - - Args: - batch_id: Batch identifier - sid: Session ID - """ - _orchestrator.run_batch_parallel(batch_id, sid) - - -def get_batch_status(batch_id: str, sid: str) -> Dict[str, Any]: - """ - Get current status of a batch build. - - Args: - batch_id: Batch identifier - sid: Session ID - - Returns: - Status dict with progress info - """ - sess = get_session(sid) - status = BuildCache.get_batch_status(sess, batch_id) - return status or {"error": "Batch not found"} diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 654d5ac..2179178 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -18,12 +18,6 @@ from pathlib import Path from deck_builder.partner_selection import apply_partner_inputs from exceptions import CommanderPartnerError -# M7: Cache for commander DataFrame to avoid repeated Parquet loads -_COMMANDER_DF_CACHE: Dict[str, Any] = {"df": None, "mtime": None} - -# M7: Cache for past builds summary to avoid repeated file scans -_PAST_BUILDS_CACHE: Dict[str, Any] = {"index": None, "mtime": None} - _TAG_ACRONYM_KEEP = {"EDH", "ETB", "ETBs", "CMC", "ET", "OTK"} _REASON_SOURCE_OVERRIDES = { "creature_all_theme": "Theme Match", @@ -159,44 +153,40 @@ def _display_tags_from_entry(entry: Dict[str, Any]) -> List[str]: def _run_theme_metadata_enrichment(out_func=None) -> None: """Run full metadata enrichment sequence after theme catalog/YAML generation. - Uses consolidated ThemeEnrichmentPipeline for 5-10x faster processing. - Idempotent: safe to re-run; errors are swallowed (logged) to avoid + Idempotent: each script is safe to re-run; errors are swallowed (logged) to avoid impacting primary setup/tagging pipeline. Designed to centralize logic so both manual refresh (routes/themes.py) and automatic setup flows invoke identical steps. """ try: import os - from pathlib import Path - from code.tagging.theme_enrichment import run_enrichment_pipeline - - root = Path(__file__).resolve().parents[3] - min_examples = int(os.environ.get('EDITORIAL_MIN_EXAMPLES', '5')) - + import sys + import subprocess + root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) + scripts_dir = os.path.join(root, 'code', 'scripts') + py = sys.executable + steps: List[List[str]] = [ + [py, os.path.join(scripts_dir, 'autofill_min_examples.py')], + [py, os.path.join(scripts_dir, 'pad_min_examples.py'), '--min', os.environ.get('EDITORIAL_MIN_EXAMPLES', '5')], + [py, os.path.join(scripts_dir, 'cleanup_placeholder_examples.py'), '--apply'], + [py, os.path.join(scripts_dir, 'purge_anchor_placeholders.py'), '--apply'], + # Augment YAML with description / popularity buckets from the freshly built catalog + [py, os.path.join(scripts_dir, 'augment_theme_yaml_from_catalog.py')], + [py, os.path.join(scripts_dir, 'generate_theme_editorial_suggestions.py'), '--apply', '--limit-yaml', '0'], + [py, os.path.join(scripts_dir, 'lint_theme_editorial.py')], # non-strict lint pass + ] def _emit(msg: str): try: if out_func: out_func(msg) except Exception: pass - - # Run consolidated pipeline instead of 7 separate subprocess scripts - stats = run_enrichment_pipeline( - root=root, - min_examples=min_examples, - write=True, - enforce_min=False, # Non-strict lint pass - strict=False, - progress_callback=_emit, - ) - - _emit(f"Theme enrichment complete: {stats.total_themes} themes processed") - - except Exception as e: - if out_func: + for cmd in steps: try: - out_func(f"[metadata_enrich] pipeline failed: {e}") - except Exception: - pass + subprocess.run(cmd, check=True) + except Exception as e: + _emit(f"[metadata_enrich] step failed ({os.path.basename(cmd[1]) if len(cmd)>1 else cmd}): {e}") + continue + except Exception: return @@ -230,18 +220,10 @@ def _maybe_refresh_partner_synergy(out_func=None, *, force: bool = False, root: if not needs_refresh: source_times: list[float] = [] - # M4: Check all_cards.parquet instead of commander_cards.csv - try: - from path_util import get_processed_cards_path - parquet_path = Path(get_processed_cards_path()) - candidates = [ - root_path / "config" / "themes" / "theme_list.json", - parquet_path, - ] - except Exception: - candidates = [ - root_path / "config" / "themes" / "theme_list.json", - ] + candidates = [ + root_path / "config" / "themes" / "theme_list.json", + root_path / "csv_files" / "commander_cards.csv", + ] for candidate in candidates: try: if candidate.exists(): @@ -359,7 +341,7 @@ def _global_prune_disallowed_pool(b: DeckBuilder) -> None: drop_idx = tags_series.apply(lambda lst, nd=needles: _has_any(lst, nd)) mask_keep = [mk and (not di) for mk, di in zip(mask_keep, drop_idx.tolist())] try: - import pandas as _pd + import pandas as _pd # type: ignore mask_keep = _pd.Series(mask_keep, index=work.index) except Exception: pass @@ -453,9 +435,8 @@ def _attach_enforcement_plan(b: DeckBuilder, comp: Dict[str, Any] | None) -> Dic def commander_names() -> List[str]: - df = _get_cached_commander_df() - if df is None: - return [] + tmp = DeckBuilder() + df = tmp.load_commander_data() return df["name"].astype(str).tolist() @@ -480,15 +461,13 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li tmp = DeckBuilder() try: if hasattr(tmp, '_normalize_commander_query'): - query = tmp._normalize_commander_query(query) + query = tmp._normalize_commander_query(query) # type: ignore[attr-defined] else: # Light fallback: basic title case query = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(query).split(' ')]) except Exception: pass - df = _get_cached_commander_df() - if df is None: - return [] + df = tmp.load_commander_data() # Filter to plausible commanders: Legendary Creature, or text explicitly allows being a commander. try: cols = set(df.columns.astype(str)) @@ -542,7 +521,10 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li except Exception: pass # Attach color identity for each candidate - df = _get_cached_commander_df() + try: + df = tmp.load_commander_data() + except Exception: + df = None q = (query or "").strip().lower() qn = _simplify(query) tokens = [t for t in re.split(r"[\s,]+", q) if t] @@ -633,9 +615,7 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li def commander_inspect(name: str) -> Dict[str, Any]: tmp = DeckBuilder() - df = _get_cached_commander_df() - if df is None: - return {"ok": False, "error": "Commander data not available"} + df = tmp.load_commander_data() row = df[df["name"] == name] if row.empty: return {"ok": False, "error": "Commander not found"} @@ -645,15 +625,13 @@ def commander_inspect(name: str) -> Dict[str, Any]: def commander_select(name: str) -> Dict[str, Any]: tmp = DeckBuilder() - df = _get_cached_commander_df() - if df is None: - return {"ok": False, "error": "Commander data not available"} + df = tmp.load_commander_data() # Try exact match, then normalized match row = df[df["name"] == name] if row.empty: try: if hasattr(tmp, '_normalize_commander_query'): - name2 = tmp._normalize_commander_query(name) + name2 = tmp._normalize_commander_query(name) # type: ignore[attr-defined] else: name2 = ' '.join([w[:1].upper() + w[1:].lower() if w else w for w in str(name).split(' ')]) row = df[df["name"] == name2] @@ -671,125 +649,15 @@ def commander_select(name: str) -> Dict[str, Any]: } -def _get_cached_commander_df(): - """M7: Return cached commander DataFrame, loading only if needed or stale.""" - global _COMMANDER_DF_CACHE - - # Check if we need to reload (cache miss or file changed) - need_reload = _COMMANDER_DF_CACHE["df"] is None - - if not need_reload: - # Check if the commander Parquet file has been modified since we cached it - try: - from path_util import get_commander_cards_path - commander_path = get_commander_cards_path() - if os.path.exists(commander_path): - current_mtime = os.path.getmtime(commander_path) - cached_mtime = _COMMANDER_DF_CACHE.get("mtime") - if cached_mtime is None or current_mtime > cached_mtime: - need_reload = True - else: - # If dedicated file doesn't exist, force reload to use fallback - need_reload = True - except Exception: - # If we can't check mtime, just use the cache if we have it - pass - - if need_reload: - try: - tmp = DeckBuilder() - df = tmp.load_commander_data() - from path_util import get_commander_cards_path - commander_path = get_commander_cards_path() - _COMMANDER_DF_CACHE["df"] = df - if os.path.exists(commander_path): - _COMMANDER_DF_CACHE["mtime"] = os.path.getmtime(commander_path) - else: - # No dedicated file - set mtime to None so we don't cache stale data - _COMMANDER_DF_CACHE["mtime"] = None - except Exception: - # Fall back to empty cache on error - _COMMANDER_DF_CACHE["df"] = None - _COMMANDER_DF_CACHE["mtime"] = None - - return _COMMANDER_DF_CACHE["df"] - - -def _get_past_builds_index() -> Dict[str, List[Dict[str, Any]]]: - """M7: Return cached index of past builds: commander_name -> list of {tags, age_days}.""" - global _PAST_BUILDS_CACHE - - deck_files_dir = 'deck_files' - need_rebuild = _PAST_BUILDS_CACHE["index"] is None - - if not need_rebuild: - # Check if deck_files directory has changed - try: - if os.path.exists(deck_files_dir): - current_mtime = os.path.getmtime(deck_files_dir) - cached_mtime = _PAST_BUILDS_CACHE.get("mtime") - if cached_mtime is None or current_mtime > cached_mtime: - need_rebuild = True - except Exception: - pass - - if need_rebuild: - index: Dict[str, List[Dict[str, Any]]] = {} - try: - for path in glob(os.path.join(deck_files_dir, '*.summary.json')): - try: - st = os.stat(path) - age_days = max(0, (time.time() - st.st_mtime) / 86400.0) - with open(path, 'r', encoding='utf-8') as f: - data = json.load(f) or {} - meta = data.get('meta') or {} - commander = str(meta.get('commander', '')).strip() - if not commander: - continue - tags_list = meta.get('tags') or [] - if not tags_list: - continue - - if commander not in index: - index[commander] = [] - index[commander].append({ - 'tags': tags_list, - 'age_days': age_days - }) - except Exception: - continue - - _PAST_BUILDS_CACHE["index"] = index - if os.path.exists(deck_files_dir): - _PAST_BUILDS_CACHE["mtime"] = os.path.getmtime(deck_files_dir) - except Exception: - _PAST_BUILDS_CACHE["index"] = {} - _PAST_BUILDS_CACHE["mtime"] = None - - return _PAST_BUILDS_CACHE["index"] or {} - - -def invalidate_past_builds_cache(): - """M7: Force rebuild of past builds cache on next access (call after saving new builds).""" - global _PAST_BUILDS_CACHE - _PAST_BUILDS_CACHE["index"] = None - _PAST_BUILDS_CACHE["mtime"] = None - - def tags_for_commander(name: str) -> List[str]: - df = _get_cached_commander_df() - if df is None: - return [] + tmp = DeckBuilder() + df = tmp.load_commander_data() row = df[df["name"] == name] if row.empty: return [] raw = row.iloc[0].get("themeTags", []) - # Handle both list and NumPy array types from Parquet - if isinstance(raw, (list, tuple)) or hasattr(raw, '__iter__') and not isinstance(raw, str): - try: - return list(dict.fromkeys([str(t).strip() for t in raw if str(t).strip()])) - except Exception: - pass + if isinstance(raw, list): + return list(dict.fromkeys([str(t).strip() for t in raw if str(t).strip()])) if isinstance(raw, str) and raw.strip(): parts = [p.strip().strip("'\"") for p in raw.split(',')] return [p for p in parts if p] @@ -827,8 +695,11 @@ def _recommended_scored(name: str, max_items: int = 5) -> List[Tuple[str, int, L except Exception: return None return None - # M7: Use cached DataFrame instead of loading again - df = _get_cached_commander_df() + try: + tmp = DeckBuilder() + df = tmp.load_commander_data() + except Exception: + df = None # Gather commander text and colors text = "" colors: List[str] = [] @@ -932,28 +803,35 @@ def _recommended_scored(name: str, max_items: int = 5) -> List[Tuple[str, int, L if len(reasons[orig]) < 3 and cr not in reasons[orig]: reasons[orig].append(cr) - # Past builds history - M7: Use cached index instead of scanning files + # Past builds history try: - past_builds_index = _get_past_builds_index() - builds_for_commander = past_builds_index.get(str(name).strip(), []) - for build in builds_for_commander: - age_days = build.get('age_days', 999) - tags_list = build.get('tags', []) - for tg in tags_list: - tn = _norm(str(tg)) - if tn in available_norm: - orig = norm_map[tn] - inc = 2 - recent = False - if age_days <= 30: - inc += 2 - recent = True - elif age_days <= 90: - inc += 1 - score[orig] = score.get(orig, 0) + inc - lbl = "Popular in your past builds" + (" (recent)" if recent else "") - if len(reasons[orig]) < 3 and lbl not in reasons[orig]: - reasons[orig].append(lbl) + for path in glob(os.path.join('deck_files', '*.summary.json')): + try: + st = os.stat(path) + age_days = max(0, (time.time() - st.st_mtime) / 86400.0) + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) or {} + meta = data.get('meta') or {} + if str(meta.get('commander', '')).strip() != str(name).strip(): + continue + tags_list = meta.get('tags') or [] + for tg in tags_list: + tn = _norm(str(tg)) + if tn in available_norm: + orig = norm_map[tn] + inc = 2 + recent = False + if age_days <= 30: + inc += 2 + recent = True + elif age_days <= 90: + inc += 1 + score[orig] = score.get(orig, 0) + inc + lbl = "Popular in your past builds" + (" (recent)" if recent else "") + if len(reasons[orig]) < 3 and lbl not in reasons[orig]: + reasons[orig].append(lbl) + except Exception: + continue except Exception: pass @@ -1037,16 +915,14 @@ def _is_truthy_env(name: str, default: str = '1') -> bool: def is_setup_ready() -> bool: """Fast readiness check: required files present and tagging completed. - M4: Updated to check for all_cards.parquet instead of cards.csv. - We consider the system ready if card_files/processed/all_cards.parquet exists and the + We consider the system ready if csv_files/cards.csv exists and the .tagging_complete.json flag exists. Freshness (mtime) is enforced only during auto-refresh inside _ensure_setup_ready, not here. """ try: - from path_util import get_processed_cards_path - parquet_path = get_processed_cards_path() + cards_path = os.path.join('csv_files', 'cards.csv') flag_path = os.path.join('csv_files', '.tagging_complete.json') - return os.path.exists(parquet_path) and os.path.exists(flag_path) + return os.path.exists(cards_path) and os.path.exists(flag_path) except Exception: return False @@ -1103,25 +979,20 @@ def is_setup_stale() -> bool: except Exception: pass - # Fallback: compare all_cards.parquet mtime (M4 update) - try: - from path_util import get_processed_cards_path - parquet_path = get_processed_cards_path() - if not os.path.exists(parquet_path): - return False - age_seconds = time.time() - os.path.getmtime(parquet_path) - return age_seconds > refresh_age_seconds - except Exception: + # Fallback: compare cards.csv mtime + cards_path = os.path.join('csv_files', 'cards.csv') + if not os.path.exists(cards_path): return False + age_seconds = time.time() - os.path.getmtime(cards_path) + return age_seconds > refresh_age_seconds except Exception: return False def _ensure_setup_ready(out, force: bool = False) -> None: - """Ensure card data exists and tagging has completed; bootstrap if needed. + """Ensure card CSVs exist and tagging has completed; bootstrap if needed. - M4: Updated to check for all_cards.parquet instead of cards.csv. - Mirrors the CLI behavior used in build_deck_full: if the Parquet file is + Mirrors the CLI behavior used in build_deck_full: if csv_files/cards.csv is missing, too old, or the tagging flag is absent, run initial setup and tagging. """ # Track whether a theme catalog export actually executed during this invocation @@ -1273,13 +1144,6 @@ def _ensure_setup_ready(out, force: bool = False) -> None: # Run metadata enrichment (best-effort) after export sequence. try: _run_theme_metadata_enrichment(out_func) - # Rebuild theme_list.json to pick up newly generated example_cards/commanders - # from the enrichment pipeline (which populates them from CSV data) - if use_merge and os.path.exists(build_script): - args = [_sys.executable, build_script] - if force: - args.append('--force') - _run(args, check=True) except Exception: pass try: @@ -1288,8 +1152,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None: pass # Bust theme-related in-memory caches so new catalog reflects immediately try: - from .theme_catalog_loader import bust_filter_cache - from .theme_preview import bust_preview_cache + from .theme_catalog_loader import bust_filter_cache # type: ignore + from .theme_preview import bust_preview_cache # type: ignore bust_filter_cache("catalog_refresh") bust_preview_cache("catalog_refresh") try: @@ -1326,9 +1190,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None: pass try: - # M4 (Parquet Migration): Check for processed Parquet file instead of CSV - from path_util import get_processed_cards_path - cards_path = get_processed_cards_path() + cards_path = os.path.join('csv_files', 'cards.csv') flag_path = os.path.join('csv_files', '.tagging_complete.json') auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1') # Allow tuning of time-based refresh; default 7 days @@ -1342,14 +1204,14 @@ def _ensure_setup_ready(out, force: bool = False) -> None: _write_status({"running": True, "phase": "setup", "message": "Forcing full setup and tagging...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0}) if not os.path.exists(cards_path): - out(f"Processed Parquet not found ({cards_path}). Running initial setup and tagging...") + out("cards.csv not found. Running initial setup and tagging...") _write_status({"running": True, "phase": "setup", "message": "Preparing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0}) refresh_needed = True else: try: age_seconds = time.time() - os.path.getmtime(cards_path) if age_seconds > refresh_age_seconds and not force: - out(f"Processed Parquet is older than {days} days. Refreshing data (setup + tagging)...") + out("cards.csv is older than 7 days. Refreshing data (setup + tagging)...") _write_status({"running": True, "phase": "setup", "message": "Refreshing card database (initial setup)...", "started_at": _dt.now().isoformat(timespec='seconds'), "percent": 0}) refresh_needed = True except Exception: @@ -1366,146 +1228,108 @@ def _ensure_setup_ready(out, force: bool = False) -> None: out("Setup/tagging required, but WEB_AUTO_SETUP=0. Please run Setup from the UI.") _write_status({"running": False, "phase": "requires_setup", "message": "Setup required (auto disabled)."}) return - - # Try downloading pre-tagged data from GitHub first (faster than local build) try: - import urllib.request - import urllib.error - out("[SETUP] Attempting to download pre-tagged data from GitHub...") - _write_status({"running": True, "phase": "download", "message": "Downloading pre-tagged data from GitHub...", "percent": 5}) - - base_url = "https://raw.githubusercontent.com/mwisnowski/mtg_python_deckbuilder/similarity-cache-data" - files_to_download = [ - ("card_files/processed/all_cards.parquet", "card_files/processed/all_cards.parquet"), - ("card_files/processed/.tagging_complete.json", "card_files/processed/.tagging_complete.json"), - ("card_files/similarity_cache.parquet", "card_files/similarity_cache.parquet"), - ("card_files/similarity_cache_metadata.json", "card_files/similarity_cache_metadata.json"), - ] - - download_success = True - for remote_path, local_path in files_to_download: - try: - remote_url = f"{base_url}/{remote_path}" - os.makedirs(os.path.dirname(local_path), exist_ok=True) - urllib.request.urlretrieve(remote_url, local_path) - out(f"[SETUP] Downloaded: {local_path}") - except urllib.error.HTTPError as e: - if e.code == 404: - out(f"[SETUP] File not available on GitHub (404): {remote_path}") - download_success = False - break - raise - - if download_success: - out("[SETUP] ✓ Successfully downloaded pre-tagged data from GitHub. Skipping local setup/tagging.") - _write_status({ - "running": False, - "phase": "done", - "message": "Setup complete (downloaded from GitHub)", - "percent": 100, - "finished_at": _dt.now().isoformat(timespec='seconds') - }) - # Refresh theme catalog after successful download - _refresh_theme_catalog(out, force=False, fast_path=True) - return - else: - out("[SETUP] GitHub download incomplete. Falling back to local setup/tagging...") - _write_status({"running": True, "phase": "setup", "message": "GitHub download failed, running local setup...", "percent": 0}) - except Exception as e: - out(f"[SETUP] GitHub download failed ({e}). Falling back to local setup/tagging...") - _write_status({"running": True, "phase": "setup", "message": "GitHub download failed, running local setup...", "percent": 0}) - - try: - from file_setup.setup import initial_setup + from file_setup.setup import initial_setup # type: ignore # Always run initial_setup when forced or when cards are missing/stale initial_setup() except Exception as e: out(f"Initial setup failed: {e}") _write_status({"running": False, "phase": "error", "message": f"Initial setup failed: {e}"}) return - # M4 (Parquet Migration): Use unified run_tagging with parallel support + # Tagging with progress; support parallel workers for speed try: - from tagging import tagger as _tagger + from tagging import tagger as _tagger # type: ignore + from settings import COLORS as _COLORS # type: ignore + colors = list(_COLORS) + total = len(colors) use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"} max_workers_env = os.getenv('WEB_TAG_WORKERS') try: max_workers = int(max_workers_env) if max_workers_env else None except Exception: max_workers = None - - mode_label = "parallel" if use_parallel else "sequential" _write_status({ "running": True, "phase": "tagging", - "message": f"Tagging all cards ({mode_label} mode)...", - "percent": 10, + "message": "Tagging cards (this may take a while)..." if not use_parallel else "Tagging cards in parallel...", + "color": None, + "percent": 0, + "color_idx": 0, + "color_total": total, "tagging_started_at": _dt.now().isoformat(timespec='seconds') }) - - out(f"Starting unified tagging ({mode_label} mode)...") - _tagger.run_tagging(parallel=use_parallel, max_workers=max_workers) - - _write_status({ - "running": True, - "phase": "tagging", - "message": f"Tagging complete ({mode_label} mode)", - "percent": 90, - }) - out(f"✓ Tagging complete ({mode_label} mode)") - + + if use_parallel: + try: + import concurrent.futures as _f + completed = 0 + with _f.ProcessPoolExecutor(max_workers=max_workers) as ex: + fut_map = {ex.submit(_tagger.load_dataframe, c): c for c in colors} + for fut in _f.as_completed(fut_map): + c = fut_map[fut] + try: + fut.result() + completed += 1 + pct = int(completed * 100 / max(1, total)) + _write_status({ + "running": True, + "phase": "tagging", + "message": f"Tagged {c}", + "color": c, + "percent": pct, + "color_idx": completed, + "color_total": total, + }) + except Exception as e: + out(f"Parallel tagging failed for {c}: {e}") + _write_status({"running": False, "phase": "error", "message": f"Tagging {c} failed: {e}", "color": c}) + return + except Exception as e: + out(f"Parallel tagging init failed: {e}; falling back to sequential") + use_parallel = False + + if not use_parallel: + for idx, _color in enumerate(colors, start=1): + try: + pct = int((idx - 1) * 100 / max(1, total)) + # Estimate ETA based on average time per completed color + eta_s = None + try: + from datetime import datetime as __dt + ts = __dt.fromisoformat(json.load(open(os.path.join('csv_files', '.setup_status.json'), 'r', encoding='utf-8')).get('tagging_started_at')) # type: ignore + elapsed = max(0.0, (_dt.now() - ts).total_seconds()) + completed = max(0, idx - 1) + if completed > 0: + avg = elapsed / completed + remaining = max(0, total - completed) + eta_s = int(avg * remaining) + except Exception: + eta_s = None + payload = { + "running": True, + "phase": "tagging", + "message": f"Tagging {_color}...", + "color": _color, + "percent": pct, + "color_idx": idx, + "color_total": total, + } + if eta_s is not None: + payload["eta_seconds"] = eta_s + _write_status(payload) + _tagger.load_dataframe(_color) + except Exception as e: + out(f"Tagging {_color} failed: {e}") + _write_status({"running": False, "phase": "error", "message": f"Tagging {_color} failed: {e}", "color": _color}) + return except Exception as e: - out(f"Tagging failed: {e}") - _write_status({"running": False, "phase": "error", "message": f"Tagging failed: {e}"}) + out(f"Tagging failed to start: {e}") + _write_status({"running": False, "phase": "error", "message": f"Tagging failed to start: {e}"}) return try: os.makedirs('csv_files', exist_ok=True) with open(flag_path, 'w', encoding='utf-8') as _fh: json.dump({'tagged_at': _dt.now().isoformat(timespec='seconds')}, _fh) - - # Aggregate card files into Parquet AFTER tagging completes - try: - _write_status({"running": True, "phase": "aggregating", "message": "Consolidating card data...", "percent": 90}) - out("Aggregating card CSVs into Parquet files...") - from file_setup.card_aggregator import CardAggregator - aggregator = CardAggregator() - - # Aggregate all_cards.parquet - stats = aggregator.aggregate_all('csv_files', 'card_files/all_cards.parquet') - out(f"Aggregated {stats['total_cards']} cards into all_cards.parquet ({stats['file_size_mb']} MB)") - - # Convert commander_cards.csv and background_cards.csv to Parquet - import pandas as pd - - # Convert commander_cards.csv - commander_csv = 'csv_files/commander_cards.csv' - commander_parquet = 'card_files/commander_cards.parquet' - if os.path.exists(commander_csv): - df_cmd = pd.read_csv(commander_csv, comment='#', low_memory=False) - # Convert mixed-type columns to strings for Parquet compatibility - for col in ["power", "toughness", "keywords"]: - if col in df_cmd.columns: - df_cmd[col] = df_cmd[col].astype(str) - df_cmd.to_parquet(commander_parquet, engine="pyarrow", compression="snappy", index=False) - out(f"Converted commander_cards.csv to Parquet ({len(df_cmd)} commanders)") - - # Convert background_cards.csv - background_csv = 'csv_files/background_cards.csv' - background_parquet = 'card_files/background_cards.parquet' - if os.path.exists(background_csv): - df_bg = pd.read_csv(background_csv, comment='#', low_memory=False) - # Convert mixed-type columns to strings for Parquet compatibility - for col in ["power", "toughness", "keywords"]: - if col in df_bg.columns: - df_bg[col] = df_bg[col].astype(str) - df_bg.to_parquet(background_parquet, engine="pyarrow", compression="snappy", index=False) - out(f"Converted background_cards.csv to Parquet ({len(df_bg)} backgrounds)") - - _write_status({"running": True, "phase": "aggregating", "message": "Card aggregation complete", "percent": 95}) - except Exception as e: - # Non-fatal: aggregation failure shouldn't block the rest of setup - out(f"Warning: Card aggregation failed: {e}") - _write_status({"running": True, "phase": "aggregating", "message": f"Aggregation failed (non-fatal): {e}", "percent": 95}) - # Final status with percent 100 and timing info finished_dt = _dt.now() finished = finished_dt.isoformat(timespec='seconds') @@ -1524,8 +1348,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None: # Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress _refresh_theme_catalog(out, force=True, fast_path=False) try: - from .theme_catalog_loader import bust_filter_cache - from .theme_preview import bust_preview_cache + from .theme_catalog_loader import bust_filter_cache # type: ignore + from .theme_preview import bust_preview_cache # type: ignore bust_filter_cache("tagging_complete") bust_preview_cache("tagging_complete") except Exception: @@ -1721,19 +1545,19 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Owned/Prefer-owned integration (optional for headless runs) try: if use_owned_only: - b.use_owned_only = True + b.use_owned_only = True # type: ignore[attr-defined] # Prefer explicit owned_names list if provided; else let builder discover from files if owned_names: try: - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] except Exception: - b.owned_card_names = set() + b.owned_card_names = set() # type: ignore[attr-defined] # Soft preference flag does not filter; only biases selection order if prefer_owned: try: - b.prefer_owned = True + b.prefer_owned = True # type: ignore[attr-defined] if owned_names and not getattr(b, 'owned_card_names', None): - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] except Exception: pass except Exception: @@ -1751,13 +1575,13 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Thread combo preferences (if provided) try: if prefer_combos is not None: - b.prefer_combos = bool(prefer_combos) + b.prefer_combos = bool(prefer_combos) # type: ignore[attr-defined] if combo_target_count is not None: - b.combo_target_count = int(combo_target_count) + b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] if combo_balance: bal = str(combo_balance).strip().lower() if bal in ('early','late','mix'): - b.combo_balance = bal + b.combo_balance = bal # type: ignore[attr-defined] except Exception: pass @@ -1934,7 +1758,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception: pass if hasattr(b, 'export_decklist_csv'): - csv_path = b.export_decklist_csv() + csv_path = b.export_decklist_csv() # type: ignore[attr-defined] except Exception as e: out(f"CSV export failed: {e}") try: @@ -1942,7 +1766,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Try to mirror build_deck_full behavior by displaying the contents import os as _os base, _ext = _os.path.splitext(_os.path.basename(csv_path)) if csv_path else (f"deck_{b.timestamp}", "") - txt_path = b.export_decklist_text(filename=base + '.txt') + txt_path = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] try: b._display_txt_contents(txt_path) except Exception: @@ -1950,7 +1774,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Compute bracket compliance and save JSON alongside exports try: if hasattr(b, 'compute_and_print_compliance'): - rep0 = b.compute_and_print_compliance(base_stem=base) + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] # Attach planning preview (no mutation) and only auto-enforce if explicitly enabled rep0 = _attach_enforcement_plan(b, rep0) try: @@ -1959,7 +1783,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i except Exception: _auto = False if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): - b.enforce_and_reexport(base_stem=base, mode='auto') + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] except Exception: pass # Load compliance JSON for UI consumption @@ -1981,7 +1805,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # Build structured summary for UI try: if hasattr(b, 'build_deck_summary'): - summary = b.build_deck_summary() + summary = b.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None # Write sidecar summary JSON next to CSV (if available) @@ -1999,7 +1823,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i "txt": txt_path, } try: - commander_meta = b.get_commander_export_metadata() + commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -2030,8 +1854,6 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) - # M7: Invalidate past builds cache so new build appears in recommendations - invalidate_past_builds_cache() except Exception: pass # Success return @@ -2044,12 +1866,7 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i # ----------------- # Step-by-step build session # ----------------- -def _make_stages_legacy(b: DeckBuilder) -> List[Dict[str, Any]]: - """Legacy stage order: lands → creatures → spells → theme fill → post-adjust. - - This is the original ordering where lands are added first, before creatures - and spells. Kept for backward compatibility via WEB_STAGE_ORDER=legacy. - """ +def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: stages: List[Dict[str, Any]] = [] # Run Multi-Copy before land steps (per web-first flow preference) mc_selected = False @@ -2147,257 +1964,25 @@ def _make_stages_legacy(b: DeckBuilder) -> List[Dict[str, Any]]: return stages -def _make_stages_new(b: DeckBuilder) -> List[Dict[str, Any]]: - """New stage order: creatures → ideal spells → lands → theme fill → post-adjust. - - This is the preferred ordering where creatures and core spells are added first, - then lands (which can now analyze actual pip requirements), then theme fill tops up. - """ - stages: List[Dict[str, Any]] = [] - # Run Multi-Copy first (if selected) - mc_selected = False - try: - mc_selected = bool(getattr(b, '_web_multi_copy', None)) - except Exception: - mc_selected = False - if mc_selected: - stages.append({"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}) - - # M3: Include injection first - if hasattr(b, '_inject_includes_after_lands') and getattr(b, 'include_cards', None): - stages.append({"key": "inject_includes", "label": "Include Cards", "runner_name": "__inject_includes__"}) - - # 1) CREATURES - All theme sub-stages - try: - combine_mode = getattr(b, 'tag_mode', 'AND') - except Exception: - combine_mode = 'AND' - has_two_tags = bool(getattr(b, 'primary_tag', None) and getattr(b, 'secondary_tag', None)) - if combine_mode == 'AND' and has_two_tags and hasattr(b, 'add_creatures_all_theme_phase'): - stages.append({"key": "creatures_all_theme", "label": "Creatures: All-Theme", "runner_name": "add_creatures_all_theme_phase"}) - if getattr(b, 'primary_tag', None) and hasattr(b, 'add_creatures_primary_phase'): - stages.append({"key": "creatures_primary", "label": "Creatures: Primary", "runner_name": "add_creatures_primary_phase"}) - if getattr(b, 'secondary_tag', None) and hasattr(b, 'add_creatures_secondary_phase'): - stages.append({"key": "creatures_secondary", "label": "Creatures: Secondary", "runner_name": "add_creatures_secondary_phase"}) - if getattr(b, 'tertiary_tag', None) and hasattr(b, 'add_creatures_tertiary_phase'): - stages.append({"key": "creatures_tertiary", "label": "Creatures: Tertiary", "runner_name": "add_creatures_tertiary_phase"}) - if hasattr(b, 'add_creatures_fill_phase'): - stages.append({"key": "creatures_fill", "label": "Creatures: Fill", "runner_name": "add_creatures_fill_phase"}) - - # 2) SPELLS - Ideal categories (granular) - spell_categories: List[Tuple[str, str, str]] = [ - ("ramp", "Ramp", "add_ramp"), - ("removal", "Removal", "add_removal"), - ("wipes", "Board Wipes", "add_board_wipes"), - ("card_advantage", "Card Advantage", "add_card_advantage"), - ("protection", "Protective Effects", "add_protection"), - ] - any_granular = any(callable(getattr(b, rn, None)) for _key, _label, rn in spell_categories) - if any_granular: - for key, label, runner in spell_categories: - if callable(getattr(b, runner, None)): - stages.append({"key": f"spells_{key}", "label": label, "runner_name": runner}) - - # Auto-Complete Combos (if preferred and allowed) - try: - prefer_c = bool(getattr(b, 'prefer_combos', False)) - except Exception: - prefer_c = False - allow_combos = True - try: - lim = getattr(b, 'bracket_limits', {}).get('two_card_combos') - if lim is not None and int(lim) == 0: - allow_combos = False - except Exception: - allow_combos = True - if prefer_c and allow_combos: - stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) - elif hasattr(b, 'add_spells_phase'): - # Monolithic spells with combos first - try: - prefer_c = bool(getattr(b, 'prefer_combos', False)) - allow_combos = True - try: - lim = getattr(b, 'bracket_limits', {}).get('two_card_combos') - if lim is not None and int(lim) == 0: - allow_combos = False - except Exception: - allow_combos = True - if prefer_c and allow_combos: - stages.append({"key": "autocombos", "label": "Auto-Complete Combos", "runner_name": "__auto_complete_combos__"}) - except Exception: - pass - stages.append({"key": "spells", "label": "Spells", "runner_name": "add_spells_phase"}) - - # 3) LANDS - Steps 1..8 (after spells so pip counts are known) - for i in range(1, 9): - fn = getattr(b, f"run_land_step{i}", None) - if callable(fn): - stages.append({"key": f"land{i}", "label": f"Lands (Step {i})", "runner_name": f"run_land_step{i}"}) - - # 4) THEME FILL - Final spell topper - if callable(getattr(b, 'fill_remaining_theme_spells', None)): - stages.append({"key": "spells_fill", "label": "Theme Spell Fill", "runner_name": "fill_remaining_theme_spells"}) - - # 5) LAND ADJUSTMENTS - Post-spell rebalance (same as legacy) - if hasattr(b, 'post_spell_land_adjust'): - stages.append({"key": "post_adjust", "label": "Post-Spell Land Adjust", "runner_name": "post_spell_land_adjust"}) - - # Reporting (always last) - if hasattr(b, 'run_reporting_phase'): - stages.append({"key": "reporting", "label": "Reporting", "runner_name": "run_reporting_phase"}) - - return stages - - -def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]: - """Dispatcher: choose stage ordering based on WEB_STAGE_ORDER environment variable. - - - 'new' (default): creatures → ideal spells → lands → theme fill - - 'legacy': lands → creatures → spells → theme fill (original order) - """ - stage_order = os.getenv('WEB_STAGE_ORDER', 'new').strip().lower() - - if stage_order == 'legacy': - return _make_stages_legacy(b) - else: - # Default to new order - return _make_stages_new(b) - - -def _get_stage_skip_config(sess: Dict[str, Any]) -> Dict[str, bool]: - """Extract skip configuration flags from session. - - Returns dict with all skip flags (default False if not present): - - skip_lands: Skip all land stages - - skip_to_misc: Skip to misc lands (duals/triomes/misc/optimize) - - skip_basics: Skip basic lands (land1) - - skip_staples: Skip staple lands (land2) - - skip_kindred: Skip kindred/tribal lands (land3) - - skip_fetches: Skip fetch lands (land4) - - skip_duals: Skip dual/shock lands (land5) - - skip_triomes: Skip triome/tri-color lands (land6) - - skip_all_creatures: Skip all creature stages - - skip_creature_primary: Skip creature primary stage - - skip_creature_secondary: Skip creature secondary stage - - skip_creature_fill: Skip creature fill stage - - skip_all_spells: Skip all spell stages - - skip_ramp: Skip ramp spells - - skip_removal: Skip removal spells - - skip_wipes: Skip board wipes - - skip_card_advantage: Skip card advantage - - skip_protection: Skip protection spells - - skip_spell_fill: Skip spell fill stage - - skip_post_adjust: Skip post-adjustment - """ - return { - "skip_lands": bool(sess.get("skip_lands", False)), - "skip_to_misc": bool(sess.get("skip_to_misc", False)), - "skip_basics": bool(sess.get("skip_basics", False)), - "skip_staples": bool(sess.get("skip_staples", False)), - "skip_kindred": bool(sess.get("skip_kindred", False)), - "skip_fetches": bool(sess.get("skip_fetches", False)), - "skip_duals": bool(sess.get("skip_duals", False)), - "skip_triomes": bool(sess.get("skip_triomes", False)), - "skip_all_creatures": bool(sess.get("skip_all_creatures", False)), - "skip_creature_primary": bool(sess.get("skip_creature_primary", False)), - "skip_creature_secondary": bool(sess.get("skip_creature_secondary", False)), - "skip_creature_fill": bool(sess.get("skip_creature_fill", False)), - "skip_all_spells": bool(sess.get("skip_all_spells", False)), - "skip_ramp": bool(sess.get("skip_ramp", False)), - "skip_removal": bool(sess.get("skip_removal", False)), - "skip_wipes": bool(sess.get("skip_wipes", False)), - "skip_card_advantage": bool(sess.get("skip_card_advantage", False)), - "skip_protection": bool(sess.get("skip_protection", False)), - "skip_spell_fill": bool(sess.get("skip_spell_fill", False)), - "skip_post_adjust": bool(sess.get("skip_post_adjust", False)), - } - - -def _check_stage_skip(stage_id: str, skip_config: Dict[str, bool]) -> bool: - """Check if a stage should be skipped based on skip configuration. - - Land stage mapping: - land1 = basics, land2 = staples, land3 = kindred, land4 = fetches, - land5 = duals/shocks, land6 = triomes, land7 = misc, land8 = optimize - - Args: - stage_id: Stage identifier (e.g., 'land1', 'creatures_primary', 'spells_ramp') - skip_config: Skip configuration dict from _get_stage_skip_config() - - Returns: - True if stage should be skipped, False otherwise - """ - # Land stages - if stage_id == "land1": - return skip_config.get("skip_basics", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) - elif stage_id == "land2": - return skip_config.get("skip_staples", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) - elif stage_id == "land3": - return skip_config.get("skip_kindred", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) - elif stage_id == "land4": - return skip_config.get("skip_fetches", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) - elif stage_id == "land5": - return skip_config.get("skip_duals", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) - elif stage_id == "land6": - return skip_config.get("skip_triomes", False) or skip_config.get("skip_to_misc", False) or skip_config.get("skip_lands", False) - elif stage_id == "land7": - # land7 = misc lands - skip_to_misc should STOP here and gate, not skip through - return skip_config.get("skip_lands", False) - elif stage_id == "land8": - # land8 = optimize - skip if skip_lands is enabled - return skip_config.get("skip_lands", False) - - # Creature stages - elif stage_id == "creatures_all_theme": - return skip_config.get("skip_all_creatures", False) - elif stage_id == "creatures_primary": - return skip_config.get("skip_creature_primary", False) or skip_config.get("skip_all_creatures", False) - elif stage_id == "creatures_secondary": - return skip_config.get("skip_creature_secondary", False) or skip_config.get("skip_all_creatures", False) - elif stage_id == "creatures_fill": - return skip_config.get("skip_creature_fill", False) or skip_config.get("skip_all_creatures", False) - - # Spell stages - elif stage_id == "spells_ramp": - return skip_config.get("skip_ramp", False) or skip_config.get("skip_all_spells", False) - elif stage_id == "spells_removal": - return skip_config.get("skip_removal", False) or skip_config.get("skip_all_spells", False) - elif stage_id == "spells_wipes": - return skip_config.get("skip_wipes", False) or skip_config.get("skip_all_spells", False) - elif stage_id == "spells_card_advantage": - return skip_config.get("skip_card_advantage", False) or skip_config.get("skip_all_spells", False) - elif stage_id == "spells_protection": - return skip_config.get("skip_protection", False) or skip_config.get("skip_all_spells", False) - elif stage_id == "spells_fill": - return skip_config.get("skip_spell_fill", False) or skip_config.get("skip_all_spells", False) - - # Post-adjust stage - elif stage_id == "post_adjust": - return skip_config.get("skip_post_adjust", False) - - return False - - def _apply_combined_commander_to_builder(builder: DeckBuilder, combined: Any) -> None: """Attach combined commander metadata to the builder.""" try: - builder.combined_commander = combined + builder.combined_commander = combined # type: ignore[attr-defined] except Exception: pass try: - builder.partner_mode = getattr(combined, "partner_mode", None) + builder.partner_mode = getattr(combined, "partner_mode", None) # type: ignore[attr-defined] except Exception: pass try: - builder.secondary_commander = getattr(combined, "secondary_name", None) + builder.secondary_commander = getattr(combined, "secondary_name", None) # type: ignore[attr-defined] except Exception: pass try: - builder.combined_color_identity = getattr(combined, "color_identity", None) - builder.combined_theme_tags = getattr(combined, "theme_tags", None) - builder.partner_warnings = getattr(combined, "warnings", None) + builder.combined_color_identity = getattr(combined, "color_identity", None) # type: ignore[attr-defined] + builder.combined_theme_tags = getattr(combined, "theme_tags", None) # type: ignore[attr-defined] + builder.partner_warnings = getattr(combined, "warnings", None) # type: ignore[attr-defined] except Exception: pass commander_dict = getattr(builder, "commander_dict", None) @@ -2583,17 +2168,17 @@ def start_build_ctx( # Owned-only / prefer-owned (if requested) try: if use_owned_only: - b.use_owned_only = True + b.use_owned_only = True # type: ignore[attr-defined] if owned_names: try: - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] except Exception: - b.owned_card_names = set() + b.owned_card_names = set() # type: ignore[attr-defined] if prefer_owned: try: - b.prefer_owned = True + b.prefer_owned = True # type: ignore[attr-defined] if owned_names and not getattr(b, 'owned_card_names', None): - b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) + b.owned_card_names = set(str(n).strip() for n in owned_names if str(n).strip()) # type: ignore[attr-defined] except Exception: pass except Exception: @@ -2646,14 +2231,14 @@ def start_build_ctx( # Thread combo config try: if combo_target_count is not None: - b.combo_target_count = int(combo_target_count) + b.combo_target_count = int(combo_target_count) # type: ignore[attr-defined] except Exception: pass try: if combo_balance: bal = str(combo_balance).strip().lower() if bal in ('early','late','mix'): - b.combo_balance = bal + b.combo_balance = bal # type: ignore[attr-defined] except Exception: pass # Stages @@ -2735,23 +2320,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: - ctx["csv_path"] = b.export_decklist_csv() + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] except Exception as e: logs.append(f"CSV export failed: {e}") if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: import os as _os base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv")) - ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') + ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] # Export the run configuration JSON for manual builds try: - b.export_run_config_json(directory='config', filename=base + '.json') + b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] except Exception: pass # Compute bracket compliance and save JSON alongside exports try: if hasattr(b, 'compute_and_print_compliance'): - rep0 = b.compute_and_print_compliance(base_stem=base) + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] rep0 = _attach_enforcement_plan(b, rep0) try: import os as __os @@ -2759,7 +2344,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: _auto = False if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): - b.enforce_and_reexport(base_stem=base, mode='auto') + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] except Exception: pass # Load compliance JSON for UI consumption @@ -2811,7 +2396,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal summary = None try: if hasattr(b, 'build_deck_summary'): - summary = b.build_deck_summary() + summary = b.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None # Write sidecar summary JSON next to CSV (if available) @@ -2830,7 +2415,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "txt": ctx.get("txt_path"), } try: - commander_meta = b.get_commander_export_metadata() + commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -2860,8 +2445,6 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) - # M7: Invalidate past builds cache so new build appears in recommendations - invalidate_past_builds_cache() except Exception: pass return { @@ -2890,12 +2473,12 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp_now = None try: if hasattr(b, 'compute_and_print_compliance'): - comp_now = b.compute_and_print_compliance(base_stem=None) + comp_now = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp_now = None try: if comp_now: - comp_now = _attach_enforcement_plan(b, comp_now) + comp_now = _attach_enforcement_plan(b, comp_now) # type: ignore[attr-defined] except Exception: pass # If still FAIL, return the saved result without advancing or rerunning @@ -2933,13 +2516,6 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal stage = stages[i] label = stage["label"] runner_name = stage["runner_name"] - stage_id = stage.get("key", "") - - # Check if stage should be skipped (M2: Skip Controls) - # Note: Skip means "auto-continue without user input", not "skip execution" - # The stage still runs and adds cards, but we don't gate on it - skip_config = _get_stage_skip_config(ctx.get("session", {})) - should_skip = _check_stage_skip(stage_id, skip_config) # Take snapshot before executing; for rerun with replace, restore first if we have one if rerun and replace and ctx.get("snapshot") is not None and i == max(0, int(ctx.get("last_visible_idx", ctx["idx"]) or 1) - 1): @@ -3407,7 +2983,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: @@ -3442,7 +3018,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: pass - # If this stage added cards, gate for user review UNLESS skip is enabled + # If this stage added cards, present it and advance idx if added_cards: # Progress counts try: @@ -3471,23 +3047,6 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass ctx["idx"] = i + 1 ctx["last_visible_idx"] = i + 1 - - # M2 Skip Controls: If stage is skipped, auto-advance instead of gating - if should_skip: - # Track that this stage was auto-skipped - try: - skipped_list = ctx.get("skipped_stages", []) - if stage_id not in skipped_list: - skipped_list.append(stage_id) - ctx["skipped_stages"] = skipped_list - except Exception: - pass - # Log the skip and continue to next stage - logs.append(f"Auto-continued through '{label}' (skip enabled)") - i += 1 - continue - - # Normal gating: return stage result for user review return { "done": False, "label": label, @@ -3508,7 +3067,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: @@ -3575,7 +3134,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal comp = None try: if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) + comp = b.compute_and_print_compliance(base_stem=None) # type: ignore[attr-defined] except Exception: comp = None try: @@ -3617,23 +3176,23 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal pass if not ctx.get("csv_path") and hasattr(b, 'export_decklist_csv'): try: - ctx["csv_path"] = b.export_decklist_csv() + ctx["csv_path"] = b.export_decklist_csv() # type: ignore[attr-defined] except Exception as e: logs.append(f"CSV export failed: {e}") if not ctx.get("txt_path") and hasattr(b, 'export_decklist_text'): try: import os as _os base, _ext = _os.path.splitext(_os.path.basename(ctx.get("csv_path") or f"deck_{b.timestamp}.csv")) - ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') + ctx["txt_path"] = b.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] # Export the run configuration JSON for manual builds try: - b.export_run_config_json(directory='config', filename=base + '.json') + b.export_run_config_json(directory='config', filename=base + '.json') # type: ignore[attr-defined] except Exception: pass # Compute bracket compliance and save JSON alongside exports try: if hasattr(b, 'compute_and_print_compliance'): - rep0 = b.compute_and_print_compliance(base_stem=base) + rep0 = b.compute_and_print_compliance(base_stem=base) # type: ignore[attr-defined] rep0 = _attach_enforcement_plan(b, rep0) try: import os as __os @@ -3641,7 +3200,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal except Exception: _auto = False if _auto and isinstance(rep0, dict) and rep0.get('overall') == 'FAIL' and hasattr(b, 'enforce_and_reexport'): - b.enforce_and_reexport(base_stem=base, mode='auto') + b.enforce_and_reexport(base_stem=base, mode='auto') # type: ignore[attr-defined] except Exception: pass # Load compliance JSON for UI consumption @@ -3662,7 +3221,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal summary = None try: if hasattr(b, 'build_deck_summary'): - summary = b.build_deck_summary() + summary = b.build_deck_summary() # type: ignore[attr-defined] except Exception: summary = None # Write sidecar summary JSON next to CSV (if available) @@ -3681,7 +3240,7 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "txt": ctx.get("txt_path"), } try: - commander_meta = b.get_commander_export_metadata() + commander_meta = b.get_commander_export_metadata() # type: ignore[attr-defined] except Exception: commander_meta = {} names = commander_meta.get("commander_names") or [] @@ -3711,8 +3270,6 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) - # M7: Invalidate past builds cache so new build appears in recommendations - invalidate_past_builds_cache() except Exception: pass # Final progress diff --git a/code/web/services/owned_store.py b/code/web/services/owned_store.py index 5225a3c..76fa313 100644 --- a/code/web/services/owned_store.py +++ b/code/web/services/owned_store.py @@ -124,74 +124,135 @@ def add_names(names: Iterable[str]) -> Tuple[int, int]: def _enrich_from_csvs(target_names: Iterable[str]) -> Dict[str, Dict[str, object]]: - """Return metadata for target names by scanning all_cards.parquet (M4). + """Return metadata for target names by scanning csv_files/*_cards.csv. Output: { Name: { 'tags': [..], 'type': str|None, 'colors': [..] } } """ + from pathlib import Path + import json as _json + import csv as _csv + + base = Path('csv_files') meta: Dict[str, Dict[str, object]] = {} want = {str(n).strip().lower() for n in target_names if str(n).strip()} - if not want: + if not (base.exists() and want): return meta + csv_files = [p for p in base.glob('*_cards.csv') if p.name.lower() not in ('cards.csv', 'commander_cards.csv')] - try: - from deck_builder import builder_utils as bu - df = bu._load_all_cards_parquet() - if df.empty: - return meta - - # Filter to cards we care about - df['name_lower'] = df['name'].str.lower() - df_filtered = df[df['name_lower'].isin(want)].copy() - - for _, row in df_filtered.iterrows(): - nm = str(row.get('name') or '').strip() - if not nm: - continue - - entry = meta.setdefault(nm, {"tags": [], "type": None, "colors": []}) - - # Tags (already a list after our conversion in builder_utils) - tags = row.get('themeTags') - if tags and isinstance(tags, list): - existing = entry.get('tags') or [] - seen = {str(t).lower() for t in existing} - for t in tags: - t_str = str(t).strip() - if t_str and t_str.lower() not in seen: - existing.append(t_str) - seen.add(t_str.lower()) - entry['tags'] = existing - - # Type - if not entry.get('type'): - t_raw = str(row.get('type') or '').strip() - if t_raw: - tline = t_raw.split('—')[0].strip() if '—' in t_raw else t_raw - prim = None - for cand in ['Creature','Instant','Sorcery','Artifact','Enchantment','Planeswalker','Land','Battle']: - if cand.lower() in tline.lower(): - prim = cand + def _norm(s: str) -> str: return str(s or '').strip().lower() + for path in csv_files: + try: + with path.open('r', encoding='utf-8', errors='ignore') as f: + reader = _csv.DictReader(f) + headers = [h for h in (reader.fieldnames or [])] + name_key = None + tags_key = None + type_key = None + colors_key = None + for h in headers: + hn = _norm(h) + if hn in ('name', 'card', 'cardname', 'card_name'): + name_key = h + if hn in ('tags', 'theme_tags', 'themetags', 'themetagsjson') or hn == 'themetags' or hn == 'themetagsjson': + tags_key = h + if hn in ('type', 'type_line', 'typeline'): + type_key = h + if hn in ('colors', 'coloridentity', 'color_identity', 'color'): + colors_key = h + if not tags_key: + for h in headers: + if h.strip() in ('ThemeTags', 'themeTags'): + tags_key = h break - if not prim and tline: - prim = tline.split()[0] - if prim: - entry['type'] = prim - - # Colors - if not entry.get('colors'): - colors_raw = str(row.get('colorIdentity') or '').strip() - if colors_raw: - parts = [c.strip() for c in colors_raw.split(',') if c.strip()] - entry['colors'] = parts - - except Exception: - # Defensive: return empty or partial meta - pass - + if not colors_key: + for h in headers: + if h.strip() in ('ColorIdentity', 'colorIdentity'): + colors_key = h + break + if not name_key: + continue + for row in reader: + try: + nm = str(row.get(name_key) or '').strip() + if not nm: + continue + low = nm.lower() + if low not in want: + continue + entry = meta.setdefault(nm, {"tags": [], "type": None, "colors": []}) + # Tags + if tags_key: + raw = (row.get(tags_key) or '').strip() + vals: List[str] = [] + if raw: + if raw.startswith('['): + try: + arr = _json.loads(raw) + if isinstance(arr, list): + vals = [str(x).strip() for x in arr if str(x).strip()] + except Exception: + vals = [] + if not vals: + parts = [p.strip() for p in raw.replace(';', ',').split(',')] + vals = [p for p in parts if p] + if vals: + existing = entry.get('tags') or [] + seen = {str(t).lower() for t in existing} + for t in vals: + if str(t).lower() not in seen: + existing.append(str(t)) + seen.add(str(t).lower()) + entry['tags'] = existing + # Type + if type_key and not entry.get('type'): + t_raw = str(row.get(type_key) or '').strip() + if t_raw: + tline = t_raw.split('—')[0].strip() if '—' in t_raw else t_raw + prim = None + for cand in ['Creature','Instant','Sorcery','Artifact','Enchantment','Planeswalker','Land','Battle']: + if cand.lower() in tline.lower(): + prim = cand + break + if not prim and tline: + prim = tline.split()[0] + if prim: + entry['type'] = prim + # Colors + if colors_key and not entry.get('colors'): + c_raw = str(row.get(colors_key) or '').strip() + cols: List[str] = [] + if c_raw: + if c_raw.startswith('['): + try: + arr = _json.loads(c_raw) + if isinstance(arr, list): + cols = [str(x).strip().upper() for x in arr if str(x).strip()] + except Exception: + cols = [] + if not cols: + parts = [p.strip().upper() for p in c_raw.replace(';', ',').replace('[','').replace(']','').replace("'",'').split(',') if p.strip()] + if parts: + cols = parts + if not cols: + for ch in c_raw: + if ch.upper() in ('W','U','B','R','G','C'): + cols.append(ch.upper()) + if cols: + seen_c = set() + uniq = [] + for c in cols: + if c not in seen_c: + uniq.append(c) + seen_c.add(c) + entry['colors'] = uniq + except Exception: + continue + except Exception: + continue return meta def add_and_enrich(names: Iterable[str]) -> Tuple[int, int]: - """Add names and enrich their metadata from Parquet (M4). + """Add names and enrich their metadata from CSVs in one pass. Returns (added_count, total_after). """ data = _load_raw() diff --git a/code/web/services/partner_suggestions.py b/code/web/services/partner_suggestions.py index b781ef5..91eb97e 100644 --- a/code/web/services/partner_suggestions.py +++ b/code/web/services/partner_suggestions.py @@ -362,7 +362,7 @@ def load_dataset(*, force: bool = False, refresh: bool = False) -> Optional[Part if allow_auto_refresh: _DATASET_REFRESH_ATTEMPTED = True try: - from .orchestrator import _maybe_refresh_partner_synergy + from .orchestrator import _maybe_refresh_partner_synergy # type: ignore _maybe_refresh_partner_synergy(None, force=True) except Exception as refresh_exc: # pragma: no cover - best-effort diff --git a/code/web/services/preview_cache.py b/code/web/services/preview_cache.py index b93a688..2f2b368 100644 --- a/code/web/services/preview_cache.py +++ b/code/web/services/preview_cache.py @@ -21,7 +21,7 @@ import json import threading import math -from .preview_metrics import record_eviction +from .preview_metrics import record_eviction # type: ignore # Phase 2 extraction: adaptive TTL band policy moved into preview_policy from .preview_policy import ( @@ -30,7 +30,7 @@ from .preview_policy import ( DEFAULT_TTL_MIN as _POLICY_TTL_MIN, DEFAULT_TTL_MAX as _POLICY_TTL_MAX, ) -from .preview_cache_backend import redis_store +from .preview_cache_backend import redis_store # type: ignore TTL_SECONDS = 600 # Backward-compat variable names retained (tests may reference) mapping to policy constants diff --git a/code/web/services/preview_cache_backend.py b/code/web/services/preview_cache_backend.py index d24d635..3750d22 100644 --- a/code/web/services/preview_cache_backend.py +++ b/code/web/services/preview_cache_backend.py @@ -24,9 +24,9 @@ import os import time try: # lazy optional dependency - import redis + import redis # type: ignore except Exception: # pragma: no cover - absence path - redis = None + redis = None # type: ignore _URL = os.getenv("THEME_PREVIEW_REDIS_URL") _DISABLED = (os.getenv("THEME_PREVIEW_REDIS_DISABLE") or "").lower() in {"1","true","yes","on"} @@ -42,7 +42,7 @@ def _init() -> None: _INIT_ERR = "disabled_or_missing" return try: - _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) + _CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # type: ignore # lightweight ping (non-fatal) try: _CLIENT.ping() @@ -86,7 +86,7 @@ def redis_get(key: Tuple[str, int, str | None, str | None, str]) -> Optional[Dic return None try: skey = "tpv:" + "|".join([str(part) for part in key]) - raw: bytes | None = _CLIENT.get(skey) + raw: bytes | None = _CLIENT.get(skey) # type: ignore if not raw: return None obj = json.loads(raw.decode("utf-8")) diff --git a/code/web/services/sampling.py b/code/web/services/sampling.py index 40d8a0b..f7e9aad 100644 --- a/code/web/services/sampling.py +++ b/code/web/services/sampling.py @@ -130,7 +130,7 @@ def sample_real_cards_for_theme(theme: str, limit: int, colors_filter: Optional[ if allow_splash: off = ci - commander_colors if len(off) == 1: - c["_splash_off_color"] = True + c["_splash_off_color"] = True # type: ignore new_pool.append(c) continue pool = new_pool diff --git a/code/web/services/similarity_cache.py b/code/web/services/similarity_cache.py deleted file mode 100644 index ff4c3aa..0000000 --- a/code/web/services/similarity_cache.py +++ /dev/null @@ -1,386 +0,0 @@ -""" -Similarity cache manager for card similarity calculations. - -Provides persistent caching of pre-computed card similarity scores to improve -card detail page load times from 2-6s down to <500ms. - -Cache format: Parquet file with columnar structure: -- card_name: str (source card) -- similar_name: str (similar card name) -- similarity: float (similarity score) -- edhrecRank: float (EDHREC rank of similar card) -- rank: int (ranking position, 0-19 for top 20) - -Metadata stored in separate JSON sidecar file. - -Benefits vs JSON: -- 5-10x faster load times -- 50-70% smaller file size -- Better compression for large datasets -- Consistent with other card data storage -""" - -import json -import logging -import os -import pandas as pd -import pyarrow as pa -import pyarrow.parquet as pq -from datetime import datetime -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - -# Default cache settings -CACHE_VERSION = "2.0" # Bumped for Parquet format -DEFAULT_CACHE_PATH = Path(__file__).parents[3] / "card_files" / "similarity_cache.parquet" -DEFAULT_METADATA_PATH = Path(__file__).parents[3] / "card_files" / "similarity_cache_metadata.json" - - -class SimilarityCache: - """Manages persistent cache for card similarity calculations using Parquet.""" - - def __init__(self, cache_path: Optional[Path] = None, enabled: bool = True): - """ - Initialize similarity cache manager. - - Args: - cache_path: Path to cache file. If None, uses DEFAULT_CACHE_PATH - enabled: Whether cache is enabled (can be disabled via env var) - """ - self.cache_path = cache_path or DEFAULT_CACHE_PATH - self.metadata_path = self.cache_path.with_name( - self.cache_path.stem + "_metadata.json" - ) - self.enabled = enabled and os.getenv("SIMILARITY_CACHE_ENABLED", "1") == "1" - self._cache_df: Optional[pd.DataFrame] = None - self._metadata: Optional[dict] = None - - # Ensure cache directory exists - self.cache_path.parent.mkdir(parents=True, exist_ok=True) - - if self.enabled: - logger.info(f"SimilarityCache initialized at {self.cache_path}") - else: - logger.info("SimilarityCache disabled") - - def load_cache(self) -> pd.DataFrame: - """ - Load cache from disk. - - Returns: - DataFrame with columns: card_name, similar_name, similarity, edhrecRank, rank - Returns empty DataFrame if file doesn't exist or loading fails - """ - if not self.enabled: - return self._empty_cache_df() - - if self._cache_df is not None: - return self._cache_df - - if not self.cache_path.exists(): - logger.info("Cache file not found, returning empty cache") - self._cache_df = self._empty_cache_df() - return self._cache_df - - try: - # Load Parquet file - self._cache_df = pq.read_table(self.cache_path).to_pandas() - - # Load metadata - if self.metadata_path.exists(): - with open(self.metadata_path, "r", encoding="utf-8") as f: - self._metadata = json.load(f) - else: - self._metadata = self._empty_metadata() - - # Validate cache structure - if not self._validate_cache(self._cache_df): - logger.warning("Cache validation failed, returning empty cache") - self._cache_df = self._empty_cache_df() - return self._cache_df - - total_cards = len(self._cache_df["card_name"].unique()) if len(self._cache_df) > 0 else 0 - logger.info( - f"Loaded similarity cache v{self._metadata.get('version', 'unknown')} with {total_cards:,} cards ({len(self._cache_df):,} entries)" - ) - - return self._cache_df - - except Exception as e: - logger.error(f"Failed to load cache: {e}") - self._cache_df = self._empty_cache_df() - return self._cache_df - - def save_cache(self, cache_df: pd.DataFrame, metadata: Optional[dict] = None) -> bool: - """ - Save cache to disk. - - Args: - cache_df: DataFrame with similarity data - metadata: Optional metadata dict. If None, uses current metadata with updates. - - Returns: - True if save successful, False otherwise - """ - if not self.enabled: - logger.debug("Cache disabled, skipping save") - return False - - try: - # Ensure directory exists - self.cache_path.parent.mkdir(parents=True, exist_ok=True) - - # Update metadata - if metadata is None: - metadata = self._metadata or self._empty_metadata() - - total_cards = len(cache_df["card_name"].unique()) if len(cache_df) > 0 else 0 - metadata["total_cards"] = total_cards - metadata["last_updated"] = datetime.now().isoformat() - metadata["total_entries"] = len(cache_df) - - # Write Parquet file (with compression) - temp_cache = self.cache_path.with_suffix(".tmp") - pq.write_table( - pa.table(cache_df), - temp_cache, - compression="snappy", - version="2.6", - ) - temp_cache.replace(self.cache_path) - - # Write metadata file - temp_meta = self.metadata_path.with_suffix(".tmp") - with open(temp_meta, "w", encoding="utf-8") as f: - json.dump(metadata, f, indent=2, ensure_ascii=False) - temp_meta.replace(self.metadata_path) - - self._cache_df = cache_df - self._metadata = metadata - - logger.info(f"Saved similarity cache with {total_cards:,} cards ({len(cache_df):,} entries)") - - return True - - except Exception as e: - logger.error(f"Failed to save cache: {e}") - return False - - def get_similar(self, card_name: str, limit: int = 5, randomize: bool = True) -> Optional[list[dict]]: - """ - Get cached similar cards for a given card. - - Args: - card_name: Name of the card to look up - limit: Maximum number of results to return - randomize: If True, randomly sample from cached results; if False, return top by rank - - Returns: - List of similar cards with similarity scores, or None if not in cache - """ - if not self.enabled: - return None - - cache_df = self.load_cache() - - if len(cache_df) == 0: - return None - - # Filter to this card - card_data = cache_df[cache_df["card_name"] == card_name] - - if len(card_data) == 0: - return None - - # Randomly sample if requested and we have more results than limit - if randomize and len(card_data) > limit: - card_data = card_data.sample(n=limit, random_state=None) - else: - # Sort by rank and take top N - card_data = card_data.sort_values("rank").head(limit) - - # Convert to list of dicts - results = [] - for _, row in card_data.iterrows(): - results.append({ - "name": row["similar_name"], - "similarity": row["similarity"], - "edhrecRank": row["edhrecRank"], - }) - - return results - - def set_similar(self, card_name: str, similar_cards: list[dict]) -> bool: - """ - Cache similar cards for a given card. - - Args: - card_name: Name of the card - similar_cards: List of similar cards with similarity scores - - Returns: - True if successful, False otherwise - """ - if not self.enabled: - return False - - cache_df = self.load_cache() - - # Remove existing entries for this card - cache_df = cache_df[cache_df["card_name"] != card_name] - - # Add new entries - new_rows = [] - for rank, card in enumerate(similar_cards): - new_rows.append({ - "card_name": card_name, - "similar_name": card["name"], - "similarity": card["similarity"], - "edhrecRank": card.get("edhrecRank", float("inf")), - "rank": rank, - }) - - if new_rows: - new_df = pd.DataFrame(new_rows) - cache_df = pd.concat([cache_df, new_df], ignore_index=True) - - return self.save_cache(cache_df) - - def invalidate(self, card_name: Optional[str] = None) -> bool: - """ - Invalidate cache entries. - - Args: - card_name: If provided, invalidate only this card. If None, clear entire cache. - - Returns: - True if successful, False otherwise - """ - if not self.enabled: - return False - - if card_name is None: - # Clear entire cache - logger.info("Clearing entire similarity cache") - self._cache_df = self._empty_cache_df() - self._metadata = self._empty_metadata() - return self.save_cache(self._cache_df, self._metadata) - - # Clear specific card - cache_df = self.load_cache() - - initial_len = len(cache_df) - cache_df = cache_df[cache_df["card_name"] != card_name] - - if len(cache_df) < initial_len: - logger.info(f"Invalidated cache for card: {card_name}") - return self.save_cache(cache_df) - - return False - - def get_stats(self) -> dict: - """ - Get cache statistics. - - Returns: - Dictionary with cache stats (version, total_cards, build_date, file_size, etc.) - """ - if not self.enabled: - return {"enabled": False} - - cache_df = self.load_cache() - metadata = self._metadata or self._empty_metadata() - - stats = { - "enabled": True, - "version": metadata.get("version", "unknown"), - "total_cards": len(cache_df["card_name"].unique()) if len(cache_df) > 0 else 0, - "total_entries": len(cache_df), - "build_date": metadata.get("build_date"), - "last_updated": metadata.get("last_updated"), - "file_exists": self.cache_path.exists(), - "file_path": str(self.cache_path), - "format": "parquet", - } - - if self.cache_path.exists(): - stats["file_size_mb"] = round( - self.cache_path.stat().st_size / (1024 * 1024), 2 - ) - - return stats - - @staticmethod - def _empty_cache_df() -> pd.DataFrame: - """ - Create empty cache DataFrame. - - Returns: - Empty DataFrame with correct schema - """ - return pd.DataFrame(columns=["card_name", "similar_name", "similarity", "edhrecRank", "rank"]) - - @staticmethod - def _empty_metadata() -> dict: - """ - Create empty metadata structure. - - Returns: - Empty metadata dictionary - """ - return { - "version": CACHE_VERSION, - "total_cards": 0, - "total_entries": 0, - "build_date": None, - "last_updated": None, - "threshold": 0.6, - "min_results": 3, - } - - @staticmethod - def _validate_cache(cache_df: pd.DataFrame) -> bool: - """ - Validate cache DataFrame structure. - - Args: - cache_df: DataFrame to validate - - Returns: - True if valid, False otherwise - """ - if not isinstance(cache_df, pd.DataFrame): - return False - - # Check required columns - required_cols = {"card_name", "similar_name", "similarity", "edhrecRank", "rank"} - if not required_cols.issubset(cache_df.columns): - logger.warning(f"Cache missing required columns. Expected: {required_cols}, Got: {set(cache_df.columns)}") - return False - - return True - - -# Singleton instance for global access -_cache_instance: Optional[SimilarityCache] = None - - -def get_cache() -> SimilarityCache: - """ - Get singleton cache instance. - - Returns: - Global SimilarityCache instance - """ - global _cache_instance - - if _cache_instance is None: - # Check environment variables for custom path - cache_path_str = os.getenv("SIMILARITY_CACHE_PATH") - cache_path = Path(cache_path_str) if cache_path_str else None - - _cache_instance = SimilarityCache(cache_path=cache_path) - - return _cache_instance diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py index 4bb10eb..aee1a3f 100644 --- a/code/web/services/summary_utils.py +++ b/code/web/services/summary_utils.py @@ -7,7 +7,7 @@ from .combo_utils import detect_for_summary as _detect_for_summary def _owned_set_helper() -> set[str]: try: - from .build_utils import owned_set as _owned_set + from .build_utils import owned_set as _owned_set # type: ignore return _owned_set() except Exception: @@ -21,7 +21,7 @@ def _owned_set_helper() -> set[str]: def _sanitize_tag_list(values: Iterable[Any]) -> List[str]: cleaned: List[str] = [] - for raw in values or []: + for raw in values or []: # type: ignore[arg-type] text = str(raw or "").strip() if not text: continue @@ -78,7 +78,7 @@ def format_theme_label(raw: Any) -> str: def format_theme_list(values: Iterable[Any]) -> List[str]: seen: set[str] = set() result: List[str] = [] - for raw in values or []: + for raw in values or []: # type: ignore[arg-type] label = format_theme_label(raw) if not label: continue diff --git a/code/web/services/synergy_builder.py b/code/web/services/synergy_builder.py deleted file mode 100644 index 3bd49c9..0000000 --- a/code/web/services/synergy_builder.py +++ /dev/null @@ -1,607 +0,0 @@ -""" -Synergy Builder - Analyzes multiple deck builds and creates optimized "best-of" deck. - -Takes multiple builds of the same configuration and identifies cards that appear -frequently across builds, scoring them for synergy based on: -- Frequency of appearance (higher = more consistent with strategy) -- EDHREC rank (lower rank = more popular/powerful) -- Theme tag matches (more matching tags = better fit) -""" - -from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional -from collections import Counter -from code.logging_util import get_logger -from code.deck_builder import builder_utils as bu -import pandas as pd -import os - -logger = get_logger(__name__) - - -@dataclass -class ScoredCard: - """A card with its synergy score and metadata.""" - name: str - frequency: float # 0.0-1.0, percentage of builds containing this card - appearance_count: int # Number of builds this card appears in - synergy_score: float # 0-100+ calculated score - category: str # Card type category (Creature, Land, etc.) - role: str = "" # Card role from tagging - tags: List[str] = field(default_factory=list) # Theme tags - edhrec_rank: Optional[int] = None # EDHREC rank if available - count: int = 1 # Number of copies (usually 1 for Commander) - type_line: str = "" # Full type line (e.g., "Creature — Rabbit Scout") - - -@dataclass -class CardPool: - """Aggregated pool of cards from multiple builds.""" - cards: Dict[str, ScoredCard] # card_name -> ScoredCard - total_builds: int - config: Dict[str, Any] # Original build configuration - themes: List[str] # Theme tags from config - - def get_by_category(self, category: str) -> List[ScoredCard]: - """Get all cards in a specific category.""" - return [card for card in self.cards.values() if card.category == category] - - def get_top_cards(self, limit: int = 100) -> List[ScoredCard]: - """Get top N cards by synergy score.""" - return sorted(self.cards.values(), key=lambda c: c.synergy_score, reverse=True)[:limit] - - def get_high_frequency_cards(self, min_frequency: float = 0.8) -> List[ScoredCard]: - """Get cards appearing in at least min_frequency of builds.""" - return [card for card in self.cards.values() if card.frequency >= min_frequency] - - -class SynergyAnalyzer: - """Analyzes multiple builds and scores cards for synergy.""" - - # Scoring weights - FREQUENCY_WEIGHT = 0.5 - EDHREC_WEIGHT = 0.25 - THEME_WEIGHT = 0.25 - HIGH_FREQUENCY_BONUS = 1.1 # 10% bonus for cards in 80%+ builds - - def __init__(self): - """Initialize synergy analyzer.""" - self._type_line_cache: Dict[str, str] = {} - - def _load_type_lines(self) -> Dict[str, str]: - """ - Load card type lines from parquet for all cards. - - Returns: - Dict mapping card name (lowercase) to type_line - """ - if self._type_line_cache: - return self._type_line_cache - - try: - parquet_path = os.path.join("card_files", "processed", "all_cards.parquet") - if not os.path.exists(parquet_path): - logger.warning(f"[Synergy] Card parquet not found at {parquet_path}") - return {} - - df = pd.read_parquet(parquet_path) - - # Try 'type' first, then 'type_line' - type_col = None - if 'type' in df.columns: - type_col = 'type' - elif 'type_line' in df.columns: - type_col = 'type_line' - - if not type_col or 'name' not in df.columns: - logger.warning(f"[Synergy] Card parquet missing required columns. Available: {list(df.columns)}") - return {} - - # Build mapping: lowercase name -> type_line - for _, row in df.iterrows(): - name = str(row.get('name', '')).strip() - type_line = str(row.get(type_col, '')).strip() - if name and type_line: - self._type_line_cache[name.lower()] = type_line - - logger.info(f"[Synergy] Loaded type lines for {len(self._type_line_cache)} cards from parquet") - return self._type_line_cache - - except Exception as e: - logger.warning(f"[Synergy] Error loading type lines from parquet: {e}") - return {} - - def analyze_builds(self, builds: List[Dict[str, Any]], config: Dict[str, Any]) -> CardPool: - """ - Aggregate all cards from builds and calculate appearance frequencies. - - Args: - builds: List of build results from BuildCache - config: Original deck configuration - - Returns: - CardPool with all unique cards and their frequencies - """ - logger.info(f"[Synergy] Analyzing {len(builds)} builds for synergy") - - if not builds: - raise ValueError("Cannot analyze synergy with no builds") - - total_builds = len(builds) - themes = config.get("tags", []) - - # Load type lines from card CSV - type_line_map = self._load_type_lines() - - # Count card appearances and cumulative counts across all builds - card_appearances: Counter = Counter() # card_name -> number of builds containing it - card_total_counts: Counter = Counter() # card_name -> sum of counts across all builds - card_metadata: Dict[str, Dict[str, Any]] = {} - - for build in builds: - result = build.get("result", {}) - summary = result.get("summary", {}) - - if not isinstance(summary, dict): - logger.warning("[Synergy] Build missing summary, skipping") - continue - - type_breakdown = summary.get("type_breakdown", {}) - if not isinstance(type_breakdown, dict): - continue - - type_cards = type_breakdown.get("cards", {}) - if not isinstance(type_cards, dict): - continue - - # Collect unique cards from this build - unique_cards_in_build = set() - - for category, card_list in type_cards.items(): - if not isinstance(card_list, list): - continue - - for card in card_list: - if not isinstance(card, dict): - continue - - card_name = card.get("name") - if not card_name: - continue - - card_count = card.get("count", 1) - unique_cards_in_build.add(card_name) - - # Track cumulative count across all builds (for multi-copy cards like basics) - card_total_counts[card_name] += card_count - - # Store metadata (first occurrence) - if card_name not in card_metadata: - # Get type_line from parquet, fallback to card data (which won't have it from summary) - type_line = type_line_map.get(card_name.lower(), "") - if not type_line: - type_line = card.get("type", card.get("type_line", "")) - - # Debug: Log first few cards - if len(card_metadata) < 3: - logger.info(f"[Synergy Debug] Card: {card_name}, Type line: {type_line}, From map: {card_name.lower() in type_line_map}") - - card_metadata[card_name] = { - "category": category, - "role": card.get("role", ""), - "tags": card.get("tags", []), - "type_line": type_line - } - - # Increment appearance count for each unique card in this build - for card_name in unique_cards_in_build: - card_appearances[card_name] += 1 - - # Create ScoredCard objects with frequencies and average counts - scored_cards: Dict[str, ScoredCard] = {} - - for card_name, appearance_count in card_appearances.items(): - frequency = appearance_count / total_builds - metadata = card_metadata.get(card_name, {}) - - scored_card = ScoredCard( - name=card_name, - frequency=frequency, - appearance_count=appearance_count, - synergy_score=0.0, # Will be calculated next - category=metadata.get("category", "Unknown"), - role=metadata.get("role", ""), - tags=metadata.get("tags", []), - count=1, # Default to 1 copy per card in synergy deck (basics override this later) - type_line=metadata.get("type_line", "") - ) - - # Debug: Log first few scored cards - if len(scored_cards) < 3: - logger.info(f"[Synergy Debug] ScoredCard: {scored_card.name}, type_line='{scored_card.type_line}', count={scored_card.count}, in_map={card_name.lower() in type_line_map}") - - # Calculate synergy score - scored_card.synergy_score = self.score_card(scored_card, themes) - - scored_cards[card_name] = scored_card - - logger.info(f"[Synergy] Analyzed {len(scored_cards)} unique cards from {total_builds} builds") - - return CardPool( - cards=scored_cards, - total_builds=total_builds, - config=config, - themes=themes - ) - - def score_card(self, card: ScoredCard, themes: List[str]) -> float: - """ - Calculate synergy score for a card. - - Score = frequency_weight * frequency * 100 + - edhrec_weight * (1 - rank/max_rank) * 100 + - theme_weight * (matching_tags / total_tags) * 100 - - Args: - card: ScoredCard to score - themes: Theme tags from config - - Returns: - Synergy score (0-100+) - """ - # Frequency component (0-100) - frequency_score = card.frequency * 100 - - # EDHREC component (placeholder - would need EDHREC data) - # For now, assume no EDHREC data available - edhrec_score = 50.0 # Neutral score - - # Theme component (0-100) - theme_score = 0.0 - if themes and card.tags: - theme_set = set(themes) - card_tag_set = set(card.tags) - matching_tags = len(theme_set & card_tag_set) - theme_score = (matching_tags / len(themes)) * 100 if themes else 0.0 - - # Calculate weighted score - score = ( - self.FREQUENCY_WEIGHT * frequency_score + - self.EDHREC_WEIGHT * edhrec_score + - self.THEME_WEIGHT * theme_score - ) - - # Bonus for high-frequency cards (appear in 80%+ builds) - if card.frequency >= 0.8: - score *= self.HIGH_FREQUENCY_BONUS - - return round(score, 2) - - -class SynergyDeckBuilder: - """Builds an optimized deck from a synergy-scored card pool.""" - - def __init__(self, analyzer: Optional[SynergyAnalyzer] = None): - """ - Initialize synergy deck builder. - - Args: - analyzer: SynergyAnalyzer instance (creates new if None) - """ - self.analyzer = analyzer or SynergyAnalyzer() - - def _allocate_basic_lands( - self, - selected_cards: List[ScoredCard], - by_category: Dict[str, List[ScoredCard]], - pool: CardPool, - ideals: Optional[Dict[str, int]] - ) -> List[ScoredCard]: - """ - Allocate basic lands based on color identity and remaining land slots. - - Separates basic lands from nonbasics, then allocates basics based on: - 1. Total lands target from ideals - 2. Color identity from config - 3. Current nonbasic land count - - Args: - selected_cards: Currently selected cards (may include basics from pool) - by_category: Cards grouped by category - pool: Card pool with configuration - ideals: Ideal card counts - - Returns: - Updated list of selected cards with properly allocated basics - """ - if not ideals: - return selected_cards # No ideals, keep as-is - - # Get basic land names - basic_names = bu.basic_land_names() - - # Separate basics from nonbasics - nonbasic_cards = [c for c in selected_cards if c.name not in basic_names] - - # Calculate how many basics we need - # Note: For nonbasics, count=1 per card (singleton rule), so count == number of unique cards - target_lands = ideals.get("lands", 35) - nonbasic_lands = [c for c in nonbasic_cards if c.category == "Land"] - current_nonbasic_count = len(nonbasic_lands) - - # If we have too many nonbasics, trim them - if current_nonbasic_count > target_lands: - logger.info(f"[Synergy] Too many nonbasics ({current_nonbasic_count}), trimming to {target_lands}") - # Keep the highest scoring nonbasics - sorted_nonbasic_lands = sorted(nonbasic_lands, key=lambda c: c.synergy_score, reverse=True) - trimmed_nonbasic_lands = sorted_nonbasic_lands[:target_lands] - # Update nonbasic_cards to exclude trimmed lands - other_nonbasics = [c for c in nonbasic_cards if c.category != "Land"] - nonbasic_cards = other_nonbasics + trimmed_nonbasic_lands - return nonbasic_cards # No room for basics - - needed_basics = max(0, target_lands - current_nonbasic_count) - - if needed_basics == 0: - logger.info("[Synergy] No basic lands needed (nonbasics exactly fill target)") - return nonbasic_cards - - logger.info(f"[Synergy] Need {needed_basics} basics to fill {target_lands} land target (have {current_nonbasic_count} nonbasics)") - - # Get color identity from config - color_identity = pool.config.get("colors", []) - if not color_identity: - logger.warning(f"[Synergy] No color identity in config (keys: {list(pool.config.keys())}), skipping basic land allocation") - return nonbasic_cards - - # Map colors to basic land names - from code.deck_builder import builder_constants as bc - basic_map = getattr(bc, 'BASIC_LAND_MAPPING', { - 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', 'R': 'Mountain', 'G': 'Forest' - }) - - # Allocate basics evenly across colors - allocation: Dict[str, int] = {} - colors = [c.upper() for c in color_identity if c.upper() in basic_map] - - if not colors: - logger.warning(f"[Synergy] No valid colors found in identity: {color_identity}") - return nonbasic_cards - - # Distribute basics evenly, with remainder going to first colors - n = len(colors) - base = needed_basics // n - rem = needed_basics % n - - for idx, color in enumerate(sorted(colors)): # sorted for deterministic allocation - count = base + (1 if idx < rem else 0) - land_name = basic_map.get(color) - if land_name: - allocation[land_name] = count - - # Create ScoredCard objects for basics - basic_cards = [] - for land_name, count in allocation.items(): - # Try to get type_line from cache first (most reliable) - type_line = self.analyzer._type_line_cache.get(land_name.lower(), "") - if not type_line: - # Fallback: construct from land name - type_line = f"Basic Land — {land_name[:-1] if land_name.endswith('s') else land_name}" - - # Try to get existing scored data from pool, else create minimal entry - if land_name in pool.cards: - existing = pool.cards[land_name] - basic_card = ScoredCard( - name=land_name, - frequency=existing.frequency, - appearance_count=existing.appearance_count, - synergy_score=existing.synergy_score, - category="Land", - role="basic", - tags=[], - count=count, - type_line=type_line # Use looked-up type_line - ) - else: - # Not in pool (common for basics), create minimal entry - basic_card = ScoredCard( - name=land_name, - frequency=1.0, # Assume high frequency for basics - appearance_count=pool.total_builds, - synergy_score=50.0, # Neutral score - category="Land", - role="basic", - tags=[], - count=count, - type_line=type_line - ) - basic_cards.append(basic_card) - - # Update by_category to replace old basics with new allocation - land_category = by_category.get("Land", []) - land_category = [c for c in land_category if c.name not in basic_names] # Remove old basics - land_category.extend(basic_cards) # Add new basics - by_category["Land"] = land_category - - # Combine and return - result = nonbasic_cards + basic_cards - logger.info(f"[Synergy] Allocated {needed_basics} basic lands across {len(colors)} colors: {allocation}") - return result - - def build_deck( - self, - pool: CardPool, - ideals: Optional[Dict[str, int]] = None, - target_size: int = 99 # Commander + 99 cards = 100 - ) -> Dict[str, Any]: - """ - Build an optimized deck from the card pool, respecting ideal counts. - - Selects highest-scoring cards by category to meet ideal distributions. - - Args: - pool: CardPool with scored cards - ideals: Target card counts by category (e.g., {"Creature": 25, "Land": 35}) - target_size: Total number of cards to include (default 99, excluding commander) - - Returns: - Dict with deck list and metadata - """ - logger.info(f"[Synergy] Building deck from pool of {len(pool.cards)} cards") - - # Map category names to ideal keys (case-insensitive matching) - category_mapping = { - "Creature": "creatures", - "Land": "lands", - "Artifact": "artifacts", - "Enchantment": "enchantments", - "Instant": "instants", - "Sorcery": "sorceries", - "Planeswalker": "planeswalkers", - "Battle": "battles" - } - - selected_cards: List[ScoredCard] = [] - by_category: Dict[str, List[ScoredCard]] = {} - - if ideals: - # Build by category to meet ideals (±2 tolerance) - logger.info(f"[Synergy] Using ideals: {ideals}") - - # Get basic land names for filtering - basic_names = bu.basic_land_names() - - for category in ["Land", "Creature", "Artifact", "Enchantment", "Instant", "Sorcery", "Planeswalker", "Battle"]: - ideal_key = category_mapping.get(category, category.lower()) - target_count = ideals.get(ideal_key, 0) - - if target_count == 0: - continue - - # Get all cards in this category sorted by score - all_category_cards = pool.get_by_category(category) - - # For lands: only select nonbasics (basics allocated separately based on color identity) - if category == "Land": - # Filter out basics - nonbasic_lands = [c for c in all_category_cards if c.name not in basic_names] - category_cards = sorted( - nonbasic_lands, - key=lambda c: c.synergy_score, - reverse=True - ) - # Reserve space for basics - typically want 15-20 basics minimum - # So select fewer nonbasics to leave room - min_basics_estimate = 15 # Reasonable minimum for most decks - max_nonbasics = max(0, target_count - min_basics_estimate) - selected = category_cards[:max_nonbasics] - logger.info(f"[Synergy] Land: selected {len(selected)} nonbasics (max {max_nonbasics}, leaving room for basics)") - else: - category_cards = sorted( - all_category_cards, - key=lambda c: c.synergy_score, - reverse=True - ) - # Select top cards up to target count - selected = category_cards[:target_count] - - selected_cards.extend(selected) - by_category[category] = selected - - logger.info( - f"[Synergy] {category}: selected {len(selected)}/{target_count} " - f"(pool had {len(category_cards)} available)" - ) - - # Calculate how many basics we'll need before filling remaining slots - target_lands = ideals.get("lands", 35) - current_land_count = len(by_category.get("Land", [])) - estimated_basics = max(0, target_lands - current_land_count) - - # Fill remaining slots with highest-scoring cards from any category (except Land) - # But reserve space for basic lands that will be added later - remaining_slots = target_size - len(selected_cards) - estimated_basics - if remaining_slots > 0: - selected_names = {c.name for c in selected_cards} - # Exclude Land category from filler to avoid over-selecting lands - remaining_pool = [ - c for c in pool.get_top_cards(limit=len(pool.cards)) - if c.name not in selected_names and c.category != "Land" - ] - filler_cards = remaining_pool[:remaining_slots] - selected_cards.extend(filler_cards) - - # Add filler cards to by_category - for card in filler_cards: - by_category.setdefault(card.category, []).append(card) - - logger.info(f"[Synergy] Filled {len(filler_cards)} remaining slots (reserved {estimated_basics} for basics)") - else: - # No ideals provided - fall back to top-scoring cards - logger.info("[Synergy] No ideals provided, selecting top-scoring cards") - sorted_cards = pool.get_top_cards(limit=len(pool.cards)) - selected_cards = sorted_cards[:target_size] - - # Group by category for summary - for card in selected_cards: - by_category.setdefault(card.category, []).append(card) - - # Add basic lands after nonbasics are selected - selected_cards = self._allocate_basic_lands(selected_cards, by_category, pool, ideals) - - # Calculate stats (accounting for multi-copy cards) - unique_cards = len(selected_cards) - total_cards = sum(c.count for c in selected_cards) # Actual card count including duplicates - - # Debug: Check for cards with unexpected counts - cards_with_count = [(c.name, c.count) for c in selected_cards if c.count != 1] - if cards_with_count: - logger.info(f"[Synergy Debug] Cards with count != 1: {cards_with_count[:10]}") - - avg_frequency = sum(c.frequency for c in selected_cards) / unique_cards if unique_cards else 0 - avg_score = sum(c.synergy_score for c in selected_cards) / unique_cards if unique_cards else 0 - high_freq_count = len([c for c in selected_cards if c.frequency >= 0.8]) - - logger.info( - f"[Synergy] Built deck: {total_cards} cards ({unique_cards} unique), " - f"avg frequency={avg_frequency:.2f}, avg score={avg_score:.2f}, " - f"high-frequency cards={high_freq_count}" - ) - - return { - "cards": selected_cards, - "by_category": by_category, - "total_cards": total_cards, # Actual count including duplicates - "unique_cards": unique_cards, # Unique card types - "avg_frequency": round(avg_frequency, 3), - "avg_score": round(avg_score, 2), - "high_frequency_count": high_freq_count, - "commander": pool.config.get("commander"), - "themes": pool.themes - } - - -# Global analyzer instance -_analyzer = SynergyAnalyzer() -_builder = SynergyDeckBuilder(_analyzer) - - -def analyze_and_build_synergy_deck( - builds: List[Dict[str, Any]], - config: Dict[str, Any] -) -> Dict[str, Any]: - """ - Convenience function to analyze builds and create synergy deck in one call. - - Args: - builds: List of build results - config: Original deck configuration (includes ideals) - - Returns: - Synergy deck result dict - """ - pool = _analyzer.analyze_builds(builds, config) - ideals = config.get("ideals", {}) - deck = _builder.build_deck(pool, ideals=ideals) - return deck diff --git a/code/web/services/theme_catalog_loader.py b/code/web/services/theme_catalog_loader.py index e7c6247..c5a88e2 100644 --- a/code/web/services/theme_catalog_loader.py +++ b/code/web/services/theme_catalog_loader.py @@ -26,10 +26,10 @@ from pydantic import BaseModel # - Docker (WORKDIR /app/code): modules also available top-level. # - Package/zip installs (rare): may require 'code.' prefix. try: - from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry + from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore except ImportError: # pragma: no cover - fallback path try: - from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry + from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore except ImportError: # pragma: no cover - last resort (avoid beyond top-level relative import) raise @@ -97,19 +97,11 @@ def _needs_reload() -> bool: if not CATALOG_JSON.exists(): return bool(_CACHE) mtime = CATALOG_JSON.stat().st_mtime - idx: SlugThemeIndex | None = _CACHE.get("index") + idx: SlugThemeIndex | None = _CACHE.get("index") # type: ignore if idx is None: return True if mtime > idx.mtime: return True - - # OPTIMIZATION: Skip YAML scanning unless explicitly enabled via env var. - # Checking 732 YAML files takes ~800ms and is only needed during theme authoring. - # In production, theme_list.json is the source of truth (built from YAMLs offline). - import os as _os - if _os.getenv("THEME_CATALOG_CHECK_YAML_CHANGES") != "1": - return False - # If any YAML newer than catalog mtime or newest YAML newer than cached scan -> reload if YAML_DIR.exists(): import time as _t @@ -121,7 +113,8 @@ def _needs_reload() -> bool: # Fast path: use os.scandir for lower overhead vs Path.glob newest = 0.0 try: - with _os.scandir(YAML_DIR) as it: + import os as _os + with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type] for entry in it: if entry.is_file() and entry.name.endswith('.yml'): try: @@ -164,7 +157,7 @@ def _compute_etag(size: int, mtime: float, yaml_mtime: float) -> str: def load_index() -> SlugThemeIndex: if not _needs_reload(): - return _CACHE["index"] + return _CACHE["index"] # type: ignore if not CATALOG_JSON.exists(): raise FileNotFoundError("theme_list.json missing") raw = json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}") @@ -220,7 +213,7 @@ def validate_catalog_integrity(rebuild: bool = True) -> Dict[str, Any]: out.update({"ok": False, "error": f"read_error:{e}"}) return out # Recompute hash using same heuristic as build script - from scripts.build_theme_catalog import load_catalog_yaml + from scripts.build_theme_catalog import load_catalog_yaml # type: ignore try: yaml_catalog = load_catalog_yaml(verbose=False) # keyed by display_name except Exception: @@ -495,7 +488,7 @@ def prewarm_common_filters(max_archetypes: int = 12) -> None: # Gather archetypes & buckets (limited) archetypes: List[str] = [] try: - archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] + archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] # type: ignore[arg-type] except Exception: archetypes = [] buckets = ["Very Common", "Common", "Uncommon", "Niche", "Rare"] diff --git a/code/web/services/theme_preview.py b/code/web/services/theme_preview.py index cc406af..d1d3991 100644 --- a/code/web/services/theme_preview.py +++ b/code/web/services/theme_preview.py @@ -17,7 +17,7 @@ import json try: import yaml # type: ignore except Exception: # pragma: no cover - PyYAML already in requirements; defensive - yaml = None + yaml = None # type: ignore from .preview_metrics import ( record_build_duration, record_role_counts, @@ -51,8 +51,8 @@ from .preview_cache import ( store_cache_entry, evict_if_needed, ) -from .preview_cache_backend import redis_get -from .preview_metrics import record_redis_get, record_redis_store +from .preview_cache_backend import redis_get # type: ignore +from .preview_metrics import record_redis_get, record_redis_store # type: ignore # Local alias to maintain existing internal variable name usage _PREVIEW_CACHE = PREVIEW_CACHE @@ -66,7 +66,7 @@ __all__ = ["get_theme_preview", "preview_metrics", "bust_preview_cache"] ## (duplicate imports removed) # Legacy constant alias retained for any external references; now a function in cache module. -TTL_SECONDS = ttl_seconds +TTL_SECONDS = ttl_seconds # type: ignore # Per-theme error histogram (P2 observability) _PREVIEW_PER_THEME_ERRORS: Dict[str, int] = {} @@ -89,7 +89,7 @@ def _load_curated_synergy_matrix() -> None: # Expect top-level key 'pairs' but allow raw mapping pairs = data.get('pairs', data) if isinstance(pairs, dict): - _CURATED_SYNERGY_MATRIX = pairs + _CURATED_SYNERGY_MATRIX = pairs # type: ignore else: _CURATED_SYNERGY_MATRIX = None else: diff --git a/code/web/static/js_backup_pre_typescript/app.js b/code/web/static/app.js similarity index 100% rename from code/web/static/js_backup_pre_typescript/app.js rename to code/web/static/app.js diff --git a/code/web/static/css_backup_pre_tailwind/styles.css b/code/web/static/css_backup_pre_tailwind/styles.css deleted file mode 100644 index eda7352..0000000 --- a/code/web/static/css_backup_pre_tailwind/styles.css +++ /dev/null @@ -1,1208 +0,0 @@ -/* Base */ -:root{ - /* MTG color palette (approx from provided values) */ - --banner-h: 52px; - --sidebar-w: 260px; - --green-main: rgb(0,115,62); - --green-light: rgb(196,211,202); - --blue-main: rgb(14,104,171); - --blue-light: rgb(179,206,234); - --red-main: rgb(211,32,42); - --red-light: rgb(235,159,130); - --white-main: rgb(249,250,244); - --white-light: rgb(248,231,185); - --black-main: rgb(21,11,0); - --black-light: rgb(166,159,157); - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; - --ring: #60a5fa; /* focus ring */ - --ok: #16a34a; /* success */ - --warn: #f59e0b; /* warning */ - --err: #ef4444; /* error */ - /* Surface overrides for specific regions (default to panel) */ - --surface-banner: var(--panel); - --surface-banner-text: var(--text); - --surface-sidebar: var(--panel); - --surface-sidebar-text: var(--text); -} - -/* Light blend between Slate and Parchment (leans gray) */ -[data-theme="light-blend"]{ - --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ - --panel: #ffffff; /* crisp panels for readability */ - --text: #0b0d12; - --muted: #6b655d; /* slightly warm muted */ - --border: #d6d1c7; /* neutral warm-gray border */ - /* Slightly darker banner/sidebar for separation */ - --surface-banner: #1a1b1e; - --surface-sidebar: #1a1b1e; - --surface-banner-text: #e8e8e8; - --surface-sidebar-text: #e8e8e8; -} - -[data-theme="dark"]{ - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; -} -[data-theme="high-contrast"]{ - --bg: #000; - --panel: #000; - --text: #fff; - --muted: #e5e7eb; - --border: #fff; - --ring: #ff0; -} -[data-theme="cb-friendly"]{ - /* Tweak accents for color-blind friendliness */ - --green-main: #2e7d32; /* darker green */ - --red-main: #c62828; /* deeper red */ - --blue-main: #1565c0; /* balanced blue */ -} -*{box-sizing:border-box} -html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} -body { - font-family: system-ui, Arial, sans-serif; - margin: 0; - color: var(--text); - background: var(--bg); - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: auto; -} -/* Honor HTML hidden attribute across the app */ -[hidden] { display: none !important; } -/* Accessible focus ring for keyboard navigation */ -.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } -/* Top banner */ -.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); } -.top-banner{ min-height: var(--banner-h); } -.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; } -.top-banner .top-inner > div{ min-width:0; } -@media (max-width: 1100px){ - .top-banner .top-inner{ grid-auto-rows:auto; } - .top-banner .top-inner select{ max-width:140px; } -} -.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; } -.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; } -.banner-status.busy{ color:#fbbf24; } -.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; } -.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; } - -/* Layout */ -.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } -.sidebar{ - background: var(--surface-sidebar); - color: var(--surface-sidebar-text); - border-right: 1px solid var(--border); - padding: 1rem; - position: fixed; - top: var(--banner-h); - left: 0; - bottom: 0; - overflow: auto; - width: var(--sidebar-w); - z-index: 9; /* below the banner (z=10) */ - box-shadow: 2px 0 10px rgba(0,0,0,.18); - display: flex; - flex-direction: column; -} -.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } - -/* Collapsible sidebar behavior */ -body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } -body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } -body.nav-collapsed .content{ grid-column: 2; } -body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; } -body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; } -/* Smooth hide/show on mobile while keeping fixed positioning */ -.sidebar{ transition: transform .2s ease-out, visibility .2s linear; } -/* Suppress sidebar transitions during page load to prevent pop-in */ -body.no-transition .sidebar{ transition: none !important; } -/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ -body.htmx-settling .sidebar{ transition: none !important; } -body.htmx-settling .layout{ transition: none !important; } -body.htmx-settling .content{ transition: none !important; } -body.htmx-settling *{ transition-duration: 0s !important; } - -/* Mobile tweaks */ -@media (max-width: 900px){ - :root{ --sidebar-w: 240px; } - .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; } - .banner-status{ padding-left: .5rem; } - .layout{ grid-template-columns: 0 1fr; } - .sidebar{ transform: translateX(-100%); visibility: hidden; } - body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } - body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } - .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } - .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); } - /* Spacing tweaks: tighter left, larger gaps between visible items */ - .top-banner .top-inner > div{ gap: 25px !important; } - .top-banner .top-inner > div:first-child{ padding-left: 0 !important; } - /* Mobile: show only Menu, Title, and Theme selector */ - #btn-open-permalink{ display:none !important; } - #banner-status{ display:none !important; } - #health-dot{ display:none !important; } - .top-banner #theme-reset{ display:none !important; } -} - -/* Additional mobile spacing for bottom floating controls */ -@media (max-width: 720px) { - .content { - padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ - } -} - -.brand h1{ display:none; } -.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; } -.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } -.dot.green{ background: var(--green-main); } -.dot.blue{ background: var(--blue-main); } -.dot.red{ background: var(--red-main); } -.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } -.dot.black{ background: var(--black-light); } - -.nav{ display:flex; flex-direction:column; gap:.35rem; } -.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } -.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } - -/* Sidebar theme controls anchored at bottom */ -.sidebar .nav { flex: 1 1 auto; } -.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } -.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } -.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; } -.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; } -.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); } - -/* Simple two-column layout for inspect panel */ -.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } -.two-col .grow { min-width: 0; } -.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } -@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } - -/* Left-rail variant puts the image first */ -.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } -/* Ensure left-rail variant also collapses to 1 column on small screens */ -@media (max-width: 900px){ - .two-col.two-col-left-rail{ grid-template-columns: 1fr; } - /* So the commander image doesn't dominate on mobile */ - .two-col .card-preview{ max-width: 360px; margin: 0 auto; } - .two-col .card-preview img{ width: 100%; height: auto; } -} -.card-preview.card-sm{ max-width:200px; } - -/* Buttons, inputs */ -button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } -button:hover{ filter:brightness(1.05); } -/* Anchor-style buttons */ -.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } -.btn:hover{ filter:brightness(1.05); text-decoration:none; } -.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } -label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } -.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } -.color-identity .mana + .mana{ margin-left:4px; } -.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } -.mana-W{ background:#f9fafb; border-color:#d1d5db; } -.mana-U{ background:#3b82f6; border-color:#1d4ed8; } -.mana-B{ background:#111827; border-color:#1f2937; } -.mana-R{ background:#ef4444; border-color:#b91c1c; } -.mana-G{ background:#10b981; border-color:#047857; } -.mana-C{ background:#d3d3d3; border-color:#9ca3af; } -select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } -fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } -small, .muted{ color: var(--muted); } -.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } -.partner-preview[hidden]{ display:none !important; } -.partner-preview__header{ font-weight:600; } -.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } -.partner-preview__art{ flex:0 0 auto; } -.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } -.partner-preview__details{ flex:1 1 180px; min-width:0; } -.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } -.partner-preview__pairing{ margin-top:.35rem; } -.partner-preview__themes{ margin-top:.35rem; font-size:12px; } -.partner-preview--static{ margin-bottom:.5rem; } -.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } - -/* Toasts */ -.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } -.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } -.toast.hide{ opacity:0; transform: translateY(6px); } -.toast.success{ border-color: rgba(22,163,74,.4); } -.toast.error{ border-color: rgba(239,68,68,.45); } -.toast.warn{ border-color: rgba(245,158,11,.45); } - -/* Skeletons */ -[data-skeleton]{ position: relative; } -[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } -[data-skeleton-placeholder]{ display:none; pointer-events:none; } -[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } -[data-skeleton][data-skeleton-overlay="false"]::after, -[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } -[data-skeleton]::after{ - content: ''; - position: absolute; inset: 0; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); - background-size: 200% 100%; - animation: shimmer 1.1s linear infinite; - display: none; -} -[data-skeleton].is-loading::after{ display:block; } -[data-skeleton].is-loading::before{ - content: attr(data-skeleton-label); - position:absolute; - top:50%; - left:50%; - transform:translate(-50%, -50%); - color: var(--muted); - font-size:.85rem; - text-align:center; - line-height:1.4; - max-width:min(92%, 360px); - padding:.3rem .5rem; - pointer-events:none; - z-index:1; - filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); -} -[data-skeleton][data-skeleton-label=""]::before{ content:''; } -@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } - -/* Banner */ -.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } -.banner h1{ font-size: 2rem; margin:0 0 .35rem; } -.banner .subtitle{ color: var(--muted); font-size:.95rem; } - -/* Home actions */ -.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } -.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } -.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } -.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } - -/* Card grid for added cards (responsive, compact tiles) */ -.card-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ - 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; - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 640px 420px; -} -@media (max-width: 420px){ - .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .card-tile{ width: 100%; } - .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } -} -.card-tile{ - width:170px; - position: relative; - background: var(--panel); - border:1px solid var(--border); - border-radius:6px; - padding:.25rem .25rem .4rem; - text-align:center; -} -.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } -.card-tile.locked{ - /* Subtle yellow/goldish-white accent for locked cards */ - border-color: #f5e6a8; /* soft parchment gold */ - box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; -} -.card-tile.must-include{ - border-color: rgba(74,222,128,.85); - box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); -} -.card-tile.must-exclude{ - border-color: rgba(239,68,68,.85); - box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; - opacity: .95; -} -.card-tile.must-include.must-exclude{ - border-color: rgba(249,115,22,.85); - box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; -} -.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } -.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } -.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } - -.must-have-controls{ - display:flex; - justify-content:center; - gap:.35rem; - flex-wrap:wrap; - margin-top:.35rem; -} -.must-have-btn{ - border:1px solid var(--border); - background:rgba(30,41,59,.6); - color:#f8fafc; - font-size:11px; - text-transform:uppercase; - letter-spacing:.06em; - padding:.25rem .6rem; - border-radius:9999px; - cursor:pointer; - transition: all .18s ease; -} -.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ - border-color: rgba(74,222,128,.75); - background: rgba(74,222,128,.18); - color: #bbf7d0; - box-shadow: 0 0 0 1px rgba(16,185,129,.25); -} -.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ - border-color: rgba(239,68,68,.75); - background: rgba(239,68,68,.18); - color: #fecaca; - box-shadow: 0 0 0 1px rgba(239,68,68,.25); -} -.must-have-btn:focus-visible{ - outline:2px solid rgba(59,130,246,.6); - outline-offset:2px; -} -.card-tile.must-exclude .must-have-btn.include[data-active="0"], -.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ - opacity:.65; -} - -.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } -.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } - -/* Shared ownership badge for card tiles and stacked images */ -.owned-badge{ - position:absolute; - top:6px; - left:6px; - background:rgba(17,24,39,.9); - color:#e5e7eb; - border:1px solid var(--border); - border-radius:12px; - font-size:12px; - line-height:18px; - height:18px; - min-width:18px; - padding:0 6px; - text-align:center; - pointer-events:none; - z-index:2; -} - -/* Step 1 candidate grid (200px-wide scaled images) */ -.candidate-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap:.75rem; -} -.candidate-tile{ - background: var(--panel); - border:1px solid var(--border); - border-radius:8px; - padding:.4rem; -} -.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } -.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } -.candidate-tile .meta{ text-align:center; margin-top:.35rem; } -.candidate-tile .name{ font-weight:600; font-size:.95rem; } -.candidate-tile .score{ color:var(--muted); font-size:.85rem; } - -/* Deck summary: highlight game changers */ -.game-changer { color: var(--green-main); } -.stack-card.game-changer { outline: 2px solid var(--green-main); } - -/* Image button inside card tiles */ -.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } - -/* Stage Navigator */ -.stage-nav { margin:.5rem 0 1rem; } -.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } -.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } -.stage-nav .stage-item.done .stage-link { opacity:.75; } -.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } -.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } -.stage-nav .name { font-size:12px; } - -/* Build controls sticky box tweaks */ -.build-controls { - position: sticky; - top: calc(var(--banner-offset, 48px) + 6px); - z-index: 100; - background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 10px; - margin: 0.5rem 0; - box-shadow: 0 4px 12px rgba(0,0,0,.25); -} - -@media (max-width: 1024px){ - :root { --banner-offset: 56px; } - .build-controls { - position: fixed !important; /* Fixed to viewport instead of sticky */ - bottom: 0 !important; /* Anchor to bottom of screen */ - left: 0 !important; - right: 0 !important; - top: auto !important; /* Override top positioning */ - border-radius: 0 !important; /* Remove border radius for full width */ - margin: 0 !important; /* Remove margins for full edge-to-edge */ - padding: 0.5rem !important; /* Reduced padding */ - box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ - border-left: none !important; - border-right: none !important; - border-bottom: none !important; /* Remove bottom border */ - background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; - z-index: 1000 !important; /* Higher z-index to ensure it's above content */ - } -} -@media (min-width: 721px){ - :root { --banner-offset: 48px; } -} - -/* Progress bar */ -.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } -.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } -.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } - -/* Chips */ -.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } -.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } - -/* Cards toolbar */ -.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } -.cards-toolbar input[type="text"]{ min-width: 220px; } -.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } -.cards-toolbar .hint{ color: var(--muted); font-size:12px; } - -/* Collapse groups and reason toggle */ -.group{ margin:.5rem 0; } -.group-header{ display:flex; align-items:center; gap:.5rem; } -.group-header h5{ margin:.4rem 0; } -.group-header .count{ color: var(--muted); font-size:12px; } -.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } -.group-grid[data-collapsed]{ display:none; } -.hide-reasons .card-tile .reason{ display:none; } -.card-tile.force-show .reason{ display:block !important; } -.card-tile.force-hide .reason{ display:none !important; } -.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } -.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } -.chips-inline .chip{ cursor:pointer; user-select:none; } - -/* Inline error banner */ -.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } -.inline-error-banner .muted{ color:#fda4af; } - -/* Alternatives panel */ -.alts ul{ list-style:none; padding:0; margin:0; } -.alts li{ display:flex; align-items:center; gap:.4rem; } -/* LQIP blur/fade-in for thumbnails */ -img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } -img.lqip.loaded { filter: blur(0); opacity: 1; } - -/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ -@media (prefers-reduced-motion: reduce) { - * { scroll-behavior: auto !important; } - img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } -} - -/* Virtualization wrapper should mirror grid to keep multi-column flow */ -.virt-wrapper { display: grid; } - -/* Mobile responsive fixes for horizontal scrolling issues */ -@media (max-width: 768px) { - /* Prevent horizontal overflow */ - html, body { - overflow-x: hidden !important; - width: 100% !important; - max-width: 100vw !important; - } - - /* Test hand responsive adjustments */ - #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } - - /* Modal & form layout fixes (original block retained inside media query) */ - /* Fix modal layout on mobile */ - .modal { - padding: 10px !important; - box-sizing: border-box; - } - .modal-content { - width: 100% !important; - max-width: calc(100vw - 20px) !important; - box-sizing: border-box !important; - overflow-x: hidden !important; - } - /* Force single column for include/exclude grid */ - .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } - /* Fix basics grid */ - .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } - /* Ensure all inputs and textareas fit properly */ - .modal input, - .modal textarea, - .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } - /* Fix chips containers */ - .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } - /* Ensure fieldsets don't overflow */ - .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } - /* Fix any inline styles that might cause overflow */ - .modal fieldset > div, - .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } -} - -@media (max-width: 640px){ - #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } - /* Generic stack shrink */ - .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } -} - -@media (max-width: 560px){ - #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } - #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } - #test-hand .stack-card{ flex:0 0 auto; } - .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } -} - -@media (max-width: 480px) { - .modal-content { - padding: 12px !important; - margin: 5px !important; - } - - .modal fieldset { - padding: 8px !important; - margin: 6px 0 !important; - } - - /* Enhanced mobile build controls */ - .build-controls { - flex-direction: column !important; - gap: 0.25rem !important; /* Reduced gap */ - align-items: stretch !important; - padding: 0.5rem !important; /* Reduced padding */ - } - - /* Two-column grid layout for mobile build controls */ - .build-controls { - display: grid !important; - grid-template-columns: 1fr 1fr !important; /* Two equal columns */ - grid-gap: 0.25rem !important; - align-items: stretch !important; - } - - .build-controls form { - display: contents !important; /* Allow form contents to participate in grid */ - width: auto !important; - } - - .build-controls button { - flex: none !important; - padding: 0.4rem 0.5rem !important; /* Much smaller padding */ - font-size: 12px !important; /* Smaller font */ - min-height: 36px !important; /* Smaller minimum height */ - line-height: 1.2 !important; - width: 100% !important; /* Full width within grid cell */ - box-sizing: border-box !important; - white-space: nowrap !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - /* Hide non-essential elements on mobile to keep it clean */ - .build-controls .sep, - .build-controls .replace-toggle, - .build-controls label[style*="margin-left"] { - display: none !important; - } - - .build-controls .sep { - display: none !important; /* Hide separators on mobile */ - } -} - -/* Desktop sizing for Test Hand */ -@media (min-width: 900px) { - #test-hand { --card-w: 280px !important; --card-h: 392px !important; } -} - -/* Analytics accordion styling */ -.analytics-accordion { - transition: all 0.2s ease; -} - -.analytics-accordion summary { - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.15s ease, border-color 0.15s ease; -} - -.analytics-accordion summary:hover { - background: #1f2937; - border-color: #374151; -} - -.analytics-accordion summary:active { - transform: scale(0.99); -} - -.analytics-accordion[open] summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: 0; -} - -.analytics-accordion .analytics-content { - animation: accordion-slide-down 0.3s ease-out; -} - -@keyframes accordion-slide-down { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.analytics-placeholder .skeleton-pulse { - animation: shimmer 1.5s infinite; -} - -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -/* Ideals Slider Styling */ -.ideals-slider { - -webkit-appearance: none; - appearance: none; - height: 6px; - background: var(--border); - border-radius: 3px; - outline: none; -} - -.ideals-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - background: var(--ring); - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; -} - -.ideals-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} - -.ideals-slider::-moz-range-thumb { - width: 18px; - height: 18px; - background: var(--ring); - border: none; - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; -} - -.ideals-slider::-moz-range-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} - -.slider-value { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 4px; -} - -/* ======================================== - Card Browser Styles - ======================================== */ - -/* Card browser container */ -.card-browser-container { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Filter panel */ -.card-browser-filters { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; -} - -.filter-section { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; -} - -.filter-row label { - font-weight: 600; - min-width: 80px; - color: var(--text); - font-size: 0.95rem; -} - -.filter-row select, -.filter-row input[type="text"], -.filter-row input[type="search"] { - flex: 1; - min-width: 150px; - max-width: 300px; -} - -/* Search bar styling */ -.card-search-wrapper { - position: relative; - flex: 1; - max-width: 100%; -} - -.card-search-wrapper input[type="search"] { - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 1rem; -} - -/* Results count and info bar */ -.card-browser-info { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem 0; -} - -.results-count { - font-size: 0.95rem; - color: var(--muted); -} - -.page-indicator { - font-size: 0.95rem; - color: var(--text); - font-weight: 600; -} - -/* Card browser grid */ -.card-browser-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); - gap: 0.5rem; - padding: 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - min-height: 480px; - justify-content: start; -} - -/* Individual card tile in browser */ -.card-browser-tile { - break-inside: avoid; - display: flex; - flex-direction: column; - background: var(--card-bg, #1a1d24); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; - cursor: pointer; -} - -.card-browser-tile:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); -} - -.card-browser-tile-image { - position: relative; - width: 100%; - aspect-ratio: 488/680; - overflow: hidden; - background: #0a0b0e; -} - -.card-browser-tile-image img { - width: 100%; - height: 100%; - object-fit: contain; - transition: transform 0.3s ease; -} - -.card-browser-tile:hover .card-browser-tile-image img { - transform: scale(1.05); -} - -.card-browser-tile-info { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.card-browser-tile-name { - font-weight: 600; - font-size: 0.95rem; - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; -} - -.card-browser-tile-type { - font-size: 0.85rem; - color: var(--muted); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; -} - -.card-browser-tile-stats { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.85rem; -} - -.card-browser-tile-tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.25rem; -} - -.card-browser-tile-tags .tag { - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - background: rgba(148, 163, 184, 0.15); - color: var(--muted); - border-radius: 3px; - white-space: nowrap; -} - -/* Card Details button on tiles */ -.card-details-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - padding: 0.5rem 0.75rem; - background: var(--primary); - color: white; - text-decoration: none; - border-radius: 6px; - font-weight: 500; - font-size: 0.85rem; - transition: all 0.2s; - margin-top: 0.5rem; - border: none; - cursor: pointer; -} - -.card-details-btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); -} - -.card-details-btn svg { - flex-shrink: 0; -} - -/* Card Preview Modal */ -.preview-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 9999; - align-items: center; - justify-content: center; -} - -.preview-modal.active { - display: flex; -} - -.preview-content { - position: relative; - max-width: 90%; - max-height: 90%; -} - -.preview-content img { - max-width: 100%; - max-height: 90vh; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} - -.preview-close { - position: absolute; - top: -40px; - right: 0; - background: rgba(255, 255, 255, 0.9); - color: #000; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - font-size: 24px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; -} - -.preview-close:hover { - background: #fff; - transform: scale(1.1); -} - -/* Pagination controls */ -.card-browser-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 1rem; - padding: 1rem 0; - flex-wrap: wrap; -} - -.card-browser-pagination .btn { - min-width: 120px; -} - -.card-browser-pagination .page-info { - font-size: 0.95rem; - color: var(--text); - padding: 0 1rem; -} - -/* No results message */ -.no-results { - text-align: center; - padding: 3rem 1rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; -} - -.no-results-title { - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin-bottom: 0.5rem; -} - -.no-results-message { - color: var(--muted); - margin-bottom: 1rem; - line-height: 1.5; -} - -.no-results-filters { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: center; - margin-bottom: 1rem; -} - -.no-results-filter-tag { - padding: 0.25rem 0.75rem; - background: rgba(148, 163, 184, 0.15); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - color: var(--text); -} - -/* Loading indicator */ -.card-browser-loading { - text-align: center; - padding: 2rem; - color: var(--muted); -} - -/* Responsive adjustments */ -/* Large tablets and below - reduce to ~180px cards */ -@media (max-width: 1024px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); - } -} - -/* Tablets - reduce to ~160px cards */ -@media (max-width: 768px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); - gap: 0.5rem; - padding: 0.5rem; - } - - .filter-row { - flex-direction: column; - align-items: stretch; - } - - .filter-row label { - min-width: auto; - } - - .filter-row select, - .filter-row input { - max-width: 100%; - } - - .card-browser-info { - flex-direction: column; - align-items: flex-start; - } -} - -/* Small tablets/large phones - reduce to ~140px cards */ -@media (max-width: 600px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); - gap: 0.5rem; - } -} - -/* Phones - 2 column layout with flexible width */ -@media (max-width: 480px) { - .card-browser-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.375rem; - } - - .card-browser-tile-name { - font-size: 0.85rem; - } - - .card-browser-tile-type { - font-size: 0.75rem; - } - - .card-browser-tile-info { - padding: 0.5rem; - } -} - -/* Theme chips for multi-select */ -.theme-chip { - display: inline-flex; - align-items: center; - background: var(--primary-bg); - color: var(--primary-fg); - padding: 0.25rem 0.75rem; - border-radius: 1rem; - font-size: 0.9rem; - border: 1px solid var(--border-color); -} - -.theme-chip button { - margin-left: 0.5rem; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - font-weight: bold; - font-size: 1.2rem; - line-height: 1; -} - -.theme-chip button:hover { - color: var(--error-color); -} - -/* Card Detail Page Styles */ -.card-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; - margin-bottom: 1rem; -} - -.card-tag { - background: var(--ring); - color: white; - padding: 0.35rem 0.75rem; - border-radius: 16px; - font-size: 0.85rem; - font-weight: 500; -} - -.back-button { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: var(--panel); - color: var(--text); - text-decoration: none; - border-radius: 8px; - border: 1px solid var(--border); - font-weight: 500; - transition: all 0.2s; - margin-bottom: 2rem; -} - -.back-button:hover { - background: var(--ring); - color: white; - border-color: var(--ring); -} - -/* Card Detail Page - Main Card Image */ -.card-image-large { - flex: 0 0 auto; - max-width: 360px !important; - width: 100%; -} - -.card-image-large img { - width: 100%; - height: auto; - border-radius: 12px; -} diff --git a/code/web/static/js_backup_pre_typescript/components.js b/code/web/static/js_backup_pre_typescript/components.js deleted file mode 100644 index de4021c..0000000 --- a/code/web/static/js_backup_pre_typescript/components.js +++ /dev/null @@ -1,375 +0,0 @@ -/** - * M2 Component Library - JavaScript Utilities - * - * Core functions for interactive components: - * - Card flip button (dual-faced cards) - * - Collapsible panels - * - Card popups - * - Modal management - */ - -// ============================================ -// CARD FLIP FUNCTIONALITY -// ============================================ - -/** - * Flip a dual-faced card image between front and back faces - * @param {HTMLElement} button - The flip button element - */ -function flipCard(button) { - const container = button.closest('.card-thumb-container, .card-popup-image'); - if (!container) return; - - const img = container.querySelector('img'); - if (!img) return; - - const cardName = img.dataset.cardName; - if (!cardName) return; - - const faces = cardName.split(' // '); - if (faces.length < 2) return; - - // Determine current face (default to 0 = front) - const currentFace = parseInt(img.dataset.currentFace || '0', 10); - const nextFace = currentFace === 0 ? 1 : 0; - const faceName = faces[nextFace]; - - // Determine image version based on container - const isLarge = container.classList.contains('card-thumb-large') || - container.classList.contains('card-popup-image'); - const version = isLarge ? 'normal' : 'small'; - - // Update image source - img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`; - img.alt = `${faceName} image`; - img.dataset.currentFace = nextFace.toString(); - - // Update button aria-label - const otherFace = faces[currentFace]; - button.setAttribute('aria-label', `Flip to ${otherFace}`); -} - -/** - * Reset all card images to show front face - * Useful when navigating between pages or clearing selections - */ -function resetCardFaces() { - document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => { - const cardName = img.dataset.cardName; - const faces = cardName.split(' // '); - if (faces.length > 1) { - const frontFace = faces[0]; - const container = img.closest('.card-thumb-container, .card-popup-image'); - const isLarge = container && (container.classList.contains('card-thumb-large') || - container.classList.contains('card-popup-image')); - const version = isLarge ? 'normal' : 'small'; - - img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`; - img.alt = `${frontFace} image`; - img.dataset.currentFace = '0'; - } - }); -} - -// ============================================ -// COLLAPSIBLE PANEL FUNCTIONALITY -// ============================================ - -/** - * Toggle a collapsible panel's expanded/collapsed state - * @param {string} panelId - The ID of the panel element - */ -function togglePanel(panelId) { - const panel = document.getElementById(panelId); - if (!panel) return; - - const button = panel.querySelector('.panel-toggle'); - const content = panel.querySelector('.panel-collapse-content'); - if (!button || !content) return; - - const isExpanded = button.getAttribute('aria-expanded') === 'true'; - - // Toggle state - button.setAttribute('aria-expanded', (!isExpanded).toString()); - content.style.display = isExpanded ? 'none' : 'block'; - - // Toggle classes - panel.classList.toggle('panel-expanded', !isExpanded); - panel.classList.toggle('panel-collapsed', isExpanded); -} - -/** - * Expand a collapsible panel - * @param {string} panelId - The ID of the panel element - */ -function expandPanel(panelId) { - const panel = document.getElementById(panelId); - if (!panel) return; - - const button = panel.querySelector('.panel-toggle'); - const content = panel.querySelector('.panel-collapse-content'); - if (!button || !content) return; - - button.setAttribute('aria-expanded', 'true'); - content.style.display = 'block'; - panel.classList.add('panel-expanded'); - panel.classList.remove('panel-collapsed'); -} - -/** - * Collapse a collapsible panel - * @param {string} panelId - The ID of the panel element - */ -function collapsePanel(panelId) { - const panel = document.getElementById(panelId); - if (!panel) return; - - const button = panel.querySelector('.panel-toggle'); - const content = panel.querySelector('.panel-collapse-content'); - if (!button || !content) return; - - button.setAttribute('aria-expanded', 'false'); - content.style.display = 'none'; - panel.classList.add('panel-collapsed'); - panel.classList.remove('panel-expanded'); -} - -// ============================================ -// MODAL MANAGEMENT -// ============================================ - -/** - * Open a modal by ID - * @param {string} modalId - The ID of the modal element - */ -function openModal(modalId) { - const modal = document.getElementById(modalId); - if (!modal) return; - - modal.style.display = 'flex'; - document.body.style.overflow = 'hidden'; - - // Focus first focusable element in modal - const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); - if (focusable) { - setTimeout(() => focusable.focus(), 100); - } -} - -/** - * Close a modal by ID or element - * @param {string|HTMLElement} modalOrId - Modal element or ID - */ -function closeModal(modalOrId) { - const modal = typeof modalOrId === 'string' - ? document.getElementById(modalOrId) - : modalOrId; - - if (!modal) return; - - modal.remove(); - - // Restore body scroll if no other modals are open - if (!document.querySelector('.modal')) { - document.body.style.overflow = ''; - } -} - -/** - * Close all open modals - */ -function closeAllModals() { - document.querySelectorAll('.modal').forEach(modal => modal.remove()); - document.body.style.overflow = ''; -} - -// ============================================ -// CARD POPUP FUNCTIONALITY -// ============================================ - -/** - * Show card details popup on hover or tap - * @param {string} cardName - The card name - * @param {Object} options - Popup options - * @param {string[]} options.tags - Card tags - * @param {string[]} options.highlightTags - Tags to highlight - * @param {string} options.role - Card role - * @param {string} options.layout - Card layout (for flip button) - */ -function showCardPopup(cardName, options = {}) { - // Remove any existing popup - closeCardPopup(); - - const { - tags = [], - highlightTags = [], - role = '', - layout = 'normal' - } = options; - - const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout); - const baseName = cardName.split(' // ')[0]; - - // Create popup HTML - const popup = document.createElement('div'); - popup.className = 'card-popup'; - popup.setAttribute('role', 'dialog'); - popup.setAttribute('aria-label', `${cardName} details`); - - let tagsHTML = ''; - if (tags.length > 0) { - tagsHTML = '
'; - tags.forEach(tag => { - const isHighlight = highlightTags.includes(tag); - tagsHTML += `${tag}`; - }); - tagsHTML += '
'; - } - - let roleHTML = ''; - if (role) { - roleHTML = `
Role: ${role}
`; - } - - let flipButtonHTML = ''; - if (isDFC) { - flipButtonHTML = ` - - `; - } - - popup.innerHTML = ` -
-
-
- ${cardName} image - ${flipButtonHTML} -
-
-

${cardName}

- ${roleHTML} - ${tagsHTML} -
- -
- `; - - document.body.appendChild(popup); - document.body.style.overflow = 'hidden'; - - // Focus close button - const closeBtn = popup.querySelector('.card-popup-close'); - if (closeBtn) { - setTimeout(() => closeBtn.focus(), 100); - } -} - -/** - * Close card popup - * @param {HTMLElement} [element] - Element to search from (optional) - */ -function closeCardPopup(element) { - const popup = element - ? element.closest('.card-popup') - : document.querySelector('.card-popup'); - - if (popup) { - popup.remove(); - - // Restore body scroll if no modals are open - if (!document.querySelector('.modal')) { - document.body.style.overflow = ''; - } - } -} - -/** - * Setup card thumbnail hover/tap events - * Call this after dynamically adding card thumbnails to the DOM - */ -function setupCardPopups() { - document.querySelectorAll('.card-thumb-container[data-card-name]').forEach(container => { - const img = container.querySelector('.card-thumb'); - if (!img) return; - - const cardName = container.dataset.cardName || img.dataset.cardName; - if (!cardName) return; - - // Desktop: hover - container.addEventListener('mouseenter', function(e) { - if (window.innerWidth > 768) { - const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean); - const role = img.dataset.role || ''; - const layout = img.dataset.layout || 'normal'; - - showCardPopup(cardName, { tags, highlightTags: [], role, layout }); - } - }); - - // Mobile: tap - container.addEventListener('click', function(e) { - if (window.innerWidth <= 768) { - e.preventDefault(); - - const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean); - const role = img.dataset.role || ''; - const layout = img.dataset.layout || 'normal'; - - showCardPopup(cardName, { tags, highlightTags: [], role, layout }); - } - }); - }); -} - -// ============================================ -// INITIALIZATION -// ============================================ - -// Setup event listeners when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - // Setup card popups on initial load - setupCardPopups(); - - // Close modals/popups on Escape key - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeCardPopup(); - - // Close topmost modal only - const modals = document.querySelectorAll('.modal'); - if (modals.length > 0) { - closeModal(modals[modals.length - 1]); - } - } - }); - }); -} else { - // DOM already loaded - setupCardPopups(); -} - -// Export functions for use in other scripts or inline handlers -if (typeof module !== 'undefined' && module.exports) { - module.exports = { - flipCard, - resetCardFaces, - togglePanel, - expandPanel, - collapsePanel, - openModal, - closeModal, - closeAllModals, - showCardPopup, - closeCardPopup, - setupCardPopups - }; -} diff --git a/code/web/static/shared-components.css b/code/web/static/shared-components.css deleted file mode 100644 index 986f565..0000000 --- a/code/web/static/shared-components.css +++ /dev/null @@ -1,655 +0,0 @@ -/* Shared Component Styles - Not processed by Tailwind PurgeCSS */ - -/* Card-style list items (used in theme catalog, commander browser, etc.) */ -.theme-list-card { - background: var(--panel); - padding: 0.6rem 0.75rem; - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: background-color 0.15s ease; -} - -.theme-list-card:hover { - background: var(--hover); -} - -/* Filter chips (used in theme catalog, card browser, etc.) */ -.filter-chip { - background: var(--panel-alt); - border: 1px solid var(--border); - padding: 2px 8px; - border-radius: 14px; - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; -} - -.filter-chip-remove { - background: none; - border: none; - cursor: pointer; - font-size: 12px; - padding: 0; - line-height: 1; -} - -/* Loading skeleton cards (used in theme catalog, deck lists, etc.) */ -.skeleton-card { - height: 48px; - border-radius: 8px; - background: linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%); - background-size: 200% 100%; - animation: sk 1.2s ease-in-out infinite; -} - -/* Search suggestion dropdowns (used in theme catalog, card search, etc.) */ -.search-suggestions { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--panel); - border: 1px solid var(--border); - border-top: none; - z-index: 25; - display: none; - max-height: 300px; - overflow: auto; - border-radius: 0 0 8px 8px; -} - -.search-suggestions a { - display: block; - padding: 0.5rem 0.6rem; - font-size: 13px; - text-decoration: none; - color: var(--text); - border-bottom: 1px solid var(--border); - transition: background 0.15s ease; -} - -.search-suggestions a:last-child { - border-bottom: none; -} - -.search-suggestions a:hover, -.search-suggestions a.selected { - background: var(--hover); -} - -.search-suggestions a.selected { - border-left: 3px solid var(--ring); - padding-left: calc(0.6rem - 3px); -} - -/* Card reference links (clickable card names with hover preview) */ -.card-ref { - cursor: pointer; - text-decoration: underline dotted; -} - -.card-ref:hover { - color: var(--accent); -} - -/* Modal components (used in new deck modal, settings modals, etc.) */ -.modal-overlay { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 1rem; - overflow: auto; -} - -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); -} - -.modal-content { - position: relative; - max-width: 720px; - width: clamp(320px, 90vw, 720px); - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - padding: 1rem; - max-height: min(92vh, 100%); - overflow: auto; - -webkit-overflow-scrolling: touch; -} - -/* Form field components */ -.form-label { - display: block; - margin-bottom: 0.5rem; -} - -.form-checkbox-label { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - column-gap: 0.5rem; - margin: 0; - width: 100%; - cursor: pointer; - text-align: left; -} - -.form-checkbox-label input[type="checkbox"], -.form-checkbox-label input[type="radio"] { - margin: 0; - cursor: pointer; -} - -/* Include/Exclude card chips (green/red themed) */ -.include-chips-container { - margin-top: 0.5rem; - min-height: 30px; - border: 1px solid #4ade80; - border-radius: 6px; - padding: 0.5rem; - background: rgba(74, 222, 128, 0.05); - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - align-items: flex-start; -} - -.exclude-chips-container { - margin-top: 0.5rem; - min-height: 30px; - border: 1px solid #ef4444; - border-radius: 6px; - padding: 0.5rem; - background: rgba(239, 68, 68, 0.05); - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - align-items: flex-start; -} - -.chips-inner { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - flex: 1; -} - -.chips-placeholder { - color: #6b7280; - font-size: 11px; - font-style: italic; -} - -/* Card list textarea styling */ -.include-textarea { - width: 100%; - min-height: 60px; - resize: vertical; - font-family: monospace; - font-size: 12px; - border-left: 3px solid #4ade80; - border-right: 1px solid var(--border); - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - color: var(--text); - background: var(--bg); -} - -.include-textarea::placeholder { - color: var(--muted); - opacity: 0.7; -} - -/* Alternative card buttons - force text wrapping */ -.alt-option { - display: block !important; - width: 100% !important; - max-width: 100% !important; - text-align: left !important; - white-space: normal !important; - word-wrap: break-word !important; - overflow-wrap: break-word !important; - line-height: 1.3 !important; - padding: 0.5rem 0.7rem !important; -} - -.exclude-textarea { - width: 100%; - min-height: 60px; - resize: vertical; - font-family: monospace; - font-size: 12px; - border-left: 3px solid #ef4444; - border-right: 1px solid var(--border); - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); - color: var(--text); - background: var(--bg); -} - -.exclude-textarea::placeholder { - color: var(--muted); - opacity: 0.7; -} - -/* Info/warning panels */ -.info-panel { - margin-top: 0.75rem; - padding: 0.5rem; - background: rgba(59, 130, 246, 0.1); - border: 1px solid rgba(59, 130, 246, 0.3); - border-radius: 6px; -} - -.info-panel summary { - cursor: pointer; - font-size: 12px; - color: #60a5fa; -} - -.info-panel-content { - margin-top: 0.5rem; - font-size: 12px; - line-height: 1.5; -} - -/* Include/Exclude card list helpers */ -.include-exclude-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - margin-top: 0.5rem; -} - -@media (max-width: 768px) { - .include-exclude-grid { - grid-template-columns: 1fr; - } -} - -.card-list-label { - display: block; - margin-bottom: 0.5rem; -} - -.card-list-label small { - color: #9ca3af; - opacity: 1; -} - -.card-list-label-include { - color: #4ade80; - font-weight: 500; -} - -.card-list-label-exclude { - color: #ef4444; - font-weight: 500; -} - -.card-list-controls { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0.5rem; - font-size: 12px; -} - -.card-list-count { - font-size: 11px; -} - -.card-list-validation { - margin-top: 0.5rem; - font-size: 12px; -} - -.card-list-badges { - display: flex; - gap: 0.25rem; - font-size: 10px; -} - -/* Button variants for include/exclude controls */ -.btn-upload-include { - cursor: pointer; - font-size: 11px; - padding: 0.25rem 0.5rem; - background: #065f46; - border-color: #059669; -} - -.btn-upload-exclude { - cursor: pointer; - font-size: 11px; - padding: 0.25rem 0.5rem; - background: #7f1d1d; - border-color: #dc2626; -} - -.btn-clear { - font-size: 11px; - padding: 0.25rem 0.5rem; - background: #7f1d1d; - border-color: #dc2626; -} - -/* Modal footer */ -.modal-footer { - display: flex; - gap: 0.5rem; - justify-content: space-between; - margin-top: 1rem; -} - -.modal-footer-left { - display: flex; - gap: 0.5rem; -} - -/* Chip dot color variants */ -.dot-green { - background: var(--green-main); -} - -.dot-blue { - background: var(--blue-main); -} - -.dot-orange { - background: var(--orange-main, #f97316); -} - -.dot-red { - background: var(--red-main); -} - -.dot-purple { - background: var(--purple-main, #a855f7); -} - -/* Form label with icon */ -.form-label-icon { - display: flex; - align-items: center; - gap: 0.35rem; -} - -/* Inline form (for control buttons) */ -.inline-form { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -/* Locked cards list */ -.locked-list { - list-style: none; - padding: 0; - margin: 0.35rem 0 0; - display: grid; - gap: 0.35rem; -} - -.locked-item { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.lock-box-inline { - display: inline; - margin-left: auto; -} - -/* Build controls sticky section */ -.build-controls { - position: sticky; - z-index: 5; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 0.5rem; - margin-top: 1rem; - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - align-items: center; -} - -/* Alert box */ -.alert-error { - margin-top: 0.5rem; - color: #fecaca; - background: #7f1d1d; - border: 1px solid #991b1b; - padding: 0.5rem 0.75rem; - border-radius: 8px; -} - -/* Stage timeline list */ -.timeline-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - gap: 0.25rem; -} - -.timeline-item { - display: flex; - align-items: center; - gap: 0.5rem; -} - -/* Card action buttons container */ -.card-actions-center { - display: flex; - justify-content: center; - margin-top: 0.25rem; - gap: 0.35rem; - flex-wrap: wrap; -} - -/* Ownership badge (small circular indicator) */ -.ownership-badge { - display: inline-block; - border: 1px solid var(--border); - background: var(--panel); - color: var(--text); - border-radius: 12px; - font-size: 12px; - line-height: 18px; - height: 18px; - min-width: 18px; - padding: 0 6px; - text-align: center; -} - -/* Build log pre formatting */ -.build-log { - margin-top: 0.5rem; - white-space: pre-wrap; - background: var(--panel); - border: 1px solid var(--border); - padding: 1rem; - border-radius: 8px; - max-height: 40vh; - overflow: auto; -} - -/* Last action status area (prevents layout shift) */ -.last-action { - min-height: 1.5rem; -} - -/* Deck summary section divider */ -.summary-divider { - margin: 1.25rem 0; - border-color: var(--border); -} - -/* Summary type heading */ -.summary-type-heading { - margin: 0.5rem 0 0.25rem 0; - font-weight: 600; -} - -/* Summary view controls */ -.summary-view-controls { - margin: 0.5rem 0 0.25rem 0; - display: flex; - gap: 0.5rem; - align-items: center; -} - -/* Summary section spacing */ -.summary-section { - margin-top: 0.5rem; -} - -.summary-section-lg { - margin-top: 1rem; -} - -/* Land breakdown note chips */ -.land-note-chip-expand { - background: #0f172a; - border-color: #34d399; - color: #a7f3d0; -} - -.land-note-chip-counts { - background: #111827; - border-color: #60a5fa; - color: #bfdbfe; -} - -/* Land breakdown list */ -.land-breakdown-list { - list-style: none; - padding: 0; - margin: 0.35rem 0 0; - display: grid; - gap: 0.35rem; -} - -.land-breakdown-item { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - align-items: flex-start; -} - -.land-breakdown-subs { - list-style: none; - padding: 0; - margin: 0.2rem 0 0; - display: grid; - gap: 0.15rem; - flex: 1 0 100%; -} - -.land-breakdown-sub { - font-size: 0.85rem; - color: #e5e7eb; - opacity: 0.85; -} - -/* Deck metrics wrap */ -.deck-metrics-wrap { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: flex-start; -} - -/* Combo summary styling */ -.combo-summary { - cursor: pointer; - user-select: none; - padding: 0.5rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--panel); - font-weight: 600; - transition: background-color 0.15s ease; -} - -.combo-summary:hover { - background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); - border-color: var(--text); -} - -/* Mana analytics row grid */ -.mana-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 16px; - align-items: stretch; -} - -/* Mana panel container */ -.mana-panel { - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.6rem; - background: var(--panel); -} - -/* Mana panel heading */ -.mana-panel-heading { - margin-bottom: 0.35rem; - font-weight: 600; -} - -/* Chart bars container */ -.chart-bars { - display: flex; - gap: 14px; - align-items: flex-end; - height: 140px; -} - -/* Chart column center-aligned text */ -.chart-column { - text-align: center; -} - -/* Chart SVG cursor */ -.chart-svg { - cursor: pointer; -} - -/* Existing card tile styles (for reference/consolidation) */ -.card-tile { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.75rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: background-color 0.15s ease; -} - -.card-tile:hover { - background: var(--hover); -} - -/* Theme detail card styles (for reference/consolidation) */ -.theme-detail-card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); -} diff --git a/code/web/static/styles.css b/code/web/static/styles.css index d0593a6..6992feb 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -1,5689 +1,680 @@ -/* Tailwind CSS Entry Point */ - -*, ::before, ::after { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} - -::backdrop { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: ; -} - -/* ! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com */ - -/* -1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) -2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) -*/ - -*, -::before, -::after { - box-sizing: border-box; - /* 1 */ - border-width: 0; - /* 2 */ - border-style: solid; - /* 2 */ - border-color: #e5e7eb; - /* 2 */ -} - -::before, -::after { - --tw-content: ''; -} - -/* -1. Use a consistent sensible line-height in all browsers. -2. Prevent adjustments of font size after orientation changes in iOS. -3. Use a more readable tab size. -4. Use the user's configured `sans` font-family by default. -5. Use the user's configured `sans` font-feature-settings by default. -6. Use the user's configured `sans` font-variation-settings by default. -7. Disable tap highlights on iOS -*/ - -html, -:host { - line-height: 1.5; - /* 1 */ - -webkit-text-size-adjust: 100%; - /* 2 */ - -moz-tab-size: 4; - /* 3 */ - -o-tab-size: 4; - tab-size: 4; - /* 3 */ - font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - /* 4 */ - font-feature-settings: normal; - /* 5 */ - font-variation-settings: normal; - /* 6 */ - -webkit-tap-highlight-color: transparent; - /* 7 */ -} - -/* -1. Remove the margin in all browsers. -2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. -*/ - -body { - margin: 0; - /* 1 */ - line-height: inherit; - /* 2 */ -} - -/* -1. Add the correct height in Firefox. -2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) -3. Ensure horizontal rules are visible by default. -*/ - -hr { - height: 0; - /* 1 */ - color: inherit; - /* 2 */ - border-top-width: 1px; - /* 3 */ -} - -/* -Add the correct text decoration in Chrome, Edge, and Safari. -*/ - -abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} - -/* -Remove the default font size and weight for headings. -*/ - -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: inherit; - font-weight: inherit; -} - -/* -Reset links to optimize for opt-in styling instead of opt-out. -*/ - -a { - color: inherit; - text-decoration: inherit; -} - -/* -Add the correct font weight in Edge and Safari. -*/ - -b, -strong { - font-weight: bolder; -} - -/* -1. Use the user's configured `mono` font-family by default. -2. Use the user's configured `mono` font-feature-settings by default. -3. Use the user's configured `mono` font-variation-settings by default. -4. Correct the odd `em` font sizing in all browsers. -*/ - -code, -kbd, -samp, -pre { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - /* 1 */ - font-feature-settings: normal; - /* 2 */ - font-variation-settings: normal; - /* 3 */ - font-size: 1em; - /* 4 */ -} - -/* -Add the correct font size in all browsers. -*/ - -small { - font-size: 80%; -} - -/* -Prevent `sub` and `sup` elements from affecting the line height in all browsers. -*/ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -/* -1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) -2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) -3. Remove gaps between table borders by default. -*/ - -table { - text-indent: 0; - /* 1 */ - border-color: inherit; - /* 2 */ - border-collapse: collapse; - /* 3 */ -} - -/* -1. Change the font styles in all browsers. -2. Remove the margin in Firefox and Safari. -3. Remove default padding in all browsers. -*/ - -button, -input, -optgroup, -select, -textarea { - font-family: inherit; - /* 1 */ - font-feature-settings: inherit; - /* 1 */ - font-variation-settings: inherit; - /* 1 */ - font-size: 100%; - /* 1 */ - font-weight: inherit; - /* 1 */ - line-height: inherit; - /* 1 */ - letter-spacing: inherit; - /* 1 */ - color: inherit; - /* 1 */ - margin: 0; - /* 2 */ - padding: 0; - /* 3 */ -} - -/* -Remove the inheritance of text transform in Edge and Firefox. -*/ - -button, -select { - text-transform: none; -} - -/* -1. Correct the inability to style clickable types in iOS and Safari. -2. Remove default button styles. -*/ - -button, -input:where([type='button']), -input:where([type='reset']), -input:where([type='submit']) { - -webkit-appearance: button; - /* 1 */ - background-color: transparent; - /* 2 */ - background-image: none; - /* 2 */ -} - -/* -Use the modern Firefox focus style for all focusable elements. -*/ - -:-moz-focusring { - outline: auto; -} - -/* -Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) -*/ - -:-moz-ui-invalid { - box-shadow: none; -} - -/* -Add the correct vertical alignment in Chrome and Firefox. -*/ - -progress { - vertical-align: baseline; -} - -/* -Correct the cursor style of increment and decrement buttons in Safari. -*/ - -::-webkit-inner-spin-button, -::-webkit-outer-spin-button { - height: auto; -} - -/* -1. Correct the odd appearance in Chrome and Safari. -2. Correct the outline style in Safari. -*/ - -[type='search'] { - -webkit-appearance: textfield; - /* 1 */ - outline-offset: -2px; - /* 2 */ -} - -/* -Remove the inner padding in Chrome and Safari on macOS. -*/ - -::-webkit-search-decoration { - -webkit-appearance: none; -} - -/* -1. Correct the inability to style clickable types in iOS and Safari. -2. Change font properties to `inherit` in Safari. -*/ - -::-webkit-file-upload-button { - -webkit-appearance: button; - /* 1 */ - font: inherit; - /* 2 */ -} - -/* -Add the correct display in Chrome and Safari. -*/ - -summary { - display: list-item; -} - -/* -Removes the default spacing and border for appropriate elements. -*/ - -blockquote, -dl, -dd, -h1, -h2, -h3, -h4, -h5, -h6, -hr, -figure, -p, -pre { - margin: 0; -} - -fieldset { - margin: 0; - padding: 0; -} - -legend { - padding: 0; -} - -ol, -ul, -menu { - list-style: none; - margin: 0; - padding: 0; -} - -/* -Reset default styling for dialogs. -*/ - -dialog { - padding: 0; -} - -/* -Prevent resizing textareas horizontally by default. -*/ - -textarea { - resize: vertical; -} - -/* -1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) -2. Set the default placeholder color to the user's configured gray 400 color. -*/ - -input::-moz-placeholder, textarea::-moz-placeholder { - opacity: 1; - /* 1 */ - color: #9ca3af; - /* 2 */ -} - -input::placeholder, -textarea::placeholder { - opacity: 1; - /* 1 */ - color: #9ca3af; - /* 2 */ -} - -/* -Set the default cursor for buttons. -*/ - -button, -[role="button"] { - cursor: pointer; -} - -/* -Make sure disabled buttons don't get the pointer cursor. -*/ - -:disabled { - cursor: default; -} - -/* -1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) -2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) - This can trigger a poorly considered lint error in some tools but is included by design. -*/ - -img, -svg, -video, -canvas, -audio, -iframe, -embed, -object { - display: block; - /* 1 */ - vertical-align: middle; - /* 2 */ -} - -/* -Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) -*/ - -img, -video { - max-width: 100%; - height: auto; -} - -/* Make elements with the HTML hidden attribute stay hidden by default */ - -[hidden]:where(:not([hidden="until-found"])) { - display: none; -} - -.\!container { - width: 100% !important; -} - -.container { - width: 100%; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - -.visible { - visibility: visible; -} - -.collapse { - visibility: collapse; -} - -.fixed { - position: fixed; -} - -.absolute { - position: absolute; -} - -.relative { - position: relative; -} - -.sticky { - position: sticky; -} - -.m-0 { - margin: 0px; -} - -.-my-1\.5 { - margin-top: -0.375rem; - margin-bottom: -0.375rem; -} - -.my-1 { - margin-top: 0.25rem; - margin-bottom: 0.25rem; -} - -.my-1\.5 { - margin-top: 0.375rem; - margin-bottom: 0.375rem; -} - -.my-2 { - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - -.my-3\.5 { - margin-top: 0.875rem; - margin-bottom: 0.875rem; -} - -.mb-1 { - margin-bottom: 0.25rem; -} - -.mb-1\.5 { - margin-bottom: 0.375rem; -} - -.mb-2 { - margin-bottom: 0.5rem; -} - -.mb-3 { - margin-bottom: 0.75rem; -} - -.mb-3\.5 { - margin-bottom: 0.875rem; -} - -.mb-4 { - margin-bottom: 1rem; -} - -.ml-1 { - margin-left: 0.25rem; -} - -.ml-2 { - margin-left: 0.5rem; -} - -.ml-6 { - margin-left: 1.5rem; -} - -.ml-auto { - margin-left: auto; -} - -.mr-2 { - margin-right: 0.5rem; -} - -.mt-0 { - margin-top: 0px; -} - -.mt-0\.5 { - margin-top: 0.125rem; -} - -.mt-1 { - margin-top: 0.25rem; -} - -.mt-1\.5 { - margin-top: 0.375rem; -} - -.mt-2 { - margin-top: 0.5rem; -} - -.mt-3 { - margin-top: 0.75rem; -} - -.mt-4 { - margin-top: 1rem; -} - -.\!block { - display: block !important; -} - -.block { - display: block; -} - -.inline-block { - display: inline-block; -} - -.inline { - display: inline; -} - -.flex { - display: flex; -} - -.inline-flex { - display: inline-flex; -} - -.table { - display: table; -} - -.\!grid { - display: grid !important; -} - -.grid { - display: grid; -} - -.hidden { - display: none; -} - -.h-12 { - height: 3rem; -} - -.h-auto { - height: auto; -} - -.min-h-\[1\.1em\] { - min-height: 1.1em; -} - -.min-h-\[1rem\] { - min-height: 1rem; -} - -.w-24 { - width: 6rem; -} - -.w-full { - width: 100%; -} - -.min-w-\[160px\] { - min-width: 160px; -} - -.min-w-\[2\.5rem\] { - min-width: 2.5rem; -} - -.min-w-\[220px\] { - min-width: 220px; -} - -.max-w-\[230px\] { - max-width: 230px; -} - -.flex-1 { - flex: 1 1 0%; -} - -.flex-shrink { - flex-shrink: 1; -} - -.grow { - flex-grow: 1; -} - -.border-collapse { - border-collapse: collapse; -} - -.transform { - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - -.cursor-pointer { - cursor: pointer; -} - -.select-all { - -webkit-user-select: all; - -moz-user-select: all; - user-select: all; -} - -.resize { - resize: both; -} - -.list-none { - list-style-type: none; -} - -.grid-cols-\[2fr_1fr\] { - grid-template-columns: 2fr 1fr; -} - -.grid-cols-\[repeat\(auto-fill\2c minmax\(230px\2c 1fr\)\)\] { - grid-template-columns: repeat(auto-fill,minmax(230px,1fr)); -} - -.flex-row { - flex-direction: row; -} - -.flex-col { - flex-direction: column; -} - -.flex-wrap { - flex-wrap: wrap; -} - -.items-start { - align-items: flex-start; -} - -.items-end { - align-items: flex-end; -} - -.items-center { - align-items: center; -} - -.justify-center { - justify-content: center; -} - -.justify-between { - justify-content: space-between; -} - -.gap-1 { - gap: 0.25rem; -} - -.gap-1\.5 { - gap: 0.375rem; -} - -.gap-2 { - gap: 0.5rem; -} - -.gap-2\.5 { - gap: 0.625rem; -} - -.gap-3 { - gap: 0.75rem; -} - -.gap-3\.5 { - gap: 0.875rem; -} - -.gap-4 { - gap: 1rem; -} - -.overflow-hidden { - overflow: hidden; -} - -.text-ellipsis { - text-overflow: ellipsis; -} - -.whitespace-nowrap { - white-space: nowrap; -} - -.rounded-\[10px\] { - border-radius: 10px; -} - -.rounded-lg { - border-radius: 0.5rem; -} - -.rounded-md { - border-radius: 0.375rem; -} - -.border { - border-width: 1px; -} - -.border-0 { - border-width: 0px; -} - -.border-\[var\(--border\)\] { - border-color: var(--border); -} - -.bg-gray-700 { - --tw-bg-opacity: 1; - background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); -} - -.p-0 { - padding: 0px; -} - -.p-2 { - padding: 0.5rem; -} - -.px-1\.5 { - padding-left: 0.375rem; - padding-right: 0.375rem; -} - -.px-2 { - padding-left: 0.5rem; - padding-right: 0.5rem; -} - -.py-0\.5 { - padding-top: 0.125rem; - padding-bottom: 0.125rem; -} - -.py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem; -} - -.text-left { - text-align: left; -} - -.text-center { - text-align: center; -} - -.text-\[11px\] { - font-size: 11px; -} - -.text-\[13px\] { - font-size: 13px; -} - -.text-lg { - font-size: 1.125rem; - line-height: 1.75rem; -} - -.text-sm { - font-size: 0.875rem; - line-height: 1.25rem; -} - -.text-xs { - font-size: 0.75rem; - line-height: 1rem; -} - -.font-medium { - font-weight: 500; -} - -.font-normal { - font-weight: 400; -} - -.font-semibold { - font-weight: 600; -} - -.uppercase { - text-transform: uppercase; -} - -.capitalize { - text-transform: capitalize; -} - -.italic { - font-style: italic; -} - -.text-\[var\(--text\)\] { - color: var(--text); -} - -.text-gray-200 { - --tw-text-opacity: 1; - color: rgb(229 231 235 / var(--tw-text-opacity, 1)); -} - -.underline { - text-decoration-line: underline; -} - -.no-underline { - text-decoration-line: none; -} - -.opacity-30 { - opacity: 0.3; -} - -.opacity-70 { - opacity: 0.7; -} - -.opacity-85 { - opacity: 0.85; -} - -.outline { - outline-style: solid; -} - -.ring { - --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); - --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); - box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); -} - -.blur { - --tw-blur: blur(8px); - filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); -} - -.filter { - filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); -} - -.transition { - transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -.ease-in-out { - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); -} - -.\[start\:end\] { - start: end; -} - -/* Import custom CSS (not purged by Tailwind) */ - /* Base */ - :root{ - /* MTG color palette (approx from provided values) */ - --banner-h: 52px; - --sidebar-w: 260px; - --green-main: rgb(0,115,62); - --green-light: rgb(196,211,202); - --blue-main: rgb(14,104,171); - --blue-light: rgb(179,206,234); - --red-main: rgb(211,32,42); - --red-light: rgb(235,159,130); - --white-main: rgb(249,250,244); - --white-light: rgb(248,231,185); - --black-main: rgb(21,11,0); - --black-light: rgb(166,159,157); - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; - --ring: #60a5fa; - /* focus ring */ - --ok: #16a34a; - /* success */ - --warn: #f59e0b; - /* warning */ - --err: #ef4444; - /* error */ - /* Surface overrides for specific regions (default to panel) */ - --surface-banner: var(--panel); - --surface-banner-text: var(--text); - --surface-sidebar: var(--panel); - --surface-sidebar-text: var(--text); + /* MTG color palette (approx from provided values) */ + --banner-h: 52px; + --sidebar-w: 260px; + --green-main: rgb(0,115,62); + --green-light: rgb(196,211,202); + --blue-main: rgb(14,104,171); + --blue-light: rgb(179,206,234); + --red-main: rgb(211,32,42); + --red-light: rgb(235,159,130); + --white-main: rgb(249,250,244); + --white-light: rgb(248,231,185); + --black-main: rgb(21,11,0); + --black-light: rgb(166,159,157); + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; + --ring: #60a5fa; /* focus ring */ + --ok: #16a34a; /* success */ + --warn: #f59e0b; /* warning */ + --err: #ef4444; /* error */ + /* Surface overrides for specific regions (default to panel) */ + --surface-banner: var(--panel); + --surface-banner-text: var(--text); + --surface-sidebar: var(--panel); + --surface-sidebar-text: var(--text); } /* Light blend between Slate and Parchment (leans gray) */ - [data-theme="light-blend"]{ - --bg: #e8e2d0; - /* warm beige background (keep existing) */ - --panel: #ebe5d8; - /* lighter warm cream - more contrast with bg, subtle panels */ - --text: #0d0a08; - /* very dark brown/near-black for strong readability */ - --muted: #5a544c; - /* darker muted brown for better contrast */ - --border: #bfb5a3; - /* darker warm-gray border for better definition */ - /* Navbar/banner: darker warm brown for hierarchy */ - --surface-banner: #9b8f7a; - /* warm medium brown - darker than panels, lighter than dark theme */ - --surface-sidebar: #9b8f7a; - /* match banner for consistency */ - --surface-banner-text: #1a1410; - /* dark brown text on medium brown bg */ - --surface-sidebar-text: #1a1410; - /* dark brown text on medium brown bg */ - /* Button colors: use taupe for buttons so they stand out from light panels */ - --btn-bg: #d4cbb8; - /* medium warm taupe - stands out against light panels */ - --btn-text: #1a1410; - /* dark brown text */ - --btn-hover-bg: #c4b9a5; - /* darker taupe on hover */ + --bg: #e8e2d0; /* blend of slate (#dedfe0) and parchment (#f8e7b9), 60/40 gray */ + --panel: #ffffff; /* crisp panels for readability */ + --text: #0b0d12; + --muted: #6b655d; /* slightly warm muted */ + --border: #d6d1c7; /* neutral warm-gray border */ + /* Slightly darker banner/sidebar for separation */ + --surface-banner: #1a1b1e; + --surface-sidebar: #1a1b1e; + --surface-banner-text: #e8e8e8; + --surface-sidebar-text: #e8e8e8; } [data-theme="dark"]{ - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; + --bg: #0f0f10; + --panel: #1a1b1e; + --text: #e8e8e8; + --muted: #b6b8bd; + --border: #2a2b2f; } - [data-theme="high-contrast"]{ - --bg: #000; - --panel: #000; - --text: #fff; - --muted: #e5e7eb; - --border: #fff; - --ring: #ff0; + --bg: #000; + --panel: #000; + --text: #fff; + --muted: #e5e7eb; + --border: #fff; + --ring: #ff0; } - [data-theme="cb-friendly"]{ - /* Tweak accents for color-blind friendliness */ - --green-main: #2e7d32; - /* darker green */ - --red-main: #c62828; - /* deeper red */ - --blue-main: #1565c0; - /* balanced blue */ + /* Tweak accents for color-blind friendliness */ + --green-main: #2e7d32; /* darker green */ + --red-main: #c62828; /* deeper red */ + --blue-main: #1565c0; /* balanced blue */ } - -*{ - box-sizing:border-box -} - -html{ - height:100%; - overflow-x:hidden; - overflow-y:scroll; - max-width:100vw; -} - +*{box-sizing:border-box} +html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;} body { - font-family: system-ui, Arial, sans-serif; - margin: 0; - color: var(--text); - background: var(--bg); - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: scroll; + font-family: system-ui, Arial, sans-serif; + margin: 0; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-x: hidden; + overflow-y: auto; } - /* Honor HTML hidden attribute across the app */ - -[hidden] { - display: none !important; -} - +[hidden] { display: none !important; } /* Accessible focus ring for keyboard navigation */ - -.focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} - -/* Top banner - simplified, no changes on sidebar toggle */ - -.top-banner{ - position:sticky; - top:0; - z-index:10; - background: var(--surface-banner); - color: var(--surface-banner-text); - border-bottom:1px solid var(--border); - box-shadow:0 2px 6px rgba(0,0,0,.4); - min-height: var(--banner-h); -} - -.top-banner .top-inner{ - margin:0; - padding:.4rem 15px; - display:flex; - align-items:center; - width:100%; - box-sizing:border-box; -} - -.top-banner h1{ - font-size: 1.1rem; - margin:0; - margin-left: 25px; -} - -.flex-row{ - display: flex; - align-items: center; - gap: 25px; -} - -.top-banner .banner-left{ - width: 260px !important; - flex-shrink: 0 !important; -} - -/* Hide elements on all screen sizes */ - -#btn-open-permalink{ - display:none !important; -} - -#banner-status{ - display:none !important; -} - -.top-banner #theme-reset{ - display:none !important; +.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +/* Top banner */ +.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); } +.top-banner{ min-height: var(--banner-h); } +.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; width:100%; box-sizing:border-box; } +.top-banner .top-inner > div{ min-width:0; } +@media (max-width: 1100px){ + .top-banner .top-inner{ grid-auto-rows:auto; } + .top-banner .top-inner select{ max-width:140px; } } +.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; } +.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:100%; min-height:1.2em; } +.banner-status.busy{ color:#fbbf24; } +.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; } +.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; } /* Layout */ - -.layout{ - display:grid; - grid-template-columns: var(--sidebar-w) minmax(0, 1fr); - flex: 1 0 auto; -} - +.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } .sidebar{ - background: var(--surface-sidebar); - color: var(--surface-sidebar-text); - border-right: 1px solid var(--border); - padding: 1rem; - position: fixed; - top: var(--banner-h); - left: 0; - bottom: 0; - overflow: auto; - width: var(--sidebar-w); - z-index: 9; - /* below the banner (z=10) */ - box-shadow: 2px 0 10px rgba(0,0,0,.18); - display: flex; - flex-direction: column; -} - -.content{ - padding: 1.25rem 1.5rem; - grid-column: 2; - min-width: 0; + background: var(--surface-sidebar); + color: var(--surface-sidebar-text); + border-right: 1px solid var(--border); + padding: 1rem; + position: fixed; + top: var(--banner-h); + left: 0; + bottom: 0; + overflow: auto; + width: var(--sidebar-w); + z-index: 9; /* below the banner (z=10) */ + box-shadow: 2px 0 10px rgba(0,0,0,.18); + display: flex; + flex-direction: column; } +.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } /* Collapsible sidebar behavior */ - -body.nav-collapsed .layout{ - grid-template-columns: 0 minmax(0, 1fr); -} - -body.nav-collapsed .sidebar{ - transform: translateX(-100%); - visibility: hidden; -} - -body.nav-collapsed .content{ - grid-column: 2; -} - -/* Sidebar collapsed state doesn't change banner grid on desktop anymore */ - +body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } +body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } +body.nav-collapsed .content{ grid-column: 2; } +body.nav-collapsed .top-banner .top-inner{ grid-template-columns: auto 1fr; } +body.nav-collapsed .top-banner .top-inner{ padding-left: .5rem; padding-right: .5rem; } /* Smooth hide/show on mobile while keeping fixed positioning */ - -.sidebar{ - transition: transform .2s ease-out, visibility .2s linear; - overflow-x: hidden; -} - -/* Suppress sidebar transitions during page load to prevent pop-in */ - -body.no-transition .sidebar{ - transition: none !important; -} - -/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ - -body.htmx-settling .sidebar{ - transition: none !important; -} - -body.htmx-settling .layout{ - transition: none !important; -} - -body.htmx-settling .content{ - transition: none !important; -} - -body.htmx-settling *{ - transition-duration: 0s !important; -} +.sidebar{ transition: transform .2s ease-out, visibility .2s linear; } /* Mobile tweaks */ - @media (max-width: 900px){ - :root{ - --sidebar-w: 240px; - } - - .layout{ - grid-template-columns: 0 1fr; - } - - .sidebar{ - transform: translateX(-100%); - visibility: hidden; - } - - body:not(.nav-collapsed) .layout{ - grid-template-columns: var(--sidebar-w) 1fr; - } - - body:not(.nav-collapsed) .sidebar{ - transform: translateX(0); - visibility: visible; - } - - .content{ - padding: .9rem .6rem; - max-width: 100vw; - box-sizing: border-box; - overflow-x: hidden; - } + :root{ --sidebar-w: 240px; } + .top-banner .top-inner{ grid-template-columns: 1fr; row-gap: .35rem; padding:.4rem 15px !important; } + .banner-status{ padding-left: .5rem; } + .layout{ grid-template-columns: 0 1fr; } + .sidebar{ transform: translateX(-100%); visibility: hidden; } + body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } + body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } + .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } + .top-banner{ box-shadow:0 2px 6px rgba(0,0,0,.4); } + /* Spacing tweaks: tighter left, larger gaps between visible items */ + .top-banner .top-inner > div{ gap: 25px !important; } + .top-banner .top-inner > div:first-child{ padding-left: 0 !important; } + /* Mobile: show only Menu, Title, and Theme selector */ + #btn-open-permalink{ display:none !important; } + #banner-status{ display:none !important; } + #health-dot{ display:none !important; } + .top-banner #theme-reset{ display:none !important; } } /* Additional mobile spacing for bottom floating controls */ - @media (max-width: 720px) { - .content { - padding-bottom: 6rem !important; - /* Extra bottom padding to account for floating controls */ - } + .content { + padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ + } } -.brand h1{ - display:none; -} +.brand h1{ display:none; } +.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; } +.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } +.dot.green{ background: var(--green-main); } +.dot.blue{ background: var(--blue-main); } +.dot.red{ background: var(--red-main); } +.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } +.dot.black{ background: var(--black-light); } -.brand{ - padding-top: 0; - margin-top: 0; -} - -.mana-dots{ - display:flex; - gap:.35rem; - margin-bottom:.5rem; - margin-top: 0; - padding-top: 0; -} - -.mana-dots .dot{ - width:12px; - height:12px; - border-radius:50%; - display:inline-block; - border:1px solid rgba(0,0,0,.35); - box-shadow:0 1px 2px rgba(0,0,0,.3) inset; -} - -.dot.green{ - background: var(--green-main); -} - -.dot.blue{ - background: var(--blue-main); -} - -.dot.red{ - background: var(--red-main); -} - -.dot.white{ - background: var(--white-light); - border-color: rgba(0,0,0,.2); -} - -.dot.black{ - background: var(--black-light); -} - -.nav{ - display:flex; - flex-direction:column; - gap:.35rem; -} - -.nav a{ - color: var(--surface-sidebar-text); - text-decoration:none; - padding:.4rem .5rem; - border-radius:6px; - border:1px solid transparent; -} - -.nav a:hover{ - background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); - border-color: var(--border); -} +.nav{ display:flex; flex-direction:column; gap:.35rem; } +.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } +.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } /* Sidebar theme controls anchored at bottom */ - -.sidebar .nav { - flex: 1 1 auto; -} - -.sidebar-theme { - margin-top: auto; - padding-top: .75rem; - border-top: 1px solid var(--border); -} - -.sidebar-theme-label { - display:block; - color: var(--surface-sidebar-text); - font-size: 12px; - opacity:.8; - margin: 0 0 .35rem .1rem; -} - -.sidebar-theme-row { - display:flex; - align-items:center; - gap:.5rem; - flex-wrap: nowrap; -} - -.sidebar-theme-row select { - background: var(--panel); - color: var(--text); - border:1px solid var(--border); - border-radius:6px; - padding:.3rem .4rem; - flex: 1 1 auto; - min-width: 0; -} - -.sidebar-theme-row .btn-ghost { - background: transparent; - color: var(--surface-sidebar-text); - border:1px solid var(--border); - flex-shrink: 0; - white-space: nowrap; -} +.sidebar .nav { flex: 1 1 auto; } +.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } +.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } +.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; } +.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; } +.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); } /* Simple two-column layout for inspect panel */ - -.two-col { - display: grid; - grid-template-columns: 1fr 320px; - gap: 1rem; - align-items: start; -} - -.two-col .grow { - min-width: 0; -} - -.card-preview img { - width: 100%; - height: auto; - border-radius: 10px; - box-shadow: 0 6px 18px rgba(0,0,0,.35); - border:1px solid var(--border); - background: var(--panel); -} - -@media (max-width: 900px) { - .two-col { - grid-template-columns: 1fr; - } -} +.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } +.two-col .grow { min-width: 0; } +.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } +@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } /* Left-rail variant puts the image first */ - -.two-col.two-col-left-rail{ - grid-template-columns: 320px 1fr; -} - +.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } /* Ensure left-rail variant also collapses to 1 column on small screens */ - @media (max-width: 900px){ - .two-col.two-col-left-rail{ - grid-template-columns: 1fr; - } - - /* So the commander image doesn't dominate on mobile */ - - .two-col .card-preview{ - max-width: 360px; - margin: 0 auto; - } - - .two-col .card-preview img{ - width: 100%; - height: auto; - } -} - -.card-preview.card-sm{ - max-width:200px; + .two-col.two-col-left-rail{ grid-template-columns: 1fr; } + /* So the commander image doesn't dominate on mobile */ + .two-col .card-preview{ max-width: 360px; margin: 0 auto; } + .two-col .card-preview img{ width: 100%; height: auto; } } +.card-preview.card-sm{ max-width:200px; } /* Buttons, inputs */ - -button{ - background: var(--blue-main); - color:#fff; - border:none; - border-radius:6px; - padding:.45rem .7rem; - cursor:pointer; -} - -button:hover{ - filter:brightness(1.05); -} - +button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } +button:hover{ filter:brightness(1.05); } /* Anchor-style buttons */ - -.btn{ - display:inline-block; - background: var(--blue-main); - color:#fff; - border:none; - border-radius:6px; - padding:.45rem .7rem; - cursor:pointer; - text-decoration:none; - line-height:1; -} - -.btn:hover{ - filter:brightness(1.05); - text-decoration:none; -} - -.btn.disabled, .btn[aria-disabled="true"]{ - opacity:.6; - cursor:default; - pointer-events:none; -} - -label{ - display:inline-flex; - flex-direction:column; - gap:.25rem; - margin-right:.75rem; -} - -.color-identity{ - display:inline-flex; - align-items:center; - gap:.35rem; -} - -.color-identity .mana + .mana{ - margin-left:4px; -} - -.mana{ - display:inline-block; - width:16px; - height:16px; - border-radius:50%; - border:1px solid var(--border); - box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; -} - -.mana-W{ - background:#f9fafb; - border-color:#d1d5db; -} - -.mana-U{ - background:#3b82f6; - border-color:#1d4ed8; -} - -.mana-B{ - background:#111827; - border-color:#1f2937; -} - -.mana-R{ - background:#ef4444; - border-color:#b91c1c; -} - -.mana-G{ - background:#10b981; - border-color:#047857; -} - -.mana-C{ - background:#d3d3d3; - border-color:#9ca3af; -} - -select,input[type="text"],input[type="number"]{ - background: var(--panel); - color:var(--text); - border:1px solid var(--border); - border-radius:6px; - padding:.35rem .4rem; -} - -/* Range slider styling */ - -input[type="range"]{ - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - width: 100%; - height: 8px; - background: var(--bg); - border-radius: 4px; - outline: none; - border: 1px solid var(--border); -} - -input[type="range"]::-webkit-slider-thumb{ - -webkit-appearance: none; - appearance: none; - width: 20px; - height: 20px; - background: var(--blue-main); - border-radius: 50%; - cursor: pointer; - border: 2px solid var(--panel); - box-shadow: 0 2px 4px rgba(0,0,0,.2); -} - -input[type="range"]::-moz-range-thumb{ - width: 20px; - height: 20px; - background: var(--blue-main); - border-radius: 50%; - cursor: pointer; - border: 2px solid var(--panel); - box-shadow: 0 2px 4px rgba(0,0,0,.2); -} - -fieldset{ - border:1px solid var(--border); - border-radius:8px; - padding:.75rem; - margin:.75rem 0; -} - -small, .muted{ - color: var(--muted); -} - -.partner-preview{ - border:1px solid var(--border); - border-radius:8px; - background: var(--panel); - padding:.75rem; - margin-bottom:.5rem; -} - -.partner-preview[hidden]{ - display:none !important; -} - -.partner-preview__header{ - font-weight:600; -} - -.partner-preview__layout{ - display:flex; - gap:.75rem; - align-items:flex-start; - flex-wrap:wrap; -} - -.partner-preview__art{ - flex:0 0 auto; -} - -.partner-preview__art img{ - width:140px; - max-width:100%; - border-radius:6px; - box-shadow:0 4px 12px rgba(0,0,0,.35); -} - -.partner-preview__details{ - flex:1 1 180px; - min-width:0; -} - -.partner-preview__role{ - margin-top:.2rem; - font-size:12px; - color:var(--muted); - letter-spacing:.04em; - text-transform:uppercase; -} - -.partner-preview__pairing{ - margin-top:.35rem; -} - -.partner-preview__themes{ - margin-top:.35rem; - font-size:12px; -} - -.partner-preview--static{ - margin-bottom:.5rem; -} - -.partner-card-preview img{ - box-shadow:0 4px 12px rgba(0,0,0,.3); -} +.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } +.btn:hover{ filter:brightness(1.05); text-decoration:none; } +.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } +label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } +.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } +.color-identity .mana + .mana{ margin-left:4px; } +.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } +.mana-W{ background:#f9fafb; border-color:#d1d5db; } +.mana-U{ background:#3b82f6; border-color:#1d4ed8; } +.mana-B{ background:#111827; border-color:#1f2937; } +.mana-R{ background:#ef4444; border-color:#b91c1c; } +.mana-G{ background:#10b981; border-color:#047857; } +.mana-C{ background:#d3d3d3; border-color:#9ca3af; } +select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } +fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } +small, .muted{ color: var(--muted); } +.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } +.partner-preview[hidden]{ display:none !important; } +.partner-preview__header{ font-weight:600; } +.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } +.partner-preview__art{ flex:0 0 auto; } +.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } +.partner-preview__details{ flex:1 1 180px; min-width:0; } +.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } +.partner-preview__pairing{ margin-top:.35rem; } +.partner-preview__themes{ margin-top:.35rem; font-size:12px; } +.partner-preview--static{ margin-bottom:.5rem; } +.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } /* Toasts */ - -.toast-host{ - position: fixed; - right: 12px; - bottom: 12px; - display: flex; - flex-direction: column; - gap: 8px; - z-index: 9999; -} - -.toast{ - background: var(--panel); - color:var(--text); - border:1px solid var(--border); - border-radius:10px; - padding:.5rem .65rem; - box-shadow: 0 8px 24px rgba(0,0,0,.35); - transition: transform .2s ease, opacity .2s ease; -} - -.toast.hide{ - opacity:0; - transform: translateY(6px); -} - -.toast.success{ - border-color: rgba(22,163,74,.4); -} - -.toast.error{ - border-color: rgba(239,68,68,.45); -} - -.toast.warn{ - border-color: rgba(245,158,11,.45); -} +.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } +.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } +.toast.hide{ opacity:0; transform: translateY(6px); } +.toast.success{ border-color: rgba(22,163,74,.4); } +.toast.error{ border-color: rgba(239,68,68,.45); } +.toast.warn{ border-color: rgba(245,158,11,.45); } /* Skeletons */ - -[data-skeleton]{ - position: relative; -} - -[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ - opacity: 0; -} - -[data-skeleton-placeholder]{ - display:none; - pointer-events:none; -} - -[data-skeleton].is-loading > [data-skeleton-placeholder]{ - display:flex; - flex-direction:column; - opacity:1; -} - +[data-skeleton]{ position: relative; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } [data-skeleton][data-skeleton-overlay="false"]::after, -[data-skeleton][data-skeleton-overlay="false"]::before{ - display:none !important; -} - +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } [data-skeleton]::after{ - content: ''; - position: absolute; - inset: 0; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); - background-size: 200% 100%; - animation: shimmer 1.1s linear infinite; - display: none; + content: ''; + position: absolute; inset: 0; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); + background-size: 200% 100%; + animation: shimmer 1.1s linear infinite; + display: none; } - -[data-skeleton].is-loading::after{ - display:block; -} - +[data-skeleton].is-loading::after{ display:block; } [data-skeleton].is-loading::before{ - content: attr(data-skeleton-label); - position:absolute; - top:50%; - left:50%; - transform:translate(-50%, -50%); - color: var(--muted); - font-size:.85rem; - text-align:center; - line-height:1.4; - max-width:min(92%, 360px); - padding:.3rem .5rem; - pointer-events:none; - z-index:1; - filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); -} - -[data-skeleton][data-skeleton-label=""]::before{ - content:''; -} - -@keyframes shimmer{ - 0%{ - background-position: 200% 0; - } - - 100%{ - background-position: -200% 0; - } + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); } +[data-skeleton][data-skeleton-label=""]::before{ content:''; } +@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } /* Banner */ - -.banner{ - background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); - border: 1px solid var(--border); - border-radius: 10px; - padding: 2rem 1.6rem; - margin-bottom: 1rem; - box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; -} - -.banner h1{ - font-size: 2rem; - margin:0 0 .35rem; -} - -.banner .subtitle{ - color: var(--muted); - font-size:.95rem; -} +.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } +.banner h1{ font-size: 2rem; margin:0 0 .35rem; } +.banner .subtitle{ color: var(--muted); font-size:.95rem; } /* Home actions */ - -.actions-grid{ - display:grid; - grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); - gap: .75rem; -} - -.action-button{ - display:block; - text-decoration:none; - color: var(--text); - border:1px solid var(--border); - background: var(--panel); - padding:1.25rem; - border-radius:10px; - text-align:center; - font-weight:600; -} - -.action-button:hover{ - border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); -} - -.action-button.primary{ - background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); - border-color: #274766; -} - -/* Home page darker buttons */ - -.home-button.btn-secondary { - background: var(--btn-bg, #1a1d24); - color: var(--btn-text, #e8e8e8); - border-color: var(--border); -} - -.home-button.btn-secondary:hover { - background: var(--btn-hover-bg, #22252d); - border-color: var(--border); -} - -.home-button.btn-primary { - background: var(--blue-main); - color: white; - border-color: var(--blue-main); -} - -.home-button.btn-primary:hover { - background: #0c5aa6; - border-color: #0c5aa6; -} +.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } +.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } +.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } +.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } /* Card grid for added cards (responsive, compact tiles) */ - .card-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); - /* ~160px image + padding */ - 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; - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 640px 420px; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ + 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; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; } - @media (max-width: 420px){ - .card-grid{ - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .card-tile{ - width: 100%; - } - - .card-tile img{ - width: 100%; - max-width: 160px; - margin: 0 auto; - } + .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .card-tile{ width: 100%; } + .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } } - .card-tile{ - width:170px; - position: relative; - background: var(--panel); - border:1px solid var(--border); - border-radius:6px; - padding:.25rem .25rem .4rem; - text-align:center; + width:170px; + position: relative; + background: var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:.25rem .25rem .4rem; + text-align:center; } - -.card-tile.game-changer{ - border-color: var(--red-main); - box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; -} - +.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } .card-tile.locked{ - /* Subtle yellow/goldish-white accent for locked cards */ - border-color: #f5e6a8; - /* soft parchment gold */ - box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; + /* Subtle yellow/goldish-white accent for locked cards */ + border-color: #f5e6a8; /* soft parchment gold */ + box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; } - .card-tile.must-include{ - border-color: rgba(74,222,128,.85); - box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); } - .card-tile.must-exclude{ - border-color: rgba(239,68,68,.85); - box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; - opacity: .95; + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; } - .card-tile.must-include.must-exclude{ - border-color: rgba(249,115,22,.85); - box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; -} - -.card-tile img{ - width:160px; - height:auto; - border-radius:6px; - box-shadow: 0 6px 18px rgba(0,0,0,.35); - background:#111; -} - -.card-tile .name{ - font-weight:600; - margin-top:.25rem; - font-size:.92rem; -} - -.card-tile .reason{ - color:var(--muted); - font-size:.85rem; - margin-top:.15rem; + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; } +.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } +.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } +.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } .must-have-controls{ - display:flex; - justify-content:center; - gap:.35rem; - flex-wrap:wrap; - margin-top:.35rem; + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; } - .must-have-btn{ - border:1px solid var(--border); - background:rgba(30,41,59,.6); - color:#f8fafc; - font-size:11px; - text-transform:uppercase; - letter-spacing:.06em; - padding:.25rem .6rem; - border-radius:9999px; - cursor:pointer; - transition: all .18s ease; + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; } - .must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ - border-color: rgba(74,222,128,.75); - background: rgba(74,222,128,.18); - color: #bbf7d0; - box-shadow: 0 0 0 1px rgba(16,185,129,.25); + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); } - .must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ - border-color: rgba(239,68,68,.75); - background: rgba(239,68,68,.18); - color: #fecaca; - box-shadow: 0 0 0 1px rgba(239,68,68,.25); + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); } - .must-have-btn:focus-visible{ - outline:2px solid rgba(59,130,246,.6); - outline-offset:2px; + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; } - .card-tile.must-exclude .must-have-btn.include[data-active="0"], .card-tile.must-include .must-have-btn.exclude[data-active="0"]{ - opacity:.65; + opacity:.65; } -.group-grid{ - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 540px 360px; -} - -.alt-list{ - list-style:none; - padding:0; - margin:0; - display:grid; - gap:.25rem; - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 320px 220px; -} - -.alt-option{ - display:block !important; - width:100%; - max-width:100%; - text-align:left; - white-space:normal !important; - word-wrap:break-word !important; - overflow-wrap:break-word !important; - line-height:1.3 !important; - padding:0.5rem 0.7rem !important; -} +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } /* Shared ownership badge for card tiles and stacked images */ - .owned-badge{ - position:absolute; - top:6px; - left:6px; - background:var(--panel); - color:var(--text); - border:1px solid var(--border); - border-radius:12px; - font-size:12px; - line-height:18px; - height:18px; - min-width:18px; - padding:0 6px; - text-align:center; - pointer-events:none; - z-index:2; + position:absolute; + top:6px; + left:6px; + background:rgba(17,24,39,.9); + color:#e5e7eb; + border:1px solid var(--border); + border-radius:12px; + font-size:12px; + line-height:18px; + height:18px; + min-width:18px; + padding:0 6px; + text-align:center; + pointer-events:none; + z-index:2; } /* Step 1 candidate grid (200px-wide scaled images) */ - .candidate-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap:.75rem; + display:grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap:.75rem; } - .candidate-tile{ - background: var(--panel); - border:1px solid var(--border); - border-radius:8px; - padding:.4rem; -} - -.candidate-tile .img-btn{ - display:block; - width:100%; - padding:0; - background:transparent; - border:none; - cursor:pointer; -} - -.candidate-tile img{ - width:100%; - max-width:200px; - height:auto; - border-radius:8px; - box-shadow:0 6px 18px rgba(0,0,0,.35); - background: var(--panel); - display:block; - margin:0 auto; -} - -.candidate-tile .meta{ - text-align:center; - margin-top:.35rem; -} - -.candidate-tile .name{ - font-weight:600; - font-size:.95rem; -} - -.candidate-tile .score{ - color:var(--muted); - font-size:.85rem; + background: var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:.4rem; } +.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } +.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } +.candidate-tile .meta{ text-align:center; margin-top:.35rem; } +.candidate-tile .name{ font-weight:600; font-size:.95rem; } +.candidate-tile .score{ color:var(--muted); font-size:.85rem; } /* Deck summary: highlight game changers */ - -.game-changer { - color: var(--green-main); -} - -.stack-card.game-changer { - outline: 2px solid var(--green-main); -} +.game-changer { color: var(--green-main); } +.stack-card.game-changer { outline: 2px solid var(--green-main); } /* Image button inside card tiles */ - -.card-tile .img-btn{ - display:block; - padding:0; - background:transparent; - border:none; - cursor:pointer; - width:100%; -} +.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } /* Stage Navigator */ - -.stage-nav { - margin:.5rem 0 1rem; -} - -.stage-nav ol { - list-style:none; - padding:0; - margin:0; - display:flex; - gap:.35rem; - flex-wrap:wrap; -} - -.stage-nav .stage-link { - display:flex; - align-items:center; - gap:.4rem; - background: var(--panel); - border:1px solid var(--border); - color:var(--text); - border-radius:999px; - padding:.25rem .6rem; - cursor:pointer; -} - -.stage-nav .stage-item.done .stage-link { - opacity:.75; -} - -.stage-nav .stage-item.current .stage-link { - box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; - border-color:#3b82f6; -} - -.stage-nav .idx { - display:inline-grid; - place-items:center; - width:20px; - height:20px; - border-radius:50%; - background:var(--bg); - font-size:12px; -} - -.stage-nav .name { - font-size:12px; -} +.stage-nav { margin:.5rem 0 1rem; } +.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } +.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } +.stage-nav .stage-item.done .stage-link { opacity:.75; } +.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } +.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; } +.stage-nav .name { font-size:12px; } /* Build controls sticky box tweaks */ - -.build-controls { - position: sticky; - top: calc(var(--banner-offset, 48px) + 6px); - z-index: 100; - background: var(--panel); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 10px; - margin: 0.5rem 0; - box-shadow: 0 4px 12px rgba(0,0,0,.25); +.build-controls { + position: sticky; + top: calc(var(--banner-offset, 48px) + 6px); + z-index: 100; + background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92)); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 10px; + margin: 0.5rem 0; + box-shadow: 0 4px 12px rgba(0,0,0,.25); } @media (max-width: 1024px){ - :root { - --banner-offset: 56px; - } - - .build-controls { - position: fixed !important; - /* Fixed to viewport instead of sticky */ - bottom: 0 !important; - /* Anchor to bottom of screen */ - left: 0 !important; - right: 0 !important; - top: auto !important; - /* Override top positioning */ - border-radius: 0 !important; - /* Remove border radius for full width */ - margin: 0 !important; - /* Remove margins for full edge-to-edge */ - padding: 0.5rem !important; - /* Reduced padding */ - box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; - /* Upward shadow */ - border-left: none !important; - border-right: none !important; - border-bottom: none !important; - /* Remove bottom border */ - background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; - z-index: 1000 !important; - /* Higher z-index to ensure it's above content */ - } + :root { --banner-offset: 56px; } + .build-controls { + position: fixed !important; /* Fixed to viewport instead of sticky */ + bottom: 0 !important; /* Anchor to bottom of screen */ + left: 0 !important; + right: 0 !important; + top: auto !important; /* Override top positioning */ + border-radius: 0 !important; /* Remove border radius for full width */ + margin: 0 !important; /* Remove margins for full edge-to-edge */ + padding: 0.5rem !important; /* Reduced padding */ + box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ + border-left: none !important; + border-right: none !important; + border-bottom: none !important; /* Remove bottom border */ + background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; + z-index: 1000 !important; /* Higher z-index to ensure it's above content */ + } } - @media (min-width: 721px){ - :root { - --banner-offset: 48px; - } + :root { --banner-offset: 48px; } } /* Progress bar */ - -.progress { - position: relative; - height: 10px; - background: var(--panel); - border:1px solid var(--border); - border-radius: 999px; - overflow: hidden; -} - -.progress .bar { - position:absolute; - left:0; - top:0; - bottom:0; - width: 0%; - background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); -} - -.progress.flash { - box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; -} +.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } +.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } +.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } /* Chips */ - -.chip { - display:inline-flex; - align-items:center; - gap:.35rem; - background: var(--panel); - border:1px solid var(--border); - color:var(--text); - border-radius:999px; - padding:.2rem .55rem; - font-size:12px; -} - -.chip .dot { - width:8px; - height:8px; - border-radius:50%; - background:#6b7280; -} - -.chip:hover { - background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); - border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); -} - -.chip.active { - background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15)); - border-color: #3b82f6; - color: #60a5fa; - font-weight: 600; - box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset; -} - -.chip.active:hover { - background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25)); - border-color: #60a5fa; -} +.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } +.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } /* Cards toolbar */ - -.cards-toolbar{ - display:flex; - flex-wrap:wrap; - gap:.5rem .75rem; - align-items:center; - margin:.5rem 0 .25rem; -} - -.cards-toolbar input[type="text"]{ - min-width: 220px; -} - -.cards-toolbar .sep{ - width:1px; - height:20px; - background: var(--border); - margin:0 .25rem; -} - -.cards-toolbar .hint{ - color: var(--muted); - font-size:12px; -} +.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } +.cards-toolbar input[type="text"]{ min-width: 220px; } +.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } +.cards-toolbar .hint{ color: var(--muted); font-size:12px; } /* Collapse groups and reason toggle */ - -.group{ - margin:.5rem 0; -} - -.group-header{ - display:flex; - align-items:center; - gap:.5rem; -} - -.group-header h5{ - margin:.4rem 0; -} - -.group-header .count{ - color: var(--muted); - font-size:12px; -} - -.group-header .toggle{ - margin-left:auto; - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); - color: var(--text); - border:1px solid var(--border); - border-radius:6px; - padding:.2rem .5rem; - font-size:12px; - cursor:pointer; -} - -.group-grid[data-collapsed]{ - display:none; -} - -.hide-reasons .card-tile .reason{ - display:none; -} - -.card-tile.force-show .reason{ - display:block !important; -} - -.card-tile.force-hide .reason{ - display:none !important; -} - -.btn-why{ - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); - color: var(--text); - border:1px solid var(--border); - border-radius:6px; - padding:.15rem .4rem; - font-size:12px; - cursor:pointer; -} - -.chips-inline{ - display:flex; - gap:.35rem; - flex-wrap:wrap; - align-items:center; -} - -.chips-inline .chip{ - cursor:pointer; - -webkit-user-select:none; - -moz-user-select:none; - user-select:none; -} +.group{ margin:.5rem 0; } +.group-header{ display:flex; align-items:center; gap:.5rem; } +.group-header h5{ margin:.4rem 0; } +.group-header .count{ color: var(--muted); font-size:12px; } +.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } +.group-grid[data-collapsed]{ display:none; } +.hide-reasons .card-tile .reason{ display:none; } +.card-tile.force-show .reason{ display:block !important; } +.card-tile.force-hide .reason{ display:none !important; } +.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } +.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } +.chips-inline .chip{ cursor:pointer; user-select:none; } /* Inline error banner */ - -.inline-error-banner{ - background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); - border:1px solid #b91c1c; - color:#b91c1c; - padding:.5rem .6rem; - border-radius:8px; - margin-bottom:.5rem; -} - -.inline-error-banner .muted{ - color:#fda4af; -} +.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } +.inline-error-banner .muted{ color:#fda4af; } /* Alternatives panel */ - -.alts ul{ - list-style:none; - padding:0; - margin:0; -} - -.alts li{ - display:flex; - align-items:center; - gap:.4rem; -} - +.alts ul{ list-style:none; padding:0; margin:0; } +.alts li{ display:flex; align-items:center; gap:.4rem; } /* LQIP blur/fade-in for thumbnails */ - -img.lqip { - filter: blur(8px); - opacity: .6; - transition: filter .25s ease-out, opacity .25s ease-out; -} - -img.lqip.loaded { - filter: blur(0); - opacity: 1; -} +img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } +img.lqip.loaded { filter: blur(0); opacity: 1; } /* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ - @media (prefers-reduced-motion: reduce) { - * { - scroll-behavior: auto !important; - } - - img.lqip { - transition: none !important; - filter: none !important; - opacity: 1 !important; - } + * { scroll-behavior: auto !important; } + img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } } /* Virtualization wrapper should mirror grid to keep multi-column flow */ - -.virt-wrapper { - display: grid; -} +.virt-wrapper { display: grid; } /* Mobile responsive fixes for horizontal scrolling issues */ - @media (max-width: 768px) { - /* Prevent horizontal overflow */ + /* Prevent horizontal overflow */ + html, body { + overflow-x: hidden !important; + width: 100% !important; + max-width: 100vw !important; + } - html, body { - overflow-x: hidden !important; - width: 100% !important; - max-width: 100vw !important; - } + /* Test hand responsive adjustments */ + #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } - /* Test hand responsive adjustments */ - - #test-hand{ - --card-w: 170px !important; - --card-h: 238px !important; - --overlap: .5 !important; - } - - /* Modal & form layout fixes (original block retained inside media query) */ - - /* Fix modal layout on mobile */ - - .modal { - padding: 10px !important; - box-sizing: border-box; - } - - .modal-content { - width: 100% !important; - max-width: calc(100vw - 20px) !important; - box-sizing: border-box !important; - overflow-x: hidden !important; - } - - /* Force single column for include/exclude grid */ - - .include-exclude-grid { - display: flex !important; - flex-direction: column !important; - gap: 1rem !important; - } - - /* Fix basics grid */ - - .basics-grid { - grid-template-columns: 1fr !important; - gap: 1rem !important; - } - - /* Ensure all inputs and textareas fit properly */ - - .modal input, + /* Modal & form layout fixes (original block retained inside media query) */ + /* Fix modal layout on mobile */ + .modal { + padding: 10px !important; + box-sizing: border-box; + } + .modal-content { + width: 100% !important; + max-width: calc(100vw - 20px) !important; + box-sizing: border-box !important; + overflow-x: hidden !important; + } + /* Force single column for include/exclude grid */ + .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } + /* Fix basics grid */ + .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } + /* Ensure all inputs and textareas fit properly */ + .modal input, .modal textarea, - .modal select { - width: 100% !important; - max-width: 100% !important; - box-sizing: border-box !important; - min-width: 0 !important; - } - - /* Fix chips containers */ - - .modal [id$="_chips_container"] { - max-width: 100% !important; - overflow-x: hidden !important; - word-wrap: break-word !important; - } - - /* Ensure fieldsets don't overflow */ - - .modal fieldset { - max-width: 100% !important; - box-sizing: border-box !important; - overflow-x: hidden !important; - } - - /* Fix any inline styles that might cause overflow */ - - .modal fieldset > div, - .modal fieldset > div > div { - max-width: 100% !important; - overflow-x: hidden !important; - } + .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } + /* Fix chips containers */ + .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } + /* Ensure fieldsets don't overflow */ + .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } + /* Fix any inline styles that might cause overflow */ + .modal fieldset > div, + .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } } @media (max-width: 640px){ - #test-hand{ - --card-w: 150px !important; - --card-h: 210px !important; - } - - /* Generic stack shrink */ - - .stack-wrap:not(#test-hand){ - --card-w: 150px; - --card-h: 210px; - } + #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } + /* Generic stack shrink */ + .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } } @media (max-width: 560px){ - #test-hand{ - --card-w: 140px !important; - --card-h: 196px !important; - padding-bottom:.75rem; - } - - #test-hand .stack-grid{ - display:flex !important; - gap:.5rem; - grid-template-columns:none !important; - overflow-x:auto; - padding-bottom:.25rem; - } - - #test-hand .stack-card{ - flex:0 0 auto; - } - - .stack-wrap:not(#test-hand){ - --card-w: 140px; - --card-h: 196px; - } + #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } + #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } + #test-hand .stack-card{ flex:0 0 auto; } + .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } } @media (max-width: 480px) { - .modal-content { - padding: 12px !important; - margin: 5px !important; - } - - .modal fieldset { - padding: 8px !important; - margin: 6px 0 !important; - } - - /* Enhanced mobile build controls */ - - .build-controls { - flex-direction: column !important; - gap: 0.25rem !important; - /* Reduced gap */ - align-items: stretch !important; - padding: 0.5rem !important; - /* Reduced padding */ - } - - /* Two-column grid layout for mobile build controls */ - - .build-controls { - display: grid !important; - grid-template-columns: 1fr 1fr !important; - /* Two equal columns */ - grid-gap: 0.25rem !important; - align-items: stretch !important; - } - - .build-controls form { - display: contents !important; - /* Allow form contents to participate in grid */ - width: auto !important; - } - - .build-controls button { - flex: none !important; - padding: 0.4rem 0.5rem !important; - /* Much smaller padding */ - font-size: 12px !important; - /* Smaller font */ - min-height: 36px !important; - /* Smaller minimum height */ - line-height: 1.2 !important; - width: 100% !important; - /* Full width within grid cell */ - box-sizing: border-box !important; - white-space: nowrap !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - /* Hide non-essential elements on mobile to keep it clean */ - - .build-controls .sep, + .modal-content { + padding: 12px !important; + margin: 5px !important; + } + + .modal fieldset { + padding: 8px !important; + margin: 6px 0 !important; + } + + /* Enhanced mobile build controls */ + .build-controls { + flex-direction: column !important; + gap: 0.25rem !important; /* Reduced gap */ + align-items: stretch !important; + padding: 0.5rem !important; /* Reduced padding */ + } + + /* Two-column grid layout for mobile build controls */ + .build-controls { + display: grid !important; + grid-template-columns: 1fr 1fr !important; /* Two equal columns */ + grid-gap: 0.25rem !important; + align-items: stretch !important; + } + + .build-controls form { + display: contents !important; /* Allow form contents to participate in grid */ + width: auto !important; + } + + .build-controls button { + flex: none !important; + padding: 0.4rem 0.5rem !important; /* Much smaller padding */ + font-size: 12px !important; /* Smaller font */ + min-height: 36px !important; /* Smaller minimum height */ + line-height: 1.2 !important; + width: 100% !important; /* Full width within grid cell */ + box-sizing: border-box !important; + white-space: nowrap !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Hide non-essential elements on mobile to keep it clean */ + .build-controls .sep, .build-controls .replace-toggle, .build-controls label[style*="margin-left"] { - display: none !important; - } - - .build-controls .sep { - display: none !important; - /* Hide separators on mobile */ - } + display: none !important; + } + + .build-controls .sep { + display: none !important; /* Hide separators on mobile */ + } } /* Desktop sizing for Test Hand */ - @media (min-width: 900px) { - #test-hand { - --card-w: 280px !important; - --card-h: 392px !important; - } + #test-hand { --card-w: 280px !important; --card-h: 392px !important; } } /* Analytics accordion styling */ - .analytics-accordion { - transition: all 0.2s ease; + transition: all 0.2s ease; } .analytics-accordion summary { - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.15s ease, border-color 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; } .analytics-accordion summary:hover { - background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); - border-color: var(--text); + background: #1f2937; + border-color: #374151; } .analytics-accordion summary:active { - transform: scale(0.99); + transform: scale(0.99); } .analytics-accordion[open] summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; } .analytics-accordion .analytics-content { - animation: accordion-slide-down 0.3s ease-out; + animation: accordion-slide-down 0.3s ease-out; } @keyframes accordion-slide-down { - from { - opacity: 0; - transform: translateY(-8px); - } - - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .analytics-placeholder .skeleton-pulse { - animation: shimmer 1.5s infinite; + animation: shimmer 1.5s infinite; } @keyframes shimmer { - 0% { - background-position: -200% 0; - } - - 100% { - background-position: 200% 0; - } + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } } - -/* Ideals Slider Styling */ - -.ideals-slider { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - height: 6px; - background: var(--border); - border-radius: 3px; - outline: none; -} - -.ideals-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - background: var(--ring); - border-radius: 50%; - cursor: pointer; - -webkit-transition: all 0.15s ease; - transition: all 0.15s ease; -} - -.ideals-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} - -.ideals-slider::-moz-range-thumb { - width: 18px; - height: 18px; - background: var(--ring); - border: none; - border-radius: 50%; - cursor: pointer; - -moz-transition: all 0.15s ease; - transition: all 0.15s ease; -} - -.ideals-slider::-moz-range-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} - -.slider-value { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 4px; -} - -/* ======================================== - Card Browser Styles - ======================================== */ - -/* Card browser container */ - -.card-browser-container { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Filter panel */ - -.card-browser-filters { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; -} - -.filter-section { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; -} - -.filter-row label { - font-weight: 600; - min-width: 80px; - color: var(--text); - font-size: 0.95rem; -} - -.filter-row select, -.filter-row input[type="text"], -.filter-row input[type="search"] { - flex: 1; - min-width: 150px; - max-width: 300px; -} - -/* Search bar styling */ - -.card-search-wrapper { - position: relative; - flex: 1; - max-width: 100%; -} - -.card-search-wrapper input[type="search"] { - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 1rem; -} - -/* Results count and info bar */ - -.card-browser-info { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem 0; -} - -.results-count { - font-size: 0.95rem; - color: var(--muted); -} - -.page-indicator { - font-size: 0.95rem; - color: var(--text); - font-weight: 600; -} - -/* Card browser grid */ - -.card-browser-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); - gap: 0.5rem; - padding: 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - min-height: 480px; - justify-content: start; -} - -/* Individual card tile in browser */ - -.card-browser-tile { - -moz-column-break-inside: avoid; - break-inside: avoid; - display: flex; - flex-direction: column; - background: var(--card-bg, #1a1d24); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; - cursor: pointer; -} - -.card-browser-tile:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); -} - -.card-browser-tile-image { - position: relative; - width: 100%; - aspect-ratio: 488/680; - overflow: hidden; - background: #0a0b0e; -} - -.card-browser-tile-image img { - width: 100%; - height: 100%; - -o-object-fit: contain; - object-fit: contain; - transition: transform 0.3s ease; -} - -.card-browser-tile:hover .card-browser-tile-image img { - transform: scale(1.05); -} - -.card-browser-tile-info { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.card-browser-tile-name { - font-weight: 600; - font-size: 0.95rem; - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; -} - -.card-browser-tile-type { - font-size: 0.85rem; - color: var(--muted); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; -} - -.card-browser-tile-stats { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.85rem; -} - -.card-browser-tile-tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.25rem; -} - -.card-browser-tile-tags .tag { - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - background: rgba(148, 163, 184, 0.15); - color: var(--muted); - border-radius: 3px; - white-space: nowrap; -} - -/* Card Details button on tiles */ - -.card-details-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - padding: 0.5rem 0.75rem; - background: var(--primary); - color: white; - text-decoration: none; - border-radius: 6px; - font-weight: 500; - font-size: 0.85rem; - transition: all 0.2s; - margin-top: 0.5rem; - border: none; - cursor: pointer; -} - -.card-details-btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); -} - -.card-details-btn svg { - flex-shrink: 0; -} - -/* Card Preview Modal */ - -.preview-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 9999; - align-items: center; - justify-content: center; -} - -.preview-modal.active { - display: flex; -} - -.preview-content { - position: relative; - max-width: 90%; - max-height: 90%; -} - -.preview-content img { - max-width: 100%; - max-height: 90vh; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} - -.preview-close { - position: absolute; - top: -40px; - right: 0; - background: rgba(255, 255, 255, 0.9); - color: #000; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - font-size: 24px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; -} - -.preview-close:hover { - background: #fff; - transform: scale(1.1); -} - -/* Pagination controls */ - -.card-browser-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 1rem; - padding: 1rem 0; - flex-wrap: wrap; -} - -.card-browser-pagination .btn { - min-width: 120px; -} - -.card-browser-pagination .page-info { - font-size: 0.95rem; - color: var(--text); - padding: 0 1rem; -} - -/* No results message */ - -.no-results { - text-align: center; - padding: 3rem 1rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; -} - -.no-results-title { - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin-bottom: 0.5rem; -} - -.no-results-message { - color: var(--muted); - margin-bottom: 1rem; - line-height: 1.5; -} - -.no-results-filters { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: center; - margin-bottom: 1rem; -} - -.no-results-filter-tag { - padding: 0.25rem 0.75rem; - background: rgba(148, 163, 184, 0.15); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - color: var(--text); -} - -/* Loading indicator */ - -.card-browser-loading { - text-align: center; - padding: 2rem; - color: var(--muted); -} - -/* Responsive adjustments */ - -/* Large tablets and below - reduce to ~180px cards */ - -@media (max-width: 1024px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); - } -} - -/* Tablets - reduce to ~160px cards */ - -@media (max-width: 768px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); - gap: 0.5rem; - padding: 0.5rem; - } - - .filter-row { - flex-direction: column; - align-items: stretch; - } - - .filter-row label { - min-width: auto; - } - - .filter-row select, - .filter-row input { - max-width: 100%; - } - - .card-browser-info { - flex-direction: column; - align-items: flex-start; - } -} - -/* Small tablets/large phones - reduce to ~140px cards */ - -@media (max-width: 600px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); - gap: 0.5rem; - } -} - -/* Phones - 2 column layout with flexible width */ - -@media (max-width: 480px) { - .card-browser-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.375rem; - } - - .card-browser-tile-name { - font-size: 0.85rem; - } - - .card-browser-tile-type { - font-size: 0.75rem; - } - - .card-browser-tile-info { - padding: 0.5rem; - } -} - -/* Theme chips for multi-select */ - -.theme-chip { - display: inline-flex; - align-items: center; - background: var(--primary-bg); - color: var(--primary-fg); - padding: 0.25rem 0.75rem; - border-radius: 1rem; - font-size: 0.9rem; - border: 1px solid var(--border-color); -} - -.theme-chip button { - margin-left: 0.5rem; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - font-weight: bold; - font-size: 1.2rem; - line-height: 1; -} - -.theme-chip button:hover { - color: var(--error-color); -} - -/* Card Detail Page Styles */ - -.card-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; - margin-bottom: 1rem; -} - -.card-tag { - background: var(--ring); - color: white; - padding: 0.35rem 0.75rem; - border-radius: 16px; - font-size: 0.85rem; - font-weight: 500; -} - -.back-button { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: var(--panel); - color: var(--text); - text-decoration: none; - border-radius: 8px; - border: 1px solid var(--border); - font-weight: 500; - transition: all 0.2s; - margin-bottom: 2rem; -} - -.back-button:hover { - background: var(--ring); - color: white; - border-color: var(--ring); -} - -/* Card Detail Page - Main Card Image */ - -.card-image-large { - flex: 0 0 auto; - max-width: 360px !important; - width: 100%; -} - -.card-image-large img { - width: 100%; - height: auto; - border-radius: 12px; -} - -/* ============================================ - M2 Component Library Styles - ============================================ */ - -/* === BUTTONS === */ - -/* Button Base - enhanced from existing .btn */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - background: var(--blue-main); - color: #fff; - border: none; - border-radius: 6px; - padding: 0.5rem 1rem; - cursor: pointer; - text-decoration: none; - line-height: 1.5; - font-weight: 500; - transition: filter 0.15s ease, transform 0.05s ease; - white-space: nowrap; -} - -.btn:hover { - filter: brightness(1.1); - text-decoration: none; -} - -.btn:active { - transform: scale(0.98); -} - -.btn:disabled, -.btn.disabled, -.btn[aria-disabled="true"] { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -/* Button Variants */ - -.btn-primary { - background: var(--blue-main); - color: #fff; -} - -.btn-secondary { - background: var(--muted); - color: var(--text); -} - -.btn-ghost { - background: transparent; - color: var(--text); - border: 1px solid var(--border); -} - -.btn-ghost:hover { - background: var(--panel); - border-color: var(--text); -} - -.btn-danger { - background: var(--err); - color: #fff; -} - -/* Button Sizes */ - -.btn-sm { - padding: 0.25rem 0.75rem; - font-size: 0.875rem; -} - -.btn-md { - padding: 0.5rem 1rem; - font-size: 0.875rem; -} - -.btn-lg { - padding: 0.75rem 1.5rem; - font-size: 1rem; -} - -/* Icon Button */ - -.btn-icon { - padding: 0.5rem; - aspect-ratio: 1; - justify-content: center; -} - -.btn-icon.btn-sm { - padding: 0.25rem; - font-size: 1rem; -} - -/* Close Button */ - -.btn-close { - position: absolute; - top: 0.75rem; - right: 0.75rem; - font-size: 1.5rem; - line-height: 1; - z-index: 10; -} - -/* Tag/Chip Button */ - -.btn-tag { - display: inline-flex; - align-items: center; - gap: 0.375rem; - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 16px; - padding: 0.25rem 0.75rem; - font-size: 0.875rem; - transition: all 0.15s ease; -} - -.btn-tag:hover { - background: var(--border); - border-color: var(--text); -} - -.btn-tag-selected { - background: var(--blue-main); - color: #fff; - border-color: var(--blue-main); -} - -.btn-tag-remove { - background: transparent; - border: none; - color: inherit; - padding: 0; - margin: 0; - font-size: 1rem; - line-height: 1; - cursor: pointer; - opacity: 0.7; -} - -.btn-tag-remove:hover { - opacity: 1; -} - -/* Button Group */ - -.btn-group { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.btn-group-left { - justify-content: flex-start; -} - -.btn-group-center { - justify-content: center; -} - -.btn-group-right { - justify-content: flex-end; -} - -.btn-group-between { - justify-content: space-between; -} - -/* Legacy action-btn compatibility */ - -.action-btn { - padding: 0.75rem 1.5rem; - font-size: 1rem; -} - -/* === MODALS === */ - -.modal { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; -} - -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(2px); - z-index: -1; -} - -.modal-content { - position: relative; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - padding: 1rem; - width: 100%; - max-height: min(92vh, 100%); - display: flex; - flex-direction: column; -} - -/* Modal Sizes */ - -.modal-sm .modal-content { - max-width: 480px; -} - -.modal-md .modal-content { - max-width: 620px; -} - -.modal-lg .modal-content { - max-width: 720px; -} - -.modal-xl .modal-content { - max-width: 960px; -} - -/* Modal Position */ - -.modal-center { - align-items: center; -} - -.modal-top { - align-items: flex-start; - padding-top: 2rem; -} - -/* Modal Scrollable */ - -.modal-scrollable .modal-content { - overflow: auto; - -webkit-overflow-scrolling: touch; -} - -/* Modal Structure */ - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; - padding-right: 2rem; -} - -.modal-title { - font-size: 1.25rem; - font-weight: 600; - margin: 0; - color: var(--text); -} - -.modal-body { - flex: 1; - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} - -.modal-footer { - display: flex; - gap: 0.5rem; - justify-content: flex-end; - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border); -} - -/* Modal Variants */ - -.modal-confirm .modal-body { - padding: 1rem 0; - font-size: 0.95rem; -} - -.modal-alert { - text-align: center; -} - -.modal-alert .modal-body { - padding: 1.5rem 0; -} - -.modal-alert .alert-icon { - font-size: 3rem; - margin-bottom: 1rem; -} - -.modal-alert-info .alert-icon::before { - content: 'ℹ️'; -} - -.modal-alert-success .alert-icon::before { - content: '✅'; -} - -.modal-alert-warning .alert-icon::before { - content: '⚠️'; -} - -.modal-alert-error .alert-icon::before { - content: '❌'; -} - -/* === FORMS === */ - -.form-field { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.form-label { - font-weight: 500; - font-size: 0.875rem; - color: var(--text); - display: flex; - align-items: center; - gap: 0.25rem; -} - -.form-required { - color: var(--err); - font-weight: bold; -} - -.form-input-wrapper { - display: flex; - flex-direction: column; -} - -.form-input, -.form-textarea, -.form-select { - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - width: 100%; -} - -.form-input:focus, -.form-textarea:focus, -.form-select:focus { - outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); -} - -.form-input:disabled, -.form-textarea:disabled, -.form-select:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.form-textarea { - resize: vertical; - min-height: 80px; -} - -.form-input-number { - max-width: 150px; -} - -.form-input-file { - padding: 0.375rem 0.5rem; -} - -/* Checkbox and Radio */ - -.form-field-checkbox, -.form-field-radio { - flex-direction: row; - align-items: flex-start; -} - -.form-checkbox-label, -.form-radio-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: normal; -} - -.form-checkbox, -.form-radio { - width: 1.125rem; - height: 1.125rem; - border: 1px solid var(--border); - cursor: pointer; - flex-shrink: 0; -} - -.form-checkbox { - border-radius: 4px; -} - -.form-radio { - border-radius: 50%; -} - -.form-checkbox:checked, -.form-radio:checked { - background: var(--blue-main); - border-color: var(--blue-main); -} - -.form-checkbox:focus, -.form-radio:focus { - outline: none; - box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); -} - -.form-radio-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* Form Help and Error Text */ - -.form-help-text { - font-size: 0.8rem; - color: var(--muted); - margin-top: -0.25rem; -} - -.form-error-text { - font-size: 0.8rem; - color: var(--err); - margin-top: -0.25rem; -} - -.form-field-error .form-input, -.form-field-error .form-textarea, -.form-field-error .form-select { - border-color: var(--err); -} - -/* === CARD DISPLAY COMPONENTS === */ - -/* Card Thumbnail Container */ - -.card-thumb-container { - position: relative; - display: inline-block; -} - -.card-thumb { - display: block; - border-radius: 10px; - border: 1px solid var(--border); - background: #0b0d12; - -o-object-fit: cover; - object-fit: cover; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.card-thumb:hover { - transform: translateY(-2px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); -} - -/* Card Thumbnail Sizes */ - -.card-thumb-small .card-thumb { - width: 160px; - height: auto; -} - -.card-thumb-medium .card-thumb { - width: 230px; - height: auto; -} - -.card-thumb-large .card-thumb { - width: 360px; - height: auto; -} - -/* Card Flip Button */ - -.card-flip-btn { - position: absolute; - bottom: 8px; - right: 8px; - background: rgba(0, 0, 0, 0.75); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 6px; - padding: 0.375rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(4px); - transition: background 0.15s ease; - z-index: 5; -} - -.card-flip-btn:hover { - background: rgba(0, 0, 0, 0.9); - border-color: rgba(255, 255, 255, 0.4); -} - -.card-flip-btn svg { - width: 16px; - height: 16px; -} - -/* Card Name Label */ - -.card-name-label { - font-size: 0.75rem; - margin-top: 0.375rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 600; - text-align: center; -} - -/* Card Hover Popup */ - -.card-popup { - position: fixed; - inset: 0; - z-index: 2000; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; -} - -.card-popup-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(2px); - z-index: -1; -} - -.card-popup-content { - position: relative; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - padding: 1rem; - max-width: 400px; - width: 100%; -} - -.card-popup-image { - position: relative; - margin-bottom: 1rem; -} - -.card-popup-image img { - width: 100%; - height: auto; - border-radius: 10px; - border: 1px solid var(--border); -} - -.card-popup-info { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.card-popup-name { - font-size: 1.125rem; - font-weight: 600; - margin: 0; - color: var(--text); -} - -.card-popup-role { - font-size: 0.875rem; - color: var(--muted); -} - -.card-popup-role span { - color: var(--text); - font-weight: 500; -} - -.card-popup-tags { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.card-popup-tag { - background: var(--panel); - border: 1px solid var(--border); - color: var(--text); - padding: 0.25rem 0.5rem; - border-radius: 12px; - font-size: 0.75rem; -} - -.card-popup-tag-highlight { - background: var(--blue-main); - color: #fff; - border-color: var(--blue-main); -} - -.card-popup-close { - position: absolute; - top: 0.5rem; - right: 0.5rem; - background: rgba(0, 0, 0, 0.75); - color: #fff; - border: none; - border-radius: 6px; - width: 2rem; - height: 2rem; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - line-height: 1; - cursor: pointer; - backdrop-filter: blur(4px); -} - -.card-popup-close:hover { - background: rgba(0, 0, 0, 0.9); -} - -/* Card Grid */ - -.card-grid { - display: grid; - gap: 0.75rem; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); -} - -.card-grid-cols-auto { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); -} - -.card-grid-cols-2 { - grid-template-columns: repeat(2, 1fr); -} - -.card-grid-cols-3 { - grid-template-columns: repeat(3, 1fr); -} - -.card-grid-cols-4 { - grid-template-columns: repeat(4, 1fr); -} - -.card-grid-cols-5 { - grid-template-columns: repeat(5, 1fr); -} - -.card-grid-cols-6 { - grid-template-columns: repeat(6, 1fr); -} - -@media (max-width: 768px) { - .card-grid { - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - } -} - -/* Card List */ - -.card-list-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--panel); - transition: background 0.15s ease; -} - -.card-list-item:hover { - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); -} - -.card-list-item-info { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; -} - -.card-list-item-name { - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-list-item-count { - color: var(--muted); - font-size: 0.875rem; -} - -.card-list-item-role { - color: var(--muted); - font-size: 0.75rem; - padding: 0.125rem 0.5rem; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; -} - -/* Synthetic Card Placeholder */ - -.card-sample.synthetic { - border: 1px dashed var(--border); - border-radius: 10px; - background: var(--panel); - padding: 1rem; - display: flex; - align-items: center; - justify-content: center; -} - -.synthetic-card-placeholder { - text-align: center; -} - -.synthetic-card-icon { - font-size: 2rem; - opacity: 0.5; - margin-bottom: 0.5rem; -} - -.synthetic-card-name { - font-weight: 600; - font-size: 0.875rem; - margin-bottom: 0.25rem; -} - -.synthetic-card-reason { - font-size: 0.75rem; - color: var(--muted); -} - -/* === PANELS === */ - -.panel { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - margin-bottom: 0.75rem; -} - -/* Panel Variants */ - -.panel-default { - background: var(--panel); -} - -.panel-alt { - background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%); -} - -.panel-dark { - background: #0f1115; -} - -.panel-bordered { - background: transparent; -} - -/* Panel Padding */ - -.panel-padding-none { - padding: 0; -} - -.panel-padding-sm { - padding: 0.5rem; -} - -.panel-padding-md { - padding: 0.75rem; -} - -.panel-padding-lg { - padding: 1.5rem; -} - -/* Panel Structure */ - -.panel-header { - padding: 0.75rem; - border-bottom: 1px solid var(--border); -} - -.panel-title { - font-size: 1.125rem; - font-weight: 600; - margin: 0; - color: var(--text); -} - -.panel-body { - padding: 0.75rem; -} - -.panel-footer { - padding: 0.75rem; - border-top: 1px solid var(--border); -} - -/* Info Panel */ - -.panel-info { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1rem; -} - -.panel-info-content { - display: flex; - align-items: flex-start; - gap: 0.75rem; - flex: 1; -} - -.panel-info-icon { - font-size: 1.5rem; - flex-shrink: 0; -} - -.panel-info-text { - flex: 1; -} - -.panel-info-title { - font-size: 1rem; - font-weight: 600; - margin: 0 0 0.25rem; - color: var(--text); -} - -.panel-info-message { - font-size: 0.875rem; - color: var(--muted); -} - -.panel-info-action { - flex-shrink: 0; -} - -/* Info Panel Variants */ - -.panel-info-info { - border-color: var(--ring); - background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%); -} - -.panel-info-success { - border-color: var(--ok); - background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%); -} - -.panel-info-warning { - border-color: var(--warn); - background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%); -} - -.panel-info-error { - border-color: var(--err); - background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%); -} - -/* Stat Panel */ - -.panel-stat { - display: flex; - align-items: center; - gap: 1rem; - padding: 1rem; - text-align: center; - flex-direction: column; -} - -.panel-stat-icon { - font-size: 2rem; -} - -.panel-stat-content { - display: flex; - flex-direction: column; - align-items: center; -} - -.panel-stat-value { - font-size: 2rem; - font-weight: 700; - line-height: 1; - color: var(--text); -} - -.panel-stat-label { - font-size: 0.875rem; - color: var(--muted); - margin-top: 0.25rem; -} - -.panel-stat-sublabel { - font-size: 0.75rem; - color: var(--muted); - margin-top: 0.125rem; -} - -/* Stat Panel Variants */ - -.panel-stat-primary { - border-color: var(--ring); -} - -.panel-stat-primary .panel-stat-value { - color: var(--ring); -} - -.panel-stat-success { - border-color: var(--ok); -} - -.panel-stat-success .panel-stat-value { - color: var(--ok); -} - -.panel-stat-warning { - border-color: var(--warn); -} - -.panel-stat-warning .panel-stat-value { - color: var(--warn); -} - -.panel-stat-error { - border-color: var(--err); -} - -.panel-stat-error .panel-stat-value { - color: var(--err); -} - -/* Collapsible Panel */ - -.panel-collapsible .panel-header { - padding: 0; - border: none; -} - -.panel-toggle { - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem; - background: transparent; - border: none; - color: var(--text); - cursor: pointer; - text-align: left; - border-radius: 10px 10px 0 0; - transition: background 0.15s ease; -} - -.panel-toggle:hover { - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); -} - -.panel-toggle-icon { - width: 0; - height: 0; - border-left: 6px solid transparent; - border-right: 6px solid transparent; - border-top: 8px solid var(--text); - transition: transform 0.2s ease; -} - -.panel-collapsed .panel-toggle-icon { - transform: rotate(-90deg); -} - -.panel-expanded .panel-toggle-icon { - transform: rotate(0deg); -} - -.panel-collapse-content { - overflow: hidden; - transition: max-height 0.3s ease; -} - -/* Panel Grid */ - -.panel-grid { - display: grid; - gap: 1rem; -} - -.panel-grid-cols-auto { - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); -} - -.panel-grid-cols-1 { - grid-template-columns: 1fr; -} - -.panel-grid-cols-2 { - grid-template-columns: repeat(2, 1fr); -} - -.panel-grid-cols-3 { - grid-template-columns: repeat(3, 1fr); -} - -.panel-grid-cols-4 { - grid-template-columns: repeat(4, 1fr); -} - -@media (max-width: 768px) { - .panel-grid { - grid-template-columns: 1fr; - } -} - -/* Empty State Panel */ - -.panel-empty-state { - text-align: center; - padding: 3rem 1.5rem; -} - -.panel-empty-icon { - font-size: 4rem; - opacity: 0.5; - margin-bottom: 1rem; -} - -.panel-empty-title { - font-size: 1.25rem; - font-weight: 600; - margin: 0 0 0.5rem; - color: var(--text); -} - -.panel-empty-message { - font-size: 0.95rem; - color: var(--muted); - margin: 0 0 1.5rem; -} - -.panel-empty-action { - display: flex; - justify-content: center; -} - -/* Loading Panel */ - -.panel-loading { - text-align: center; - padding: 2rem 1rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -.panel-loading-spinner { - width: 3rem; - height: 3rem; - border: 4px solid var(--border); - border-top-color: var(--ring); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.panel-loading-message { - font-size: 0.95rem; - color: var(--muted); -} - -/* ============================================================================= - UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21) - ============================================================================= */ - -/* Flex Row Layouts */ - -.flex-row { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.flex-row-sm { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.flex-row-md { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.flex-row-lg { - display: flex; - align-items: center; - gap: 1rem; -} - -.flex-row-between { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} - -.flex-row-wrap { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.flex-row-start { - display: flex; - align-items: flex-start; - gap: 0.5rem; -} - -/* Flex Column Layouts */ - -.flex-col { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.flex-col-sm { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.flex-col-md { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.flex-col-lg { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.flex-col-center { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; -} - -/* Flex Grid/Wrap Patterns */ - -.flex-grid { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.flex-grid-sm { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; -} - -.flex-grid-md { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - -.flex-grid-lg { - display: flex; - flex-wrap: wrap; - gap: 1rem; -} - -/* Spacing Utilities */ - -.section-spacing { - margin-top: 2rem; -} - -.section-spacing-sm { - margin-top: 1rem; -} - -.section-spacing-lg { - margin-top: 3rem; -} - -.content-spacing { - margin-bottom: 1rem; -} - -.content-spacing-sm { - margin-bottom: 0.5rem; -} - -.content-spacing-lg { - margin-bottom: 2rem; -} - -/* Common Size Constraints */ - -.max-w-content { - max-width: 1200px; - margin-left: auto; - margin-right: auto; -} - -.max-w-prose { - max-width: 65ch; - margin-left: auto; - margin-right: auto; -} - -.max-w-form { - max-width: 600px; -} - -/* Common Text Patterns */ - -.text-muted { - color: var(--muted); - opacity: 0.85; -} - -.text-xs { - font-size: 0.75rem; - line-height: 1.25; -} - -.text-sm { - font-size: 0.875rem; - line-height: 1.35; -} - -.text-base { - font-size: 1rem; - line-height: 1.5; -} - -/* Screen Reader Only */ - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* ============================================================================= - CARD HOVER SYSTEM (Moved from base.html 2025-10-21) - ============================================================================= */ - -.card-hover { - position: fixed; - pointer-events: none; - z-index: 9999; - display: none; -} - -.card-hover-inner { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.card-hover img { - width: 320px; - height: auto; - display: block; - border-radius: 8px; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); - border: 1px solid var(--border); - background: var(--panel); -} - -.card-hover .dual { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.card-meta { - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.5rem 0.6rem; - max-width: 320px; - font-size: 13px; - line-height: 1.4; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); -} - -.card-meta ul { - margin: 0.25rem 0; - padding-left: 1.1rem; - list-style: disc; -} - -.card-meta li { - margin: 0.1rem 0; -} - -.card-meta .themes-list { - font-size: 18px; - line-height: 1.35; -} - -.card-meta .label { - color: #94a3b8; - text-transform: uppercase; - font-size: 10px; - letter-spacing: 0.04em; - display: block; - margin-bottom: 0.15rem; -} - -.card-meta .themes-label { - color: var(--text); - font-size: 20px; - letter-spacing: 0.05em; -} - -.card-meta .line + .line { - margin-top: 0.35rem; -} - -.card-hover .themes-list li.overlap { - color: #0ea5e9; - font-weight: 600; -} - -.card-hover .ov-chip { - display: inline-block; - background: #38bdf8; - color: #102746; - border: 1px solid #0f3a57; - border-radius: 12px; - padding: 2px 6px; - font-size: 11px; - margin-right: 4px; - font-weight: 600; -} - -/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */ - -.card-hover .dual.two-faced img { - width: 320px; -} - -.card-hover .dual.two-faced { - gap: 8px; -} - -/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */ - -.card-hover .dual.combo img { - width: 300px; -} - -@media (max-width: 1100px) { - .card-hover .dual.two-faced img { - width: 280px; - } - - .card-hover .dual.combo img { - width: 260px; - } -} - -/* Hide hover preview on narrow screens to avoid covering content */ - -@media (max-width: 900px) { - .card-hover { - display: none !important; - } -} - -/* ============================================================================= - THEME BADGES (Moved from base.html 2025-10-21) - ============================================================================= */ - -.theme-badge { - display: inline-block; - padding: 2px 6px; - border-radius: 12px; - font-size: 10px; - background: var(--panel-alt); - border: 1px solid var(--border); - letter-spacing: 0.5px; -} - -.theme-synergies { - font-size: 11px; - opacity: 0.85; - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.badge-fallback { - background: #7f1d1d; - color: #fff; -} - -.badge-quality-draft { - background: #4338ca; - color: #fff; -} - -.badge-quality-reviewed { - background: #065f46; - color: #fff; -} - -.badge-quality-final { - background: #065f46; - color: #fff; - font-weight: 600; -} - -.badge-pop-vc { - background: #065f46; - color: #fff; -} - -.badge-pop-c { - background: #047857; - color: #fff; -} - -.badge-pop-u { - background: #0369a1; - color: #fff; -} - -.badge-pop-n { - background: #92400e; - color: #fff; -} - -.badge-pop-r { - background: #7f1d1d; - color: #fff; -} - -.badge-curated { - background: #4f46e5; - color: #fff; -} - -.badge-enforced { - background: #334155; - color: #fff; -} - -.badge-inferred { - background: #57534e; - color: #fff; -} - -.theme-detail-card { - background: var(--panel); - padding: 1rem 1.1rem; - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); -} - -.theme-list-card { - background: var(--panel); - padding: 0.6rem 0.75rem; - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: background-color 0.15s ease; -} - -.theme-list-card:hover { - background: var(--hover); -} - -.theme-detail-card h3 { - margin-top: 0; - margin-bottom: 0.4rem; -} - -.theme-detail-card .desc { - margin-top: 0; - font-size: 13px; - line-height: 1.45; -} - -.theme-detail-card h4 { - margin-bottom: 0.35rem; - margin-top: 0.85rem; - font-size: 13px; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0.85; -} - -.breadcrumb { - font-size: 12px; - margin-bottom: 0.4rem; -} - -/* ============================================================================= - HOVER CARD PANEL (Moved from base.html 2025-10-21) - ============================================================================= */ - -/* Unified hover-card-panel styling parity */ - -#hover-card-panel.is-payoff { - border-color: var(--accent, #38bdf8); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset; -} - -#hover-card-panel.is-payoff .hcp-img { - border-color: var(--accent, #38bdf8); -} - -/* Two-column hover layout */ - -#hover-card-panel .hcp-body { - display: grid; - grid-template-columns: 320px 1fr; - gap: 18px; - align-items: start; -} - -#hover-card-panel .hcp-img-wrap { - grid-column: 1 / 2; -} - -#hover-card-panel.compact-img .hcp-body { - grid-template-columns: 120px 1fr; -} - -#hover-card-panel.hcp-simple { - width: auto !important; - max-width: min(360px, 90vw) !important; - padding: 12px !important; - height: auto !important; - max-height: none !important; - overflow: hidden !important; -} - -#hover-card-panel.hcp-simple .hcp-body { - display: flex; - flex-direction: column; - gap: 12px; - align-items: center; -} - -#hover-card-panel.hcp-simple .hcp-right { - display: none !important; -} - -#hover-card-panel.hcp-simple .hcp-img { - max-width: 100%; -} - -/* Tag list as multi-column list instead of pill chips for readability */ - -#hover-card-panel .hcp-taglist { - -moz-columns: 2; - columns: 2; - -moz-column-gap: 18px; - column-gap: 18px; - font-size: 13px; - line-height: 1.3; - margin: 6px 0 6px; - padding: 0; - list-style: none; - max-height: 180px; - overflow: auto; -} - -#hover-card-panel .hcp-taglist li { - -moz-column-break-inside: avoid; - break-inside: avoid; - padding: 2px 0 2px 0; - position: relative; -} - -#hover-card-panel .hcp-taglist li.overlap { - font-weight: 600; - color: var(--accent, #38bdf8); -} - -#hover-card-panel .hcp-taglist li.overlap::before { - content: '•'; - color: var(--accent, #38bdf8); - position: absolute; - left: -10px; -} - -#hover-card-panel .hcp-overlaps { - font-size: 10px; - line-height: 1.25; - margin-top: 2px; -} - -#hover-card-panel .hcp-ov-chip { - display: inline-flex; - align-items: center; - background: var(--accent, #38bdf8); - color: #102746; - border: 1px solid rgba(10, 54, 82, 0.6); - border-radius: 9999px; - padding: 3px 10px; - font-size: 13px; - margin-right: 6px; - margin-top: 4px; - font-weight: 500; - letter-spacing: 0.02em; -} - -/* Mobile hover panel */ - -#hover-card-panel.mobile { - left: 50% !important; - top: 50% !important; - bottom: auto !important; - transform: translate(-50%, -50%); - width: min(94vw, 460px) !important; - max-height: 88vh; - overflow-y: auto; - padding: 20px 22px; - pointer-events: auto !important; -} - -#hover-card-panel.mobile .hcp-body { - display: flex; - flex-direction: column; - gap: 20px; -} - -#hover-card-panel.mobile .hcp-img { - width: 100%; - max-width: min(90vw, 420px) !important; - margin: 0 auto; -} - -#hover-card-panel.mobile .hcp-right { - width: 100%; - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-start; -} - -#hover-card-panel.mobile .hcp-header { - flex-wrap: wrap; - gap: 8px; - align-items: flex-start; -} - -#hover-card-panel.mobile .hcp-role { - font-size: 12px; - letter-spacing: 0.55px; -} - -#hover-card-panel.mobile .hcp-meta { - font-size: 13px; - text-align: left; -} - -#hover-card-panel.mobile .hcp-overlaps { - display: flex; - flex-wrap: wrap; - gap: 6px; - width: 100%; -} - -#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { - margin: 0; -} - -#hover-card-panel.mobile .hcp-taglist { - -moz-columns: 1; - columns: 1; - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 4px 0 2px; - max-height: none; - overflow: visible; - padding: 0; -} - -#hover-card-panel.mobile .hcp-taglist li { - background: rgba(37, 99, 235, 0.18); - border-radius: 9999px; - padding: 4px 10px; - display: inline-flex; - align-items: center; -} - -#hover-card-panel.mobile .hcp-taglist li.overlap { - background: rgba(37, 99, 235, 0.28); - color: #dbeafe; -} - -#hover-card-panel.mobile .hcp-taglist li.overlap::before { - display: none; -} - -#hover-card-panel.mobile .hcp-reasons { - max-height: 220px; - width: 100%; -} - -#hover-card-panel.mobile .hcp-tags { - word-break: normal; - white-space: normal; - text-align: left; - width: 100%; - font-size: 12px; - opacity: 0.7; -} - -#hover-card-panel .hcp-close { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - border: none; - background: transparent; - color: #9ca3af; - font-size: 18px; - line-height: 1; - padding: 2px 4px; - cursor: pointer; - border-radius: 6px; - display: none; -} - -#hover-card-panel .hcp-close:focus { - outline: 2px solid rgba(59, 130, 246, 0.6); - outline-offset: 2px; -} - -#hover-card-panel.mobile .hcp-close { - display: inline-flex; -} - -/* Fade transition for hover panel image */ - -#hover-card-panel .hcp-img { - transition: opacity 0.22s ease; -} - -/* ============================================================================= - DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21) - ============================================================================= */ - -/* Hide modal-specific close button outside modal host */ - -#preview-close-btn { - display: none; -} - -#theme-preview-modal #preview-close-btn { - display: inline-flex; -} - -/* Overlay flip toggle for double-faced cards */ - -.dfc-host { - position: relative; -} - -.dfc-toggle { - position: absolute; - top: 6px; - left: 6px; - z-index: 5; - background: rgba(15, 23, 42, 0.82); - color: #fff; - border: 1px solid #475569; - border-radius: 50%; - width: 36px; - height: 36px; - padding: 0; - font-size: 16px; - cursor: pointer; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.92; - backdrop-filter: blur(3px); -} - -.dfc-toggle:hover, -.dfc-toggle:focus { - opacity: 1; - box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.35); - outline: none; -} - -.dfc-toggle:active { - transform: translateY(1px); -} - -.dfc-toggle .icon { - font-size: 12px; -} - -.dfc-toggle[data-face='back'] { - background: rgba(76, 29, 149, 0.85); -} - -.dfc-toggle[data-face='front'] { - background: rgba(15, 23, 42, 0.82); -} - -.dfc-toggle[aria-pressed='true'] { - box-shadow: 0 0 0 2px var(--accent, #38bdf8); -} - -.list-row .dfc-toggle { - position: static; - width: auto; - height: auto; - border-radius: 6px; - padding: 2px 8px; - font-size: 12px; - opacity: 1; - backdrop-filter: none; - margin-left: 4px; -} - -.list-row .dfc-toggle .icon { - font-size: 12px; -} - -.list-row .dfc-toggle[data-face='back'] { - background: rgba(76, 29, 149, 0.3); -} - -.list-row .dfc-toggle[data-face='front'] { - background: rgba(56, 189, 248, 0.2); -} - -/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */ - -/* ============================================================================= - SITE FOOTER (Moved from base.html 2025-10-21) - ============================================================================= */ - -.site-footer { - margin: 8px 16px; - padding: 8px 12px; - border-top: 1px solid var(--border); - color: #94a3b8; - font-size: 12px; - text-align: center; -} - -.site-footer a { - color: #cbd5e1; - text-decoration: underline; -} - -/* ============================================================================= - THEME PREVIEW FRAGMENT (themes/preview_fragment.html) - ============================================================================= */ - -/* Preview header */ - -.preview-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; -} - -.preview-header h3 { - margin: 0; - font-size: 16px; -} - -.preview-header .btn { - font-size: 12px; - line-height: 1; -} - -/* Preview controls */ - -.preview-controls { - display: flex; - gap: 1rem; - align-items: center; - margin: 0.5rem 0 0.75rem; - font-size: 11px; -} - -.preview-controls label { - display: inline-flex; - gap: 4px; - align-items: center; -} - -.preview-controls .help-icon { - opacity: 0.55; - font-size: 10px; - cursor: help; -} - -.preview-controls #preview-status { - opacity: 0.65; -} - -/* Preview rationale */ - -.preview-rationale { - margin: 0.25rem 0 0.85rem; - font-size: 11px; - background: var(--panel-alt); - border: 1px solid var(--border); - padding: 0.55rem 0.7rem; - border-radius: 8px; -} - -.preview-rationale summary { - cursor: pointer; - font-weight: 600; - letter-spacing: 0.05em; -} - -.preview-rationale-controls { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: center; - margin-top: 0.4rem; -} - -.preview-rationale-controls .btn { - font-size: 10px; - padding: 4px 8px; -} - -.preview-rationale-controls #hover-compact-indicator { - font-size: 10px; - opacity: 0.7; -} - -.preview-rationale ul { - margin: 0.5rem 0 0 0.9rem; - padding: 0; - list-style: disc; - line-height: 1.35; -} - -.preview-rationale li .detail { - opacity: 0.75; -} - -.preview-rationale li .instances { - opacity: 0.65; -} - -/* Two column layout */ - -.preview-two-col { - display: grid; - grid-template-columns: 1fr 480px; - gap: 1.25rem; - align-items: start; - position: relative; -} - -.preview-col-divider { - position: absolute; - top: 0; - bottom: 0; - left: calc(100% - 480px - 0.75rem); - width: 1px; - background: var(--border); - opacity: 0.55; -} - -/* Section headers */ - -.preview-section-header { - margin: 0.25rem 0 0.5rem; - font-size: 13px; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0.8; -} - -.preview-section-hr { - border: 0; - border-top: 1px solid var(--border); - margin: 0.35rem 0 0.6rem; -} - -/* Cards flow layout */ - -.cards-flow { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -/* Group separators */ - -.group-separator { - flex-basis: 100%; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.65; - margin-top: 0.25rem; -} - -.group-separator.mt-larger { - margin-top: 0.5rem; -} - -/* Card sample */ - -.card-sample { - width: 230px; -} - -.card-sample .thumb-wrap { - position: relative; -} - -.card-sample img.card-thumb { - filter: blur(4px); - transition: filter 0.35s ease; - background: linear-gradient(145deg, #0b0d12, #111b29); -} - -.card-sample img.card-thumb[data-loaded] { - filter: blur(0); -} - -/* Card badges */ - -.dup-badge { - position: absolute; - bottom: 4px; - right: 4px; - background: #4b5563; - color: #fff; - font-size: 10px; - padding: 2px 5px; - border-radius: 10px; -} - -.pin-btn { - position: absolute; - top: 4px; - right: 4px; - background: rgba(0, 0, 0, 0.55); - color: #fff; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 10px; - padding: 2px 5px; - cursor: pointer; -} - -/* Card metadata */ - -.card-sample .meta { - font-size: 12px; - margin-top: 2px; -} - -.card-sample .ci-ribbon { - display: flex; - gap: 2px; - margin-bottom: 2px; - min-height: 10px; -} - -.card-sample .nm { - font-weight: 600; - line-height: 1.25; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-sample .mana-line { - min-height: 14px; - display: flex; - flex-wrap: wrap; - gap: 2px; - font-size: 10px; -} - -.card-sample .rarity-badge { - font-size: 9px; - letter-spacing: 0.5px; - text-transform: uppercase; - opacity: 0.7; -} - -.card-sample .role { - opacity: 0.75; - font-size: 11px; - display: flex; - flex-wrap: wrap; - gap: 3px; -} - -.card-sample .reasons { - font-size: 9px; - opacity: 0.55; - line-height: 1.15; -} - -/* Synthetic card */ - -.card-sample.synthetic { - border: 1px dashed var(--border); - padding: 8px; - border-radius: 10px; - background: var(--panel-alt); -} - -.card-sample.synthetic .name { - font-size: 12px; - font-weight: 600; - line-height: 1.2; -} - -.card-sample.synthetic .roles { - font-size: 11px; - opacity: 0.8; -} - -.card-sample.synthetic .reasons-text { - font-size: 10px; - margin-top: 2px; - opacity: 0.6; - line-height: 1.15; -} - -/* Spacer */ - -.full-width-spacer { - flex-basis: 100%; - height: 0; -} - -/* Commander grid */ - -.commander-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); - gap: 1rem; -} - -.commander-cell { - display: flex; - flex-direction: column; - gap: 0.35rem; - align-items: center; -} - -.commander-name { - font-size: 13px; - text-align: center; - line-height: 1.35; - font-weight: 600; - max-width: 230px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.commander-cell.synergy .commander-name { - font-size: 12px; - line-height: 1.3; - font-weight: 500; - opacity: 0.92; -} - -/* Synergy commanders section */ - -.synergy-commanders-section { - margin-top: 1rem; -} - -.synergy-commanders-header { - display: flex; - align-items: center; - gap: 0.4rem; - margin-bottom: 0.4rem; -} - -.synergy-commanders-header h5 { - margin: 0; - font-size: 11px; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0.75; -} - -.derived-badge { - background: var(--panel-alt); - border: 1px solid var(--border); - border-radius: 10px; - padding: 2px 6px; - font-size: 10px; - line-height: 1; -} - -/* No commanders message */ - -.no-commanders-message { - font-size: 11px; - opacity: 0.7; -} - -/* Footer help text */ - -.preview-help-text { - margin-top: 1rem; - font-size: 10px; - opacity: 0.65; - line-height: 1.4; -} - -/* Skeleton loader */ - -.preview-skeleton .sk-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.preview-skeleton .sk-bar { - height: 16px; - background: var(--hover); - border-radius: 4px; -} - -.preview-skeleton .sk-bar.title { - width: 200px; -} - -.preview-skeleton .sk-bar.close { - width: 60px; -} - -.preview-skeleton .sk-cards { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 1rem; -} - -.preview-skeleton .sk-card { - width: 230px; - height: 327px; - background: var(--hover); - border-radius: 10px; -} - -/* Responsive */ - -@media (max-width: 950px) { - .preview-two-col { - grid-template-columns: 1fr; - } - - .preview-two-col .col-right { - order: -1; - } -} - -footer.site-footer { - flex-shrink: 0; -} - diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css deleted file mode 100644 index f8d085c..0000000 --- a/code/web/static/tailwind.css +++ /dev/null @@ -1,3537 +0,0 @@ -/* Tailwind CSS Entry Point */ -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Import custom CSS (not purged by Tailwind) */ -@import './custom.css'; - -/* Base */ -:root{ - /* MTG color palette (approx from provided values) */ - --banner-h: 52px; - --sidebar-w: 260px; - --green-main: rgb(0,115,62); - --green-light: rgb(196,211,202); - --blue-main: rgb(14,104,171); - --blue-light: rgb(179,206,234); - --red-main: rgb(211,32,42); - --red-light: rgb(235,159,130); - --white-main: rgb(249,250,244); - --white-light: rgb(248,231,185); - --black-main: rgb(21,11,0); - --black-light: rgb(166,159,157); - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; - --ring: #60a5fa; /* focus ring */ - --ok: #16a34a; /* success */ - --warn: #f59e0b; /* warning */ - --err: #ef4444; /* error */ - /* Surface overrides for specific regions (default to panel) */ - --surface-banner: var(--panel); - --surface-banner-text: var(--text); - --surface-sidebar: var(--panel); - --surface-sidebar-text: var(--text); -} - -/* Light blend between Slate and Parchment (leans gray) */ -[data-theme="light-blend"]{ - --bg: #e8e2d0; /* warm beige background (keep existing) */ - --panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */ - --text: #0d0a08; /* very dark brown/near-black for strong readability */ - --muted: #5a544c; /* darker muted brown for better contrast */ - --border: #bfb5a3; /* darker warm-gray border for better definition */ - /* Navbar/banner: darker warm brown for hierarchy */ - --surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */ - --surface-sidebar: #9b8f7a; /* match banner for consistency */ - --surface-banner-text: #1a1410; /* dark brown text on medium brown bg */ - --surface-sidebar-text: #1a1410; /* dark brown text on medium brown bg */ - /* Button colors: use taupe for buttons so they stand out from light panels */ - --btn-bg: #d4cbb8; /* medium warm taupe - stands out against light panels */ - --btn-text: #1a1410; /* dark brown text */ - --btn-hover-bg: #c4b9a5; /* darker taupe on hover */ -} - -[data-theme="dark"]{ - --bg: #0f0f10; - --panel: #1a1b1e; - --text: #e8e8e8; - --muted: #b6b8bd; - --border: #2a2b2f; -} -[data-theme="high-contrast"]{ - --bg: #000; - --panel: #000; - --text: #fff; - --muted: #e5e7eb; - --border: #fff; - --ring: #ff0; -} -[data-theme="cb-friendly"]{ - /* Tweak accents for color-blind friendliness */ - --green-main: #2e7d32; /* darker green */ - --red-main: #c62828; /* deeper red */ - --blue-main: #1565c0; /* balanced blue */ -} -*{box-sizing:border-box} -html{height:100%; overflow-x:hidden; overflow-y:scroll; max-width:100vw;} -body { - font-family: system-ui, Arial, sans-serif; - margin: 0; - color: var(--text); - background: var(--bg); - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: scroll; -} -/* Honor HTML hidden attribute across the app */ -[hidden] { display: none !important; } -/* Accessible focus ring for keyboard navigation */ -.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } -/* Top banner - simplified, no changes on sidebar toggle */ -.top-banner{ position:sticky; top:0; z-index:10; background: var(--surface-banner); color: var(--surface-banner-text); border-bottom:1px solid var(--border); box-shadow:0 2px 6px rgba(0,0,0,.4); min-height: var(--banner-h); } -.top-banner .top-inner{ margin:0; padding:.4rem 15px; display:flex; align-items:center; width:100%; box-sizing:border-box; } -.top-banner h1{ font-size: 1.1rem; margin:0; margin-left: 25px; } -.flex-row{ display: flex; align-items: center; gap: 25px; } -.top-banner .banner-left{ width: 260px !important; flex-shrink: 0 !important; } -/* Hide elements on all screen sizes */ -#btn-open-permalink{ display:none !important; } -#banner-status{ display:none !important; } -.top-banner #theme-reset{ display:none !important; } - -/* Layout */ -.layout{ display:grid; grid-template-columns: var(--sidebar-w) minmax(0, 1fr); flex: 1 0 auto; } -.sidebar{ - background: var(--surface-sidebar); - color: var(--surface-sidebar-text); - border-right: 1px solid var(--border); - padding: 1rem; - position: fixed; - top: var(--banner-h); - left: 0; - bottom: 0; - overflow: auto; - width: var(--sidebar-w); - z-index: 9; /* below the banner (z=10) */ - box-shadow: 2px 0 10px rgba(0,0,0,.18); - display: flex; - flex-direction: column; -} -.content{ padding: 1.25rem 1.5rem; grid-column: 2; min-width: 0; } - -/* Collapsible sidebar behavior */ -body.nav-collapsed .layout{ grid-template-columns: 0 minmax(0, 1fr); } -body.nav-collapsed .sidebar{ transform: translateX(-100%); visibility: hidden; } -body.nav-collapsed .content{ grid-column: 2; } -/* Sidebar collapsed state doesn't change banner grid on desktop anymore */ -/* Smooth hide/show on mobile while keeping fixed positioning */ -.sidebar{ transition: transform .2s ease-out, visibility .2s linear; overflow-x: hidden; } -/* Suppress sidebar transitions during page load to prevent pop-in */ -body.no-transition .sidebar{ transition: none !important; } -/* Suppress sidebar transitions during HTMX partial updates to prevent distracting animations */ -body.htmx-settling .sidebar{ transition: none !important; } -body.htmx-settling .layout{ transition: none !important; } -body.htmx-settling .content{ transition: none !important; } -body.htmx-settling *{ transition-duration: 0s !important; } - -/* Mobile tweaks */ -@media (max-width: 900px){ - :root{ --sidebar-w: 240px; } - .layout{ grid-template-columns: 0 1fr; } - .sidebar{ transform: translateX(-100%); visibility: hidden; } - body:not(.nav-collapsed) .layout{ grid-template-columns: var(--sidebar-w) 1fr; } - body:not(.nav-collapsed) .sidebar{ transform: translateX(0); visibility: visible; } - .content{ padding: .9rem .6rem; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; } -} - -/* Additional mobile spacing for bottom floating controls */ -@media (max-width: 720px) { - .content { - padding-bottom: 6rem !important; /* Extra bottom padding to account for floating controls */ - } -} - -.brand h1{ display:none; } -.brand{ padding-top: 0; margin-top: 0; } -.mana-dots{ display:flex; gap:.35rem; margin-bottom:.5rem; margin-top: 0; padding-top: 0; } -.mana-dots .dot{ width:12px; height:12px; border-radius:50%; display:inline-block; border:1px solid rgba(0,0,0,.35); box-shadow:0 1px 2px rgba(0,0,0,.3) inset; } -.dot.green{ background: var(--green-main); } -.dot.blue{ background: var(--blue-main); } -.dot.red{ background: var(--red-main); } -.dot.white{ background: var(--white-light); border-color: rgba(0,0,0,.2); } -.dot.black{ background: var(--black-light); } - -.nav{ display:flex; flex-direction:column; gap:.35rem; } -.nav a{ color: var(--surface-sidebar-text); text-decoration:none; padding:.4rem .5rem; border-radius:6px; border:1px solid transparent; } -.nav a:hover{ background: color-mix(in srgb, var(--surface-sidebar) 85%, var(--surface-sidebar-text) 15%); border-color: var(--border); } - -/* Sidebar theme controls anchored at bottom */ -.sidebar .nav { flex: 1 1 auto; } -.sidebar-theme { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); } -.sidebar-theme-label { display:block; color: var(--surface-sidebar-text); font-size: 12px; opacity:.8; margin: 0 0 .35rem .1rem; } -.sidebar-theme-row { display:flex; align-items:center; gap:.5rem; flex-wrap: nowrap; } -.sidebar-theme-row select { background: var(--panel); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .4rem; flex: 1 1 auto; min-width: 0; } -.sidebar-theme-row .btn-ghost { background: transparent; color: var(--surface-sidebar-text); border:1px solid var(--border); flex-shrink: 0; white-space: nowrap; } - -/* Simple two-column layout for inspect panel */ -.two-col { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; align-items: start; } -.two-col .grow { min-width: 0; } -.card-preview img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.35); border:1px solid var(--border); background: var(--panel); } -@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } } - -/* Left-rail variant puts the image first */ -.two-col.two-col-left-rail{ grid-template-columns: 320px 1fr; } -/* Ensure left-rail variant also collapses to 1 column on small screens */ -@media (max-width: 900px){ - .two-col.two-col-left-rail{ grid-template-columns: 1fr; } - /* So the commander image doesn't dominate on mobile */ - .two-col .card-preview{ max-width: 360px; margin: 0 auto; } - .two-col .card-preview img{ width: 100%; height: auto; } -} -.card-preview.card-sm{ max-width:200px; } - -/* Buttons, inputs */ -button{ background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; } -button:hover{ filter:brightness(1.05); } -/* Anchor-style buttons */ -.btn{ display:inline-block; background: var(--blue-main); color:#fff; border:none; border-radius:6px; padding:.45rem .7rem; cursor:pointer; text-decoration:none; line-height:1; } -.btn:hover{ filter:brightness(1.05); text-decoration:none; } -.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; } -label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; } -.color-identity{ display:inline-flex; align-items:center; gap:.35rem; } -.color-identity .mana + .mana{ margin-left:4px; } -.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; } -.mana-W{ background:#f9fafb; border-color:#d1d5db; } -.mana-U{ background:#3b82f6; border-color:#1d4ed8; } -.mana-B{ background:#111827; border-color:#1f2937; } -.mana-R{ background:#ef4444; border-color:#b91c1c; } -.mana-G{ background:#10b981; border-color:#047857; } -.mana-C{ background:#d3d3d3; border-color:#9ca3af; } -select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; } -/* Range slider styling */ -input[type="range"]{ - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 8px; - background: var(--bg); - border-radius: 4px; - outline: none; - border: 1px solid var(--border); -} -input[type="range"]::-webkit-slider-thumb{ - -webkit-appearance: none; - appearance: none; - width: 20px; - height: 20px; - background: var(--blue-main); - border-radius: 50%; - cursor: pointer; - border: 2px solid var(--panel); - box-shadow: 0 2px 4px rgba(0,0,0,.2); -} -input[type="range"]::-moz-range-thumb{ - width: 20px; - height: 20px; - background: var(--blue-main); - border-radius: 50%; - cursor: pointer; - border: 2px solid var(--panel); - box-shadow: 0 2px 4px rgba(0,0,0,.2); -} -fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; } -small, .muted{ color: var(--muted); } -.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; } -.partner-preview[hidden]{ display:none !important; } -.partner-preview__header{ font-weight:600; } -.partner-preview__layout{ display:flex; gap:.75rem; align-items:flex-start; flex-wrap:wrap; } -.partner-preview__art{ flex:0 0 auto; } -.partner-preview__art img{ width:140px; max-width:100%; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,.35); } -.partner-preview__details{ flex:1 1 180px; min-width:0; } -.partner-preview__role{ margin-top:.2rem; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; } -.partner-preview__pairing{ margin-top:.35rem; } -.partner-preview__themes{ margin-top:.35rem; font-size:12px; } -.partner-preview--static{ margin-bottom:.5rem; } -.partner-card-preview img{ box-shadow:0 4px 12px rgba(0,0,0,.3); } - -/* Toasts */ -.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; } -.toast{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; } -.toast.hide{ opacity:0; transform: translateY(6px); } -.toast.success{ border-color: rgba(22,163,74,.4); } -.toast.error{ border-color: rgba(239,68,68,.45); } -.toast.warn{ border-color: rgba(245,158,11,.45); } - -/* Skeletons */ -[data-skeleton]{ position: relative; } -[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } -[data-skeleton-placeholder]{ display:none; pointer-events:none; } -[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } -[data-skeleton][data-skeleton-overlay="false"]::after, -[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } -[data-skeleton]::after{ - content: ''; - position: absolute; inset: 0; - border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04)); - background-size: 200% 100%; - animation: shimmer 1.1s linear infinite; - display: none; -} -[data-skeleton].is-loading::after{ display:block; } -[data-skeleton].is-loading::before{ - content: attr(data-skeleton-label); - position:absolute; - top:50%; - left:50%; - transform:translate(-50%, -50%); - color: var(--muted); - font-size:.85rem; - text-align:center; - line-height:1.4; - max-width:min(92%, 360px); - padding:.3rem .5rem; - pointer-events:none; - z-index:1; - filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); -} -[data-skeleton][data-skeleton-label=""]::before{ content:''; } -@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } - -/* Banner */ -.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; } -.banner h1{ font-size: 2rem; margin:0 0 .35rem; } -.banner .subtitle{ color: var(--muted); font-size:.95rem; } - -/* Home actions */ -.actions-grid{ display:grid; grid-template-columns: repeat( auto-fill, minmax(220px, 1fr) ); gap: .75rem; } -.action-button{ display:block; text-decoration:none; color: var(--text); border:1px solid var(--border); background: var(--panel); padding:1.25rem; border-radius:10px; text-align:center; font-weight:600; } -.action-button:hover{ border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); } -.action-button.primary{ background: linear-gradient(180deg, rgba(14,104,171,.25), rgba(14,104,171,.05)); border-color: #274766; } - -/* Home page darker buttons */ -.home-button.btn-secondary { - background: var(--btn-bg, #1a1d24); - color: var(--btn-text, #e8e8e8); - border-color: var(--border); -} -.home-button.btn-secondary:hover { - background: var(--btn-hover-bg, #22252d); - border-color: var(--border); -} -.home-button.btn-primary { - background: var(--blue-main); - color: white; - border-color: var(--blue-main); -} -.home-button.btn-primary:hover { - background: #0c5aa6; - border-color: #0c5aa6; -} - -/* Card grid for added cards (responsive, compact tiles) */ -.card-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(170px, 170px)); /* ~160px image + padding */ - 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; - content-visibility: auto; - contain: layout paint; - contain-intrinsic-size: 640px 420px; -} -@media (max-width: 420px){ - .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .card-tile{ width: 100%; } - .card-tile img{ width: 100%; max-width: 160px; margin: 0 auto; } -} -.card-tile{ - width:170px; - position: relative; - background: var(--panel); - border:1px solid var(--border); - border-radius:6px; - padding:.25rem .25rem .4rem; - text-align:center; -} -.card-tile.game-changer{ border-color: var(--red-main); box-shadow: 0 0 0 1px rgba(211,32,42,.35) inset; } -.card-tile.locked{ - /* Subtle yellow/goldish-white accent for locked cards */ - border-color: #f5e6a8; /* soft parchment gold */ - box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; -} -.card-tile.must-include{ - border-color: rgba(74,222,128,.85); - box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); -} -.card-tile.must-exclude{ - border-color: rgba(239,68,68,.85); - box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; - opacity: .95; -} -.card-tile.must-include.must-exclude{ - border-color: rgba(249,115,22,.85); - box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; -} -.card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } -.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } -.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } - -.must-have-controls{ - display:flex; - justify-content:center; - gap:.35rem; - flex-wrap:wrap; - margin-top:.35rem; -} -.must-have-btn{ - border:1px solid var(--border); - background:rgba(30,41,59,.6); - color:#f8fafc; - font-size:11px; - text-transform:uppercase; - letter-spacing:.06em; - padding:.25rem .6rem; - border-radius:9999px; - cursor:pointer; - transition: all .18s ease; -} -.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ - border-color: rgba(74,222,128,.75); - background: rgba(74,222,128,.18); - color: #bbf7d0; - box-shadow: 0 0 0 1px rgba(16,185,129,.25); -} -.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ - border-color: rgba(239,68,68,.75); - background: rgba(239,68,68,.18); - color: #fecaca; - box-shadow: 0 0 0 1px rgba(239,68,68,.25); -} -.must-have-btn:focus-visible{ - outline:2px solid rgba(59,130,246,.6); - outline-offset:2px; -} -.card-tile.must-exclude .must-have-btn.include[data-active="0"], -.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ - opacity:.65; -} - -.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } -.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } -.alt-option{ display:block !important; width:100%; max-width:100%; text-align:left; white-space:normal !important; word-wrap:break-word !important; overflow-wrap:break-word !important; line-height:1.3 !important; padding:0.5rem 0.7rem !important; } - -/* Shared ownership badge for card tiles and stacked images */ -.owned-badge{ - position:absolute; - top:6px; - left:6px; - background:var(--panel); - color:var(--text); - border:1px solid var(--border); - border-radius:12px; - font-size:12px; - line-height:18px; - height:18px; - min-width:18px; - padding:0 6px; - text-align:center; - pointer-events:none; - z-index:2; -} - -/* Step 1 candidate grid (200px-wide scaled images) */ -.candidate-grid{ - display:grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap:.75rem; -} -.candidate-tile{ - background: var(--panel); - border:1px solid var(--border); - border-radius:8px; - padding:.4rem; -} -.candidate-tile .img-btn{ display:block; width:100%; padding:0; background:transparent; border:none; cursor:pointer; } -.candidate-tile img{ width:100%; max-width:200px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.35); background: var(--panel); display:block; margin:0 auto; } -.candidate-tile .meta{ text-align:center; margin-top:.35rem; } -.candidate-tile .name{ font-weight:600; font-size:.95rem; } -.candidate-tile .score{ color:var(--muted); font-size:.85rem; } - -/* Deck summary: highlight game changers */ -.game-changer { color: var(--green-main); } -.stack-card.game-changer { outline: 2px solid var(--green-main); } - -/* Image button inside card tiles */ -.card-tile .img-btn{ display:block; padding:0; background:transparent; border:none; cursor:pointer; width:100%; } - -/* Stage Navigator */ -.stage-nav { margin:.5rem 0 1rem; } -.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; } -.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; } -.stage-nav .stage-item.done .stage-link { opacity:.75; } -.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; } -.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:var(--bg); font-size:12px; } -.stage-nav .name { font-size:12px; } - -/* Build controls sticky box tweaks */ -.build-controls { - position: sticky; - top: calc(var(--banner-offset, 48px) + 6px); - z-index: 100; - background: var(--panel); - backdrop-filter: blur(8px); - border: 1px solid var(--border); - border-radius: 10px; - margin: 0.5rem 0; - box-shadow: 0 4px 12px rgba(0,0,0,.25); -} - -@media (max-width: 1024px){ - :root { --banner-offset: 56px; } - .build-controls { - position: fixed !important; /* Fixed to viewport instead of sticky */ - bottom: 0 !important; /* Anchor to bottom of screen */ - left: 0 !important; - right: 0 !important; - top: auto !important; /* Override top positioning */ - border-radius: 0 !important; /* Remove border radius for full width */ - margin: 0 !important; /* Remove margins for full edge-to-edge */ - padding: 0.5rem !important; /* Reduced padding */ - box-shadow: 0 -6px 20px rgba(0,0,0,.4) !important; /* Upward shadow */ - border-left: none !important; - border-right: none !important; - border-bottom: none !important; /* Remove bottom border */ - background: linear-gradient(180deg, rgba(15,17,21,.99), rgba(15,17,21,.95)) !important; - z-index: 1000 !important; /* Higher z-index to ensure it's above content */ - } -} -@media (min-width: 721px){ - :root { --banner-offset: 48px; } -} - -/* Progress bar */ -.progress { position: relative; height: 10px; background: var(--panel); border:1px solid var(--border); border-radius: 999px; overflow: hidden; } -.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); } -.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; } - -/* Chips */ -.chip { display:inline-flex; align-items:center; gap:.35rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; } -.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; } -.chip:hover { background: color-mix(in srgb, var(--panel) 85%, var(--text) 15%); border-color: color-mix(in srgb, var(--border) 70%, var(--text) 30%); } -.chip.active { - background: linear-gradient(135deg, rgba(59,130,246,.25), rgba(14,104,171,.15)); - border-color: #3b82f6; - color: #60a5fa; - font-weight: 600; - box-shadow: 0 0 0 1px rgba(59,130,246,.2) inset; -} -.chip.active:hover { - background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(14,104,171,.25)); - border-color: #60a5fa; -} - -/* Cards toolbar */ -.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; } -.cards-toolbar input[type="text"]{ min-width: 220px; } -.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; } -.cards-toolbar .hint{ color: var(--muted); font-size:12px; } - -/* Collapse groups and reason toggle */ -.group{ margin:.5rem 0; } -.group-header{ display:flex; align-items:center; gap:.5rem; } -.group-header h5{ margin:.4rem 0; } -.group-header .count{ color: var(--muted); font-size:12px; } -.group-header .toggle{ margin-left:auto; background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; } -.group-grid[data-collapsed]{ display:none; } -.hide-reasons .card-tile .reason{ display:none; } -.card-tile.force-show .reason{ display:block !important; } -.card-tile.force-hide .reason{ display:none !important; } -.btn-why{ background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); color: var(--text); border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; } -.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; } -.chips-inline .chip{ cursor:pointer; user-select:none; } - -/* Inline error banner */ -.inline-error-banner{ background: color-mix(in srgb, var(--panel) 85%, #b91c1c 15%); border:1px solid #b91c1c; color:#b91c1c; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; } -.inline-error-banner .muted{ color:#fda4af; } - -/* Alternatives panel */ -.alts ul{ list-style:none; padding:0; margin:0; } -.alts li{ display:flex; align-items:center; gap:.4rem; } -/* LQIP blur/fade-in for thumbnails */ -img.lqip { filter: blur(8px); opacity: .6; transition: filter .25s ease-out, opacity .25s ease-out; } -img.lqip.loaded { filter: blur(0); opacity: 1; } - -/* Respect reduced motion: avoid blur/fade transitions for users who prefer less motion */ -@media (prefers-reduced-motion: reduce) { - * { scroll-behavior: auto !important; } - img.lqip { transition: none !important; filter: none !important; opacity: 1 !important; } -} - -/* Virtualization wrapper should mirror grid to keep multi-column flow */ -.virt-wrapper { display: grid; } - -/* Mobile responsive fixes for horizontal scrolling issues */ -@media (max-width: 768px) { - /* Prevent horizontal overflow */ - html, body { - overflow-x: hidden !important; - width: 100% !important; - max-width: 100vw !important; - } - - /* Test hand responsive adjustments */ - #test-hand{ --card-w: 170px !important; --card-h: 238px !important; --overlap: .5 !important; } - - /* Modal & form layout fixes (original block retained inside media query) */ - /* Fix modal layout on mobile */ - .modal { - padding: 10px !important; - box-sizing: border-box; - } - .modal-content { - width: 100% !important; - max-width: calc(100vw - 20px) !important; - box-sizing: border-box !important; - overflow-x: hidden !important; - } - /* Force single column for include/exclude grid */ - .include-exclude-grid { display: flex !important; flex-direction: column !important; gap: 1rem !important; } - /* Fix basics grid */ - .basics-grid { grid-template-columns: 1fr !important; gap: 1rem !important; } - /* Ensure all inputs and textareas fit properly */ - .modal input, - .modal textarea, - .modal select { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; min-width: 0 !important; } - /* Fix chips containers */ - .modal [id$="_chips_container"] { max-width: 100% !important; overflow-x: hidden !important; word-wrap: break-word !important; } - /* Ensure fieldsets don't overflow */ - .modal fieldset { max-width: 100% !important; box-sizing: border-box !important; overflow-x: hidden !important; } - /* Fix any inline styles that might cause overflow */ - .modal fieldset > div, - .modal fieldset > div > div { max-width: 100% !important; overflow-x: hidden !important; } -} - -@media (max-width: 640px){ - #test-hand{ --card-w: 150px !important; --card-h: 210px !important; } - /* Generic stack shrink */ - .stack-wrap:not(#test-hand){ --card-w: 150px; --card-h: 210px; } -} - -@media (max-width: 560px){ - #test-hand{ --card-w: 140px !important; --card-h: 196px !important; padding-bottom:.75rem; } - #test-hand .stack-grid{ display:flex !important; gap:.5rem; grid-template-columns:none !important; overflow-x:auto; padding-bottom:.25rem; } - #test-hand .stack-card{ flex:0 0 auto; } - .stack-wrap:not(#test-hand){ --card-w: 140px; --card-h: 196px; } -} - -@media (max-width: 480px) { - .modal-content { - padding: 12px !important; - margin: 5px !important; - } - - .modal fieldset { - padding: 8px !important; - margin: 6px 0 !important; - } - - /* Enhanced mobile build controls */ - .build-controls { - flex-direction: column !important; - gap: 0.25rem !important; /* Reduced gap */ - align-items: stretch !important; - padding: 0.5rem !important; /* Reduced padding */ - } - - /* Two-column grid layout for mobile build controls */ - .build-controls { - display: grid !important; - grid-template-columns: 1fr 1fr !important; /* Two equal columns */ - grid-gap: 0.25rem !important; - align-items: stretch !important; - } - - .build-controls form { - display: contents !important; /* Allow form contents to participate in grid */ - width: auto !important; - } - - .build-controls button { - flex: none !important; - padding: 0.4rem 0.5rem !important; /* Much smaller padding */ - font-size: 12px !important; /* Smaller font */ - min-height: 36px !important; /* Smaller minimum height */ - line-height: 1.2 !important; - width: 100% !important; /* Full width within grid cell */ - box-sizing: border-box !important; - white-space: nowrap !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - - /* Hide non-essential elements on mobile to keep it clean */ - .build-controls .sep, - .build-controls .replace-toggle, - .build-controls label[style*="margin-left"] { - display: none !important; - } - - .build-controls .sep { - display: none !important; /* Hide separators on mobile */ - } -} - -/* Desktop sizing for Test Hand */ -@media (min-width: 900px) { - #test-hand { --card-w: 280px !important; --card-h: 392px !important; } -} - -/* Analytics accordion styling */ -.analytics-accordion { - transition: all 0.2s ease; -} - -.analytics-accordion summary { - display: flex; - align-items: center; - justify-content: space-between; - transition: background-color 0.15s ease, border-color 0.15s ease; -} - -.analytics-accordion summary:hover { - background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%); - border-color: var(--text); -} - -.analytics-accordion summary:active { - transform: scale(0.99); -} - -.analytics-accordion[open] summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-bottom: 0; -} - -.analytics-accordion .analytics-content { - animation: accordion-slide-down 0.3s ease-out; -} - -@keyframes accordion-slide-down { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.analytics-placeholder .skeleton-pulse { - animation: shimmer 1.5s infinite; -} - -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -/* Ideals Slider Styling */ -.ideals-slider { - -webkit-appearance: none; - appearance: none; - height: 6px; - background: var(--border); - border-radius: 3px; - outline: none; -} - -.ideals-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - background: var(--ring); - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; -} - -.ideals-slider::-webkit-slider-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} - -.ideals-slider::-moz-range-thumb { - width: 18px; - height: 18px; - background: var(--ring); - border: none; - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; -} - -.ideals-slider::-moz-range-thumb:hover { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); -} - -.slider-value { - display: inline-block; - padding: 0.25rem 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 4px; -} - -/* ======================================== - Card Browser Styles - ======================================== */ - -/* Card browser container */ -.card-browser-container { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Filter panel */ -.card-browser-filters { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; -} - -.filter-section { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.filter-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; -} - -.filter-row label { - font-weight: 600; - min-width: 80px; - color: var(--text); - font-size: 0.95rem; -} - -.filter-row select, -.filter-row input[type="text"], -.filter-row input[type="search"] { - flex: 1; - min-width: 150px; - max-width: 300px; -} - -/* Search bar styling */ -.card-search-wrapper { - position: relative; - flex: 1; - max-width: 100%; -} - -.card-search-wrapper input[type="search"] { - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 1rem; -} - -/* Results count and info bar */ -.card-browser-info { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0.5rem; - padding: 0.5rem 0; -} - -.results-count { - font-size: 0.95rem; - color: var(--muted); -} - -.page-indicator { - font-size: 0.95rem; - color: var(--text); - font-weight: 600; -} - -/* Card browser grid */ -.card-browser-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 240px)); - gap: 0.5rem; - padding: 0.5rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - min-height: 480px; - justify-content: start; -} - -/* Individual card tile in browser */ -.card-browser-tile { - break-inside: avoid; - display: flex; - flex-direction: column; - background: var(--card-bg, #1a1d24); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - transition: transform 0.2s ease, box-shadow 0.2s ease; - cursor: pointer; -} - -.card-browser-tile:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - border-color: color-mix(in srgb, var(--border) 50%, var(--ring) 50%); -} - -.card-browser-tile-image { - position: relative; - width: 100%; - aspect-ratio: 488/680; - overflow: hidden; - background: #0a0b0e; -} - -.card-browser-tile-image img { - width: 100%; - height: 100%; - object-fit: contain; - transition: transform 0.3s ease; -} - -.card-browser-tile:hover .card-browser-tile-image img { - transform: scale(1.05); -} - -.card-browser-tile-info { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.card-browser-tile-name { - font-weight: 600; - font-size: 0.95rem; - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; -} - -.card-browser-tile-type { - font-size: 0.85rem; - color: var(--muted); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.3; -} - -.card-browser-tile-stats { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.85rem; -} - -.card-browser-tile-tags { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.25rem; -} - -.card-browser-tile-tags .tag { - font-size: 0.7rem; - padding: 0.15rem 0.4rem; - background: rgba(148, 163, 184, 0.15); - color: var(--muted); - border-radius: 3px; - white-space: nowrap; -} - -/* Card Details button on tiles */ -.card-details-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - padding: 0.5rem 0.75rem; - background: var(--primary); - color: white; - text-decoration: none; - border-radius: 6px; - font-weight: 500; - font-size: 0.85rem; - transition: all 0.2s; - margin-top: 0.5rem; - border: none; - cursor: pointer; -} - -.card-details-btn:hover { - background: var(--primary-hover); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4); -} - -.card-details-btn svg { - flex-shrink: 0; -} - -/* Card Preview Modal */ -.preview-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - z-index: 9999; - align-items: center; - justify-content: center; -} - -.preview-modal.active { - display: flex; -} - -.preview-content { - position: relative; - max-width: 90%; - max-height: 90%; -} - -.preview-content img { - max-width: 100%; - max-height: 90vh; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); -} - -.preview-close { - position: absolute; - top: -40px; - right: 0; - background: rgba(255, 255, 255, 0.9); - color: #000; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - font-size: 24px; - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; -} - -.preview-close:hover { - background: #fff; - transform: scale(1.1); -} - -/* Pagination controls */ -.card-browser-pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 1rem; - padding: 1rem 0; - flex-wrap: wrap; -} - -.card-browser-pagination .btn { - min-width: 120px; -} - -.card-browser-pagination .page-info { - font-size: 0.95rem; - color: var(--text); - padding: 0 1rem; -} - -/* No results message */ -.no-results { - text-align: center; - padding: 3rem 1rem; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; -} - -.no-results-title { - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin-bottom: 0.5rem; -} - -.no-results-message { - color: var(--muted); - margin-bottom: 1rem; - line-height: 1.5; -} - -.no-results-filters { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: center; - margin-bottom: 1rem; -} - -.no-results-filter-tag { - padding: 0.25rem 0.75rem; - background: rgba(148, 163, 184, 0.15); - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - color: var(--text); -} - -/* Loading indicator */ -.card-browser-loading { - text-align: center; - padding: 2rem; - color: var(--muted); -} - -/* Responsive adjustments */ -/* Large tablets and below - reduce to ~180px cards */ -@media (max-width: 1024px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(200px, 200px)); - } -} - -/* Tablets - reduce to ~160px cards */ -@media (max-width: 768px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 180px)); - gap: 0.5rem; - padding: 0.5rem; - } - - .filter-row { - flex-direction: column; - align-items: stretch; - } - - .filter-row label { - min-width: auto; - } - - .filter-row select, - .filter-row input { - max-width: 100%; - } - - .card-browser-info { - flex-direction: column; - align-items: flex-start; - } -} - -/* Small tablets/large phones - reduce to ~140px cards */ -@media (max-width: 600px) { - .card-browser-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 160px)); - gap: 0.5rem; - } -} - -/* Phones - 2 column layout with flexible width */ -@media (max-width: 480px) { - .card-browser-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.375rem; - } - - .card-browser-tile-name { - font-size: 0.85rem; - } - - .card-browser-tile-type { - font-size: 0.75rem; - } - - .card-browser-tile-info { - padding: 0.5rem; - } -} - -/* Theme chips for multi-select */ -.theme-chip { - display: inline-flex; - align-items: center; - background: var(--primary-bg); - color: var(--primary-fg); - padding: 0.25rem 0.75rem; - border-radius: 1rem; - font-size: 0.9rem; - border: 1px solid var(--border-color); -} - -.theme-chip button { - margin-left: 0.5rem; - background: none; - border: none; - color: inherit; - cursor: pointer; - padding: 0; - font-weight: bold; - font-size: 1.2rem; - line-height: 1; -} - -.theme-chip button:hover { - color: var(--error-color); -} - -/* Card Detail Page Styles */ -.card-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; - margin-bottom: 1rem; -} - -.card-tag { - background: var(--ring); - color: white; - padding: 0.35rem 0.75rem; - border-radius: 16px; - font-size: 0.85rem; - font-weight: 500; -} - -.back-button { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - background: var(--panel); - color: var(--text); - text-decoration: none; - border-radius: 8px; - border: 1px solid var(--border); - font-weight: 500; - transition: all 0.2s; - margin-bottom: 2rem; -} - -.back-button:hover { - background: var(--ring); - color: white; - border-color: var(--ring); -} - -/* Card Detail Page - Main Card Image */ -.card-image-large { - flex: 0 0 auto; - max-width: 360px !important; - width: 100%; -} - -.card-image-large img { - width: 100%; - height: auto; - border-radius: 12px; -} - -/* ============================================ - M2 Component Library Styles - ============================================ */ - -/* === BUTTONS === */ -/* Button Base - enhanced from existing .btn */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - background: var(--blue-main); - color: #fff; - border: none; - border-radius: 6px; - padding: 0.5rem 1rem; - cursor: pointer; - text-decoration: none; - line-height: 1.5; - font-weight: 500; - transition: filter 0.15s ease, transform 0.05s ease; - white-space: nowrap; -} - -.btn:hover { - filter: brightness(1.1); - text-decoration: none; -} - -.btn:active { - transform: scale(0.98); -} - -.btn:disabled, -.btn.disabled, -.btn[aria-disabled="true"] { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -/* Button Variants */ -.btn-primary { - background: var(--blue-main); - color: #fff; -} - -.btn-secondary { - background: var(--muted); - color: var(--text); -} - -.btn-ghost { - background: transparent; - color: var(--text); - border: 1px solid var(--border); -} - -.btn-ghost:hover { - background: var(--panel); - border-color: var(--text); -} - -.btn-danger { - background: var(--err); - color: #fff; -} - -/* Button Sizes */ -.btn-sm { - padding: 0.25rem 0.75rem; - font-size: 0.875rem; -} - -.btn-md { - padding: 0.5rem 1rem; - font-size: 0.875rem; -} - -.btn-lg { - padding: 0.75rem 1.5rem; - font-size: 1rem; -} - -/* Icon Button */ -.btn-icon { - padding: 0.5rem; - aspect-ratio: 1; - justify-content: center; -} - -.btn-icon.btn-sm { - padding: 0.25rem; - font-size: 1rem; -} - -/* Close Button */ -.btn-close { - position: absolute; - top: 0.75rem; - right: 0.75rem; - font-size: 1.5rem; - line-height: 1; - z-index: 10; -} - -/* Tag/Chip Button */ -.btn-tag { - display: inline-flex; - align-items: center; - gap: 0.375rem; - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 16px; - padding: 0.25rem 0.75rem; - font-size: 0.875rem; - transition: all 0.15s ease; -} - -.btn-tag:hover { - background: var(--border); - border-color: var(--text); -} - -.btn-tag-selected { - background: var(--blue-main); - color: #fff; - border-color: var(--blue-main); -} - -.btn-tag-remove { - background: transparent; - border: none; - color: inherit; - padding: 0; - margin: 0; - font-size: 1rem; - line-height: 1; - cursor: pointer; - opacity: 0.7; -} - -.btn-tag-remove:hover { - opacity: 1; -} - -/* Button Group */ -.btn-group { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.btn-group-left { - justify-content: flex-start; -} - -.btn-group-center { - justify-content: center; -} - -.btn-group-right { - justify-content: flex-end; -} - -.btn-group-between { - justify-content: space-between; -} - -/* Legacy action-btn compatibility */ -.action-btn { - padding: 0.75rem 1.5rem; - font-size: 1rem; -} - -/* === MODALS === */ -.modal { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; -} - -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(2px); - z-index: -1; -} - -.modal-content { - position: relative; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - padding: 1rem; - width: 100%; - max-height: min(92vh, 100%); - display: flex; - flex-direction: column; -} - -/* Modal Sizes */ -.modal-sm .modal-content { - max-width: 480px; -} - -.modal-md .modal-content { - max-width: 620px; -} - -.modal-lg .modal-content { - max-width: 720px; -} - -.modal-xl .modal-content { - max-width: 960px; -} - -/* Modal Position */ -.modal-center { - align-items: center; -} - -.modal-top { - align-items: flex-start; - padding-top: 2rem; -} - -/* Modal Scrollable */ -.modal-scrollable .modal-content { - overflow: auto; - -webkit-overflow-scrolling: touch; -} - -/* Modal Structure */ -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; - padding-right: 2rem; -} - -.modal-title { - font-size: 1.25rem; - font-weight: 600; - margin: 0; - color: var(--text); -} - -.modal-body { - flex: 1; - overflow-y: auto; - -webkit-overflow-scrolling: touch; -} - -.modal-footer { - display: flex; - gap: 0.5rem; - justify-content: flex-end; - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border); -} - -/* Modal Variants */ -.modal-confirm .modal-body { - padding: 1rem 0; - font-size: 0.95rem; -} - -.modal-alert { - text-align: center; -} - -.modal-alert .modal-body { - padding: 1.5rem 0; -} - -.modal-alert .alert-icon { - font-size: 3rem; - margin-bottom: 1rem; -} - -.modal-alert-info .alert-icon::before { - content: 'ℹ️'; -} - -.modal-alert-success .alert-icon::before { - content: '✅'; -} - -.modal-alert-warning .alert-icon::before { - content: '⚠️'; -} - -.modal-alert-error .alert-icon::before { - content: '❌'; -} - -/* === FORMS === */ -.form-field { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.form-label { - font-weight: 500; - font-size: 0.875rem; - color: var(--text); - display: flex; - align-items: center; - gap: 0.25rem; -} - -.form-required { - color: var(--err); - font-weight: bold; -} - -.form-input-wrapper { - display: flex; - flex-direction: column; -} - -.form-input, -.form-textarea, -.form-select { - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - width: 100%; -} - -.form-input:focus, -.form-textarea:focus, -.form-select:focus { - outline: none; - border-color: var(--ring); - box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); -} - -.form-input:disabled, -.form-textarea:disabled, -.form-select:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.form-textarea { - resize: vertical; - min-height: 80px; -} - -.form-input-number { - max-width: 150px; -} - -.form-input-file { - padding: 0.375rem 0.5rem; -} - -/* Checkbox and Radio */ -.form-field-checkbox, -.form-field-radio { - flex-direction: row; - align-items: flex-start; -} - -.form-checkbox-label, -.form-radio-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: normal; -} - -.form-checkbox, -.form-radio { - width: 1.125rem; - height: 1.125rem; - border: 1px solid var(--border); - cursor: pointer; - flex-shrink: 0; -} - -.form-checkbox { - border-radius: 4px; -} - -.form-radio { - border-radius: 50%; -} - -.form-checkbox:checked, -.form-radio:checked { - background: var(--blue-main); - border-color: var(--blue-main); -} - -.form-checkbox:focus, -.form-radio:focus { - outline: none; - box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1); -} - -.form-radio-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* Form Help and Error Text */ -.form-help-text { - font-size: 0.8rem; - color: var(--muted); - margin-top: -0.25rem; -} - -.form-error-text { - font-size: 0.8rem; - color: var(--err); - margin-top: -0.25rem; -} - -.form-field-error .form-input, -.form-field-error .form-textarea, -.form-field-error .form-select { - border-color: var(--err); -} - -/* === CARD DISPLAY COMPONENTS === */ -/* Card Thumbnail Container */ -.card-thumb-container { - position: relative; - display: inline-block; -} - -.card-thumb { - display: block; - border-radius: 10px; - border: 1px solid var(--border); - background: #0b0d12; - object-fit: cover; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.card-thumb:hover { - transform: translateY(-2px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); -} - -/* Card Thumbnail Sizes */ -.card-thumb-small .card-thumb { - width: 160px; - height: auto; -} - -.card-thumb-medium .card-thumb { - width: 230px; - height: auto; -} - -.card-thumb-large .card-thumb { - width: 360px; - height: auto; -} - -/* Card Flip Button */ -.card-flip-btn { - position: absolute; - bottom: 8px; - right: 8px; - background: rgba(0, 0, 0, 0.75); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 6px; - padding: 0.375rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(4px); - transition: background 0.15s ease; - z-index: 5; -} - -.card-flip-btn:hover { - background: rgba(0, 0, 0, 0.9); - border-color: rgba(255, 255, 255, 0.4); -} - -.card-flip-btn svg { - width: 16px; - height: 16px; -} - -/* Card Name Label */ -.card-name-label { - font-size: 0.75rem; - margin-top: 0.375rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-weight: 600; - text-align: center; -} - -/* Card Hover Popup */ -.card-popup { - position: fixed; - inset: 0; - z-index: 2000; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; -} - -.card-popup-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(2px); - z-index: -1; -} - -.card-popup-content { - position: relative; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); - padding: 1rem; - max-width: 400px; - width: 100%; -} - -.card-popup-image { - position: relative; - margin-bottom: 1rem; -} - -.card-popup-image img { - width: 100%; - height: auto; - border-radius: 10px; - border: 1px solid var(--border); -} - -.card-popup-info { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.card-popup-name { - font-size: 1.125rem; - font-weight: 600; - margin: 0; - color: var(--text); -} - -.card-popup-role { - font-size: 0.875rem; - color: var(--muted); -} - -.card-popup-role span { - color: var(--text); - font-weight: 500; -} - -.card-popup-tags { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; -} - -.card-popup-tag { - background: var(--panel); - border: 1px solid var(--border); - color: var(--text); - padding: 0.25rem 0.5rem; - border-radius: 12px; - font-size: 0.75rem; -} - -.card-popup-tag-highlight { - background: var(--blue-main); - color: #fff; - border-color: var(--blue-main); -} - -.card-popup-close { - position: absolute; - top: 0.5rem; - right: 0.5rem; - background: rgba(0, 0, 0, 0.75); - color: #fff; - border: none; - border-radius: 6px; - width: 2rem; - height: 2rem; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - line-height: 1; - cursor: pointer; - backdrop-filter: blur(4px); -} - -.card-popup-close:hover { - background: rgba(0, 0, 0, 0.9); -} - -/* Card Grid */ -.card-grid { - display: grid; - gap: 0.75rem; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); -} - -.card-grid-cols-auto { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); -} - -.card-grid-cols-2 { - grid-template-columns: repeat(2, 1fr); -} - -.card-grid-cols-3 { - grid-template-columns: repeat(3, 1fr); -} - -.card-grid-cols-4 { - grid-template-columns: repeat(4, 1fr); -} - -.card-grid-cols-5 { - grid-template-columns: repeat(5, 1fr); -} - -.card-grid-cols-6 { - grid-template-columns: repeat(6, 1fr); -} - -@media (max-width: 768px) { - .card-grid { - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - } -} - -/* Card List */ -.card-list-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--panel); - transition: background 0.15s ease; -} - -.card-list-item:hover { - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); -} - -.card-list-item-info { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; -} - -.card-list-item-name { - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-list-item-count { - color: var(--muted); - font-size: 0.875rem; -} - -.card-list-item-role { - color: var(--muted); - font-size: 0.75rem; - padding: 0.125rem 0.5rem; - background: rgba(255, 255, 255, 0.05); - border-radius: 12px; -} - -/* Synthetic Card Placeholder */ -.card-sample.synthetic { - border: 1px dashed var(--border); - border-radius: 10px; - background: var(--panel); - padding: 1rem; - display: flex; - align-items: center; - justify-content: center; -} - -.synthetic-card-placeholder { - text-align: center; -} - -.synthetic-card-icon { - font-size: 2rem; - opacity: 0.5; - margin-bottom: 0.5rem; -} - -.synthetic-card-name { - font-weight: 600; - font-size: 0.875rem; - margin-bottom: 0.25rem; -} - -.synthetic-card-reason { - font-size: 0.75rem; - color: var(--muted); -} - -/* === PANELS === */ -.panel { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - margin-bottom: 0.75rem; -} - -/* Panel Variants */ -.panel-default { - background: var(--panel); -} - -.panel-alt { - background: color-mix(in srgb, var(--panel) 50%, var(--bg) 50%); -} - -.panel-dark { - background: #0f1115; -} - -.panel-bordered { - background: transparent; -} - -/* Panel Padding */ -.panel-padding-none { - padding: 0; -} - -.panel-padding-sm { - padding: 0.5rem; -} - -.panel-padding-md { - padding: 0.75rem; -} - -.panel-padding-lg { - padding: 1.5rem; -} - -/* Panel Structure */ -.panel-header { - padding: 0.75rem; - border-bottom: 1px solid var(--border); -} - -.panel-title { - font-size: 1.125rem; - font-weight: 600; - margin: 0; - color: var(--text); -} - -.panel-body { - padding: 0.75rem; -} - -.panel-footer { - padding: 0.75rem; - border-top: 1px solid var(--border); -} - -/* Info Panel */ -.panel-info { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1rem; -} - -.panel-info-content { - display: flex; - align-items: flex-start; - gap: 0.75rem; - flex: 1; -} - -.panel-info-icon { - font-size: 1.5rem; - flex-shrink: 0; -} - -.panel-info-text { - flex: 1; -} - -.panel-info-title { - font-size: 1rem; - font-weight: 600; - margin: 0 0 0.25rem; - color: var(--text); -} - -.panel-info-message { - font-size: 0.875rem; - color: var(--muted); -} - -.panel-info-action { - flex-shrink: 0; -} - -/* Info Panel Variants */ -.panel-info-info { - border-color: var(--ring); - background: color-mix(in srgb, var(--ring) 10%, var(--panel) 90%); -} - -.panel-info-success { - border-color: var(--ok); - background: color-mix(in srgb, var(--ok) 10%, var(--panel) 90%); -} - -.panel-info-warning { - border-color: var(--warn); - background: color-mix(in srgb, var(--warn) 10%, var(--panel) 90%); -} - -.panel-info-error { - border-color: var(--err); - background: color-mix(in srgb, var(--err) 10%, var(--panel) 90%); -} - -/* Stat Panel */ -.panel-stat { - display: flex; - align-items: center; - gap: 1rem; - padding: 1rem; - text-align: center; - flex-direction: column; -} - -.panel-stat-icon { - font-size: 2rem; -} - -.panel-stat-content { - display: flex; - flex-direction: column; - align-items: center; -} - -.panel-stat-value { - font-size: 2rem; - font-weight: 700; - line-height: 1; - color: var(--text); -} - -.panel-stat-label { - font-size: 0.875rem; - color: var(--muted); - margin-top: 0.25rem; -} - -.panel-stat-sublabel { - font-size: 0.75rem; - color: var(--muted); - margin-top: 0.125rem; -} - -/* Stat Panel Variants */ -.panel-stat-primary { - border-color: var(--ring); -} - -.panel-stat-primary .panel-stat-value { - color: var(--ring); -} - -.panel-stat-success { - border-color: var(--ok); -} - -.panel-stat-success .panel-stat-value { - color: var(--ok); -} - -.panel-stat-warning { - border-color: var(--warn); -} - -.panel-stat-warning .panel-stat-value { - color: var(--warn); -} - -.panel-stat-error { - border-color: var(--err); -} - -.panel-stat-error .panel-stat-value { - color: var(--err); -} - -/* Collapsible Panel */ -.panel-collapsible .panel-header { - padding: 0; - border: none; -} - -.panel-toggle { - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem; - background: transparent; - border: none; - color: var(--text); - cursor: pointer; - text-align: left; - border-radius: 10px 10px 0 0; - transition: background 0.15s ease; -} - -.panel-toggle:hover { - background: color-mix(in srgb, var(--panel) 80%, var(--text) 20%); -} - -.panel-toggle-icon { - width: 0; - height: 0; - border-left: 6px solid transparent; - border-right: 6px solid transparent; - border-top: 8px solid var(--text); - transition: transform 0.2s ease; -} - -.panel-collapsed .panel-toggle-icon { - transform: rotate(-90deg); -} - -.panel-expanded .panel-toggle-icon { - transform: rotate(0deg); -} - -.panel-collapse-content { - overflow: hidden; - transition: max-height 0.3s ease; -} - -/* Panel Grid */ -.panel-grid { - display: grid; - gap: 1rem; -} - -.panel-grid-cols-auto { - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); -} - -.panel-grid-cols-1 { - grid-template-columns: 1fr; -} - -.panel-grid-cols-2 { - grid-template-columns: repeat(2, 1fr); -} - -.panel-grid-cols-3 { - grid-template-columns: repeat(3, 1fr); -} - -.panel-grid-cols-4 { - grid-template-columns: repeat(4, 1fr); -} - -@media (max-width: 768px) { - .panel-grid { - grid-template-columns: 1fr; - } -} - -/* Empty State Panel */ -.panel-empty-state { - text-align: center; - padding: 3rem 1.5rem; -} - -.panel-empty-icon { - font-size: 4rem; - opacity: 0.5; - margin-bottom: 1rem; -} - -.panel-empty-title { - font-size: 1.25rem; - font-weight: 600; - margin: 0 0 0.5rem; - color: var(--text); -} - -.panel-empty-message { - font-size: 0.95rem; - color: var(--muted); - margin: 0 0 1.5rem; -} - -.panel-empty-action { - display: flex; - justify-content: center; -} - -/* Loading Panel */ -.panel-loading { - text-align: center; - padding: 2rem 1rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -.panel-loading-spinner { - width: 3rem; - height: 3rem; - border: 4px solid var(--border); - border-top-color: var(--ring); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.panel-loading-message { - font-size: 0.95rem; - color: var(--muted); -} - -/* ============================================================================= - UTILITY CLASSES - Common Layout Patterns (Added 2025-10-21) - ============================================================================= */ - -/* Flex Row Layouts */ -.flex-row { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.flex-row-sm { - display: flex; - align-items: center; - gap: 0.25rem; -} - -.flex-row-md { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.flex-row-lg { - display: flex; - align-items: center; - gap: 1rem; -} - -.flex-row-between { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} - -.flex-row-wrap { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.flex-row-start { - display: flex; - align-items: flex-start; - gap: 0.5rem; -} - -/* Flex Column Layouts */ -.flex-col { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.flex-col-sm { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.flex-col-md { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.flex-col-lg { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.flex-col-center { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; -} - -/* Flex Grid/Wrap Patterns */ -.flex-grid { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.flex-grid-sm { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; -} - -.flex-grid-md { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - -.flex-grid-lg { - display: flex; - flex-wrap: wrap; - gap: 1rem; -} - -/* Spacing Utilities */ -.section-spacing { - margin-top: 2rem; -} - -.section-spacing-sm { - margin-top: 1rem; -} - -.section-spacing-lg { - margin-top: 3rem; -} - -.content-spacing { - margin-bottom: 1rem; -} - -.content-spacing-sm { - margin-bottom: 0.5rem; -} - -.content-spacing-lg { - margin-bottom: 2rem; -} - -/* Common Size Constraints */ -.max-w-content { - max-width: 1200px; - margin-left: auto; - margin-right: auto; -} - -.max-w-prose { - max-width: 65ch; - margin-left: auto; - margin-right: auto; -} - -.max-w-form { - max-width: 600px; -} - -/* Common Text Patterns */ -.text-muted { - color: var(--muted); - opacity: 0.85; -} - -.text-xs { - font-size: 0.75rem; - line-height: 1.25; -} - -.text-sm { - font-size: 0.875rem; - line-height: 1.35; -} - -.text-base { - font-size: 1rem; - line-height: 1.5; -} - -/* Screen Reader Only */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* ============================================================================= - CARD HOVER SYSTEM (Moved from base.html 2025-10-21) - ============================================================================= */ - -.card-hover { - position: fixed; - pointer-events: none; - z-index: 9999; - display: none; -} - -.card-hover-inner { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.card-hover img { - width: 320px; - height: auto; - display: block; - border-radius: 8px; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55); - border: 1px solid var(--border); - background: var(--panel); -} - -.card-hover .dual { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.card-meta { - background: var(--panel); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.5rem 0.6rem; - max-width: 320px; - font-size: 13px; - line-height: 1.4; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); -} - -.card-meta ul { - margin: 0.25rem 0; - padding-left: 1.1rem; - list-style: disc; -} - -.card-meta li { - margin: 0.1rem 0; -} - -.card-meta .themes-list { - font-size: 18px; - line-height: 1.35; -} - -.card-meta .label { - color: #94a3b8; - text-transform: uppercase; - font-size: 10px; - letter-spacing: 0.04em; - display: block; - margin-bottom: 0.15rem; -} - -.card-meta .themes-label { - color: var(--text); - font-size: 20px; - letter-spacing: 0.05em; -} - -.card-meta .line + .line { - margin-top: 0.35rem; -} - -.card-hover .themes-list li.overlap { - color: #0ea5e9; - font-weight: 600; -} - -.card-hover .ov-chip { - display: inline-block; - background: #38bdf8; - color: #102746; - border: 1px solid #0f3a57; - border-radius: 12px; - padding: 2px 6px; - font-size: 11px; - margin-right: 4px; - font-weight: 600; -} - -/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */ -.card-hover .dual.two-faced img { - width: 320px; -} - -.card-hover .dual.two-faced { - gap: 8px; -} - -/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */ -.card-hover .dual.combo img { - width: 300px; -} - -@media (max-width: 1100px) { - .card-hover .dual.two-faced img { - width: 280px; - } - .card-hover .dual.combo img { - width: 260px; - } -} - -/* Hide hover preview on narrow screens to avoid covering content */ -@media (max-width: 900px) { - .card-hover { - display: none !important; - } -} - -/* ============================================================================= - THEME BADGES (Moved from base.html 2025-10-21) - ============================================================================= */ - -.theme-badge { - display: inline-block; - padding: 2px 6px; - border-radius: 12px; - font-size: 10px; - background: var(--panel-alt); - border: 1px solid var(--border); - letter-spacing: 0.5px; -} - -.theme-synergies { - font-size: 11px; - opacity: 0.85; - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.badge-fallback { - background: #7f1d1d; - color: #fff; -} - -.badge-quality-draft { - background: #4338ca; - color: #fff; -} - -.badge-quality-reviewed { - background: #065f46; - color: #fff; -} - -.badge-quality-final { - background: #065f46; - color: #fff; - font-weight: 600; -} - -.badge-pop-vc { - background: #065f46; - color: #fff; -} - -.badge-pop-c { - background: #047857; - color: #fff; -} - -.badge-pop-u { - background: #0369a1; - color: #fff; -} - -.badge-pop-n { - background: #92400e; - color: #fff; -} - -.badge-pop-r { - background: #7f1d1d; - color: #fff; -} - -.badge-curated { - background: #4f46e5; - color: #fff; -} - -.badge-enforced { - background: #334155; - color: #fff; -} - -.badge-inferred { - background: #57534e; - color: #fff; -} - -.theme-detail-card { - background: var(--panel); - padding: 1rem 1.1rem; - border: 1px solid var(--border); - border-radius: 10px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); -} - -.theme-list-card { - background: var(--panel); - padding: 0.6rem 0.75rem; - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: background-color 0.15s ease; -} - -.theme-list-card:hover { - background: var(--hover); -} - -.theme-detail-card h3 { - margin-top: 0; - margin-bottom: 0.4rem; -} - -.theme-detail-card .desc { - margin-top: 0; - font-size: 13px; - line-height: 1.45; -} - -.theme-detail-card h4 { - margin-bottom: 0.35rem; - margin-top: 0.85rem; - font-size: 13px; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0.85; -} - -.breadcrumb { - font-size: 12px; - margin-bottom: 0.4rem; -} - -/* ============================================================================= - HOVER CARD PANEL (Moved from base.html 2025-10-21) - ============================================================================= */ - -/* Unified hover-card-panel styling parity */ -#hover-card-panel.is-payoff { - border-color: var(--accent, #38bdf8); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.65), 0 0 0 1px var(--accent, #38bdf8) inset; -} - -#hover-card-panel.is-payoff .hcp-img { - border-color: var(--accent, #38bdf8); -} - -/* Two-column hover layout */ -#hover-card-panel .hcp-body { - display: grid; - grid-template-columns: 320px 1fr; - gap: 18px; - align-items: start; -} - -#hover-card-panel .hcp-img-wrap { - grid-column: 1 / 2; -} - -#hover-card-panel.compact-img .hcp-body { - grid-template-columns: 120px 1fr; -} - -#hover-card-panel.hcp-simple { - width: auto !important; - max-width: min(360px, 90vw) !important; - padding: 12px !important; - height: auto !important; - max-height: none !important; - overflow: hidden !important; -} - -#hover-card-panel.hcp-simple .hcp-body { - display: flex; - flex-direction: column; - gap: 12px; - align-items: center; -} - -#hover-card-panel.hcp-simple .hcp-right { - display: none !important; -} - -#hover-card-panel.hcp-simple .hcp-img { - max-width: 100%; -} - -/* Tag list as multi-column list instead of pill chips for readability */ -#hover-card-panel .hcp-taglist { - columns: 2; - column-gap: 18px; - font-size: 13px; - line-height: 1.3; - margin: 6px 0 6px; - padding: 0; - list-style: none; - max-height: 180px; - overflow: auto; -} - -#hover-card-panel .hcp-taglist li { - break-inside: avoid; - padding: 2px 0 2px 0; - position: relative; -} - -#hover-card-panel .hcp-taglist li.overlap { - font-weight: 600; - color: var(--accent, #38bdf8); -} - -#hover-card-panel .hcp-taglist li.overlap::before { - content: '•'; - color: var(--accent, #38bdf8); - position: absolute; - left: -10px; -} - -#hover-card-panel .hcp-overlaps { - font-size: 10px; - line-height: 1.25; - margin-top: 2px; -} - -#hover-card-panel .hcp-ov-chip { - display: inline-flex; - align-items: center; - background: var(--accent, #38bdf8); - color: #102746; - border: 1px solid rgba(10, 54, 82, 0.6); - border-radius: 9999px; - padding: 3px 10px; - font-size: 13px; - margin-right: 6px; - margin-top: 4px; - font-weight: 500; - letter-spacing: 0.02em; -} - -/* Mobile hover panel */ -#hover-card-panel.mobile { - left: 50% !important; - top: 50% !important; - bottom: auto !important; - transform: translate(-50%, -50%); - width: min(94vw, 460px) !important; - max-height: 88vh; - overflow-y: auto; - padding: 20px 22px; - pointer-events: auto !important; -} - -#hover-card-panel.mobile .hcp-body { - display: flex; - flex-direction: column; - gap: 20px; -} - -#hover-card-panel.mobile .hcp-img { - width: 100%; - max-width: min(90vw, 420px) !important; - margin: 0 auto; -} - -#hover-card-panel.mobile .hcp-right { - width: 100%; - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-start; -} - -#hover-card-panel.mobile .hcp-header { - flex-wrap: wrap; - gap: 8px; - align-items: flex-start; -} - -#hover-card-panel.mobile .hcp-role { - font-size: 12px; - letter-spacing: 0.55px; -} - -#hover-card-panel.mobile .hcp-meta { - font-size: 13px; - text-align: left; -} - -#hover-card-panel.mobile .hcp-overlaps { - display: flex; - flex-wrap: wrap; - gap: 6px; - width: 100%; -} - -#hover-card-panel.mobile .hcp-overlaps .hcp-ov-chip { - margin: 0; -} - -#hover-card-panel.mobile .hcp-taglist { - columns: 1; - display: flex; - flex-wrap: wrap; - gap: 6px; - margin: 4px 0 2px; - max-height: none; - overflow: visible; - padding: 0; -} - -#hover-card-panel.mobile .hcp-taglist li { - background: rgba(37, 99, 235, 0.18); - border-radius: 9999px; - padding: 4px 10px; - display: inline-flex; - align-items: center; -} - -#hover-card-panel.mobile .hcp-taglist li.overlap { - background: rgba(37, 99, 235, 0.28); - color: #dbeafe; -} - -#hover-card-panel.mobile .hcp-taglist li.overlap::before { - display: none; -} - -#hover-card-panel.mobile .hcp-reasons { - max-height: 220px; - width: 100%; -} - -#hover-card-panel.mobile .hcp-tags { - word-break: normal; - white-space: normal; - text-align: left; - width: 100%; - font-size: 12px; - opacity: 0.7; -} - -#hover-card-panel .hcp-close { - appearance: none; - border: none; - background: transparent; - color: #9ca3af; - font-size: 18px; - line-height: 1; - padding: 2px 4px; - cursor: pointer; - border-radius: 6px; - display: none; -} - -#hover-card-panel .hcp-close:focus { - outline: 2px solid rgba(59, 130, 246, 0.6); - outline-offset: 2px; -} - -#hover-card-panel.mobile .hcp-close { - display: inline-flex; -} - -/* Fade transition for hover panel image */ -#hover-card-panel .hcp-img { - transition: opacity 0.22s ease; -} - -/* ============================================================================= - DOUBLE-FACED CARD TOGGLE (Moved from base.html 2025-10-21) - ============================================================================= */ - -/* Hide modal-specific close button outside modal host */ -#preview-close-btn { - display: none; -} - -#theme-preview-modal #preview-close-btn { - display: inline-flex; -} - -/* Overlay flip toggle for double-faced cards */ -.dfc-host { - position: relative; -} - -.dfc-toggle { - position: absolute; - top: 6px; - left: 6px; - z-index: 5; - background: rgba(15, 23, 42, 0.82); - color: #fff; - border: 1px solid #475569; - border-radius: 50%; - width: 36px; - height: 36px; - padding: 0; - font-size: 16px; - cursor: pointer; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.92; - backdrop-filter: blur(3px); -} - -.dfc-toggle:hover, -.dfc-toggle:focus { - opacity: 1; - box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.35); - outline: none; -} - -.dfc-toggle:active { - transform: translateY(1px); -} - -.dfc-toggle .icon { - font-size: 12px; -} - -.dfc-toggle[data-face='back'] { - background: rgba(76, 29, 149, 0.85); -} - -.dfc-toggle[data-face='front'] { - background: rgba(15, 23, 42, 0.82); -} - -.dfc-toggle[aria-pressed='true'] { - box-shadow: 0 0 0 2px var(--accent, #38bdf8); -} - -.list-row .dfc-toggle { - position: static; - width: auto; - height: auto; - border-radius: 6px; - padding: 2px 8px; - font-size: 12px; - opacity: 1; - backdrop-filter: none; - margin-left: 4px; -} - -.list-row .dfc-toggle .icon { - font-size: 12px; -} - -.list-row .dfc-toggle[data-face='back'] { - background: rgba(76, 29, 149, 0.3); -} - -.list-row .dfc-toggle[data-face='front'] { - background: rgba(56, 189, 248, 0.2); -} - -/* Mobile visibility handled via Tailwind responsive classes in JavaScript (hidden md:flex) */ - -/* ============================================================================= - SITE FOOTER (Moved from base.html 2025-10-21) - ============================================================================= */ - -.site-footer { - margin: 8px 16px; - padding: 8px 12px; - border-top: 1px solid var(--border); - color: #94a3b8; - font-size: 12px; - text-align: center; -} - -.site-footer a { - color: #cbd5e1; - text-decoration: underline; -} - -/* ============================================================================= - THEME PREVIEW FRAGMENT (themes/preview_fragment.html) - ============================================================================= */ - -/* Preview header */ -.preview-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; -} - -.preview-header h3 { - margin: 0; - font-size: 16px; -} - -.preview-header .btn { - font-size: 12px; - line-height: 1; -} - -/* Preview controls */ -.preview-controls { - display: flex; - gap: 1rem; - align-items: center; - margin: 0.5rem 0 0.75rem; - font-size: 11px; -} - -.preview-controls label { - display: inline-flex; - gap: 4px; - align-items: center; -} - -.preview-controls .help-icon { - opacity: 0.55; - font-size: 10px; - cursor: help; -} - -.preview-controls #preview-status { - opacity: 0.65; -} - -/* Preview rationale */ -.preview-rationale { - margin: 0.25rem 0 0.85rem; - font-size: 11px; - background: var(--panel-alt); - border: 1px solid var(--border); - padding: 0.55rem 0.7rem; - border-radius: 8px; -} - -.preview-rationale summary { - cursor: pointer; - font-weight: 600; - letter-spacing: 0.05em; -} - -.preview-rationale-controls { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: center; - margin-top: 0.4rem; -} - -.preview-rationale-controls .btn { - font-size: 10px; - padding: 4px 8px; -} - -.preview-rationale-controls #hover-compact-indicator { - font-size: 10px; - opacity: 0.7; -} - -.preview-rationale ul { - margin: 0.5rem 0 0 0.9rem; - padding: 0; - list-style: disc; - line-height: 1.35; -} - -.preview-rationale li .detail { - opacity: 0.75; -} - -.preview-rationale li .instances { - opacity: 0.65; -} - -/* Two column layout */ -.preview-two-col { - display: grid; - grid-template-columns: 1fr 480px; - gap: 1.25rem; - align-items: start; - position: relative; -} - -.preview-col-divider { - position: absolute; - top: 0; - bottom: 0; - left: calc(100% - 480px - 0.75rem); - width: 1px; - background: var(--border); - opacity: 0.55; -} - -/* Section headers */ -.preview-section-header { - margin: 0.25rem 0 0.5rem; - font-size: 13px; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0.8; -} - -.preview-section-hr { - border: 0; - border-top: 1px solid var(--border); - margin: 0.35rem 0 0.6rem; -} - -/* Cards flow layout */ -.cards-flow { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -/* Group separators */ -.group-separator { - flex-basis: 100%; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.65; - margin-top: 0.25rem; -} - -.group-separator.mt-larger { - margin-top: 0.5rem; -} - -/* Card sample */ -.card-sample { - width: 230px; -} - -.card-sample .thumb-wrap { - position: relative; -} - -.card-sample img.card-thumb { - filter: blur(4px); - transition: filter 0.35s ease; - background: linear-gradient(145deg, #0b0d12, #111b29); -} - -.card-sample img.card-thumb[data-loaded] { - filter: blur(0); -} - -/* Card badges */ -.dup-badge { - position: absolute; - bottom: 4px; - right: 4px; - background: #4b5563; - color: #fff; - font-size: 10px; - padding: 2px 5px; - border-radius: 10px; -} - -.pin-btn { - position: absolute; - top: 4px; - right: 4px; - background: rgba(0, 0, 0, 0.55); - color: #fff; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 10px; - padding: 2px 5px; - cursor: pointer; -} - -/* Card metadata */ -.card-sample .meta { - font-size: 12px; - margin-top: 2px; -} - -.card-sample .ci-ribbon { - display: flex; - gap: 2px; - margin-bottom: 2px; - min-height: 10px; -} - -.card-sample .nm { - font-weight: 600; - line-height: 1.25; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-sample .mana-line { - min-height: 14px; - display: flex; - flex-wrap: wrap; - gap: 2px; - font-size: 10px; -} - -.card-sample .rarity-badge { - font-size: 9px; - letter-spacing: 0.5px; - text-transform: uppercase; - opacity: 0.7; -} - -.card-sample .role { - opacity: 0.75; - font-size: 11px; - display: flex; - flex-wrap: wrap; - gap: 3px; -} - -.card-sample .reasons { - font-size: 9px; - opacity: 0.55; - line-height: 1.15; -} - -/* Synthetic card */ -.card-sample.synthetic { - border: 1px dashed var(--border); - padding: 8px; - border-radius: 10px; - background: var(--panel-alt); -} - -.card-sample.synthetic .name { - font-size: 12px; - font-weight: 600; - line-height: 1.2; -} - -.card-sample.synthetic .roles { - font-size: 11px; - opacity: 0.8; -} - -.card-sample.synthetic .reasons-text { - font-size: 10px; - margin-top: 2px; - opacity: 0.6; - line-height: 1.15; -} - -/* Spacer */ -.full-width-spacer { - flex-basis: 100%; - height: 0; -} - -/* Commander grid */ -.commander-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); - gap: 1rem; -} - -.commander-cell { - display: flex; - flex-direction: column; - gap: 0.35rem; - align-items: center; -} - -.commander-name { - font-size: 13px; - text-align: center; - line-height: 1.35; - font-weight: 600; - max-width: 230px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.commander-cell.synergy .commander-name { - font-size: 12px; - line-height: 1.3; - font-weight: 500; - opacity: 0.92; -} - -/* Synergy commanders section */ -.synergy-commanders-section { - margin-top: 1rem; -} - -.synergy-commanders-header { - display: flex; - align-items: center; - gap: 0.4rem; - margin-bottom: 0.4rem; -} - -.synergy-commanders-header h5 { - margin: 0; - font-size: 11px; - letter-spacing: 0.05em; - text-transform: uppercase; - opacity: 0.75; -} - -.derived-badge { - background: var(--panel-alt); - border: 1px solid var(--border); - border-radius: 10px; - padding: 2px 6px; - font-size: 10px; - line-height: 1; -} - -/* No commanders message */ -.no-commanders-message { - font-size: 11px; - opacity: 0.7; -} - -/* Footer help text */ -.preview-help-text { - margin-top: 1rem; - font-size: 10px; - opacity: 0.65; - line-height: 1.4; -} - -/* Skeleton loader */ -.preview-skeleton .sk-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.preview-skeleton .sk-bar { - height: 16px; - background: var(--hover); - border-radius: 4px; -} - -.preview-skeleton .sk-bar.title { - width: 200px; -} - -.preview-skeleton .sk-bar.close { - width: 60px; -} - -.preview-skeleton .sk-cards { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 1rem; -} - -.preview-skeleton .sk-card { - width: 230px; - height: 327px; - background: var(--hover); - border-radius: 10px; -} - -/* Responsive */ -@media (max-width: 950px) { - .preview-two-col { - grid-template-columns: 1fr; - } - - .preview-two-col .col-right { - order: -1; - } -} - -footer.site-footer { - flex-shrink: 0; -} - diff --git a/code/web/static/ts/.gitkeep b/code/web/static/ts/.gitkeep deleted file mode 100644 index badfa20..0000000 --- a/code/web/static/ts/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# Placeholder for TypeScript source files -# TypeScript files will be compiled to code/web/static/js/ diff --git a/code/web/static/ts/app.ts b/code/web/static/ts/app.ts deleted file mode 100644 index 3e276eb..0000000 --- a/code/web/static/ts/app.ts +++ /dev/null @@ -1,1702 +0,0 @@ -/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */ -// Type definitions moved inline to avoid module system -interface StateManager { - get(key: string, def?: any): any; - set(key: string, val: any): void; - inHash(obj: Record): void; - readHash(): URLSearchParams; -} - -interface ToastOptions { - duration?: number; -} - -interface TelemetryManager { - send(eventName: string, data?: Record): void; -} - -interface SkeletonManager { - show(context?: HTMLElement | Document): void; - hide(context?: HTMLElement | Document): void; -} - -(function(){ - // Design tokens fallback (in case CSS variables missing in older browsers) - // No-op here since styles.css defines variables; kept for future JS reads. - - // State persistence helpers (localStorage + URL hash) - const state: StateManager = { - get: function(key: string, def?: any): any { - try { const v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; } - }, - set: function(key: string, val: any): void { - try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){} - }, - inHash: function(obj: Record): void { - // Merge obj into location.hash as query-like params - try { - const params = new URLSearchParams((location.hash||'').replace(/^#/, '')); - Object.keys(obj||{}).forEach(function(k: string){ params.set(k, obj[k]); }); - location.hash = params.toString(); - } catch(e){} - }, - readHash: function(): URLSearchParams { - try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); } - } - }; - window.__mtgState = state; - - // Toast system - let toastHost: HTMLElement | null = null; - function ensureToastHost(): HTMLElement { - if (!toastHost){ - toastHost = document.createElement('div'); - toastHost.className = 'toast-host'; - document.body.appendChild(toastHost); - } - return toastHost; - } - function toast(msg: string | HTMLElement, type?: string, opts?: ToastOptions): HTMLElement { - ensureToastHost(); - const t = document.createElement('div'); - t.className = 'toast' + (type ? ' '+type : ''); - t.setAttribute('role','status'); - t.setAttribute('aria-live','polite'); - t.textContent = ''; - if (typeof msg === 'string') { t.textContent = msg; } - else if (msg && msg.nodeType === 1) { t.appendChild(msg); } - toastHost!.appendChild(t); - const delay = (opts && opts.duration) || 2600; - setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay); - return t; - } - window.toast = toast; - function toastHTML(html: string, type?: string, opts?: ToastOptions): HTMLElement { - const container = document.createElement('div'); - container.innerHTML = html; - return toast(container, type, opts); - } - window.toastHTML = toastHTML; - - const telemetryEndpoint: string = (function(): string { - if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){ - return window.__telemetryEndpoint.trim(); - } - return '/telemetry/events'; - })(); - const telemetry: TelemetryManager = { - send: function(eventName: string, data?: Record): void { - if (!telemetryEndpoint || !eventName) return; - let payload: string; - try { - payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() }); - } catch(_){ return; } - try { - if (navigator.sendBeacon){ - const blob = new Blob([payload], { type: 'application/json' }); - navigator.sendBeacon(telemetryEndpoint, blob); - } else if (window.fetch){ - fetch(telemetryEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: payload, - keepalive: true, - }).catch(function(){ /* noop */ }); - } - } catch(_){ } - } - }; - window.appTelemetry = telemetry; - - // Global HTMX error handling => toast - document.addEventListener('htmx:responseError', function(e){ - const detail = e.detail || {} as any; - const xhr = detail.xhr || {} as any; - const rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || ''; - const payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })() as any; - const status = payload.status || xhr.status || ''; - const msg = payload.detail || payload.message || 'Action failed'; - const path = payload.path || (e && e.detail && e.detail.path) || ''; - const html = ''+ - '
'+ - ''+String(msg)+''+ (status? ' ('+status+')' : '')+ - (rid ? '' : '')+ - '
'+ - (rid ? '
Request-ID: '+rid+'
' : ''); - const t = toastHTML(html, 'error', { duration: 7000 }); - // Wire Copy - const btn = t.querySelector('[data-copy-error]') as HTMLButtonElement; - if (btn){ - btn.addEventListener('click', function(){ - const lines = [ - 'Error: '+String(msg), - 'Status: '+String(status), - 'Path: '+String(path || (xhr.responseURL||'')), - 'Request-ID: '+String(rid) - ]; - try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ } - }); - } - // Optional inline banner if a surface is available - try { - const target = e && e.target as HTMLElement; - const surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]'); - if (surface){ - const banner = document.createElement('div'); - banner.className = 'inline-error-banner'; - banner.innerHTML = ''+String(msg)+'' + (rid? ' (Request-ID: '+rid+')' : ''); - surface.prepend(banner); - setTimeout(function(){ banner.remove(); }, 8000); - } - } catch(_){ } - }); - document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); }); - - // Keyboard shortcuts - const keymap: Record void> = { - ' ': function(){ const el = document.querySelector('[data-action="continue"], .btn-continue') as HTMLElement; if (el) el.click(); }, - 'r': function(){ const el = document.querySelector('[data-action="rerun"], .btn-rerun') as HTMLElement; if (el) el.click(); }, - 'b': function(){ const el = document.querySelector('[data-action="back"], .btn-back') as HTMLElement; if (el) el.click(); }, - 'l': function(){ const el = document.querySelector('[data-action="toggle-logs"], .btn-logs') as HTMLElement; if (el) el.click(); }, - }; - document.addEventListener('keydown', function(e){ - const target = e.target as HTMLElement; - if (target && (/input|textarea|select/i).test(target.tagName)) return; // don't hijack inputs - const k = e.key.toLowerCase(); - // If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock) - try { - const active = document.activeElement as HTMLElement; - if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) { - return; - } - } catch(_) { /* noop */ } - if (keymap[k]){ e.preventDefault(); keymap[k](); } - }); - - // Focus ring visibility for keyboard nav - function addFocusVisible(){ - let hadKeyboardEvent = false; - function onKeyDown(){ hadKeyboardEvent = true; } - function onPointer(){ hadKeyboardEvent = false; } - function onFocus(e: FocusEvent){ if (hadKeyboardEvent) (e.target as HTMLElement).classList.add('focus-visible'); } - function onBlur(e: FocusEvent){ (e.target as HTMLElement).classList.remove('focus-visible'); } - window.addEventListener('keydown', onKeyDown, true); - window.addEventListener('mousedown', onPointer, true); - window.addEventListener('pointerdown', onPointer, true); - window.addEventListener('touchstart', onPointer, true); - document.addEventListener('focusin', onFocus); - document.addEventListener('focusout', onBlur); - } - addFocusVisible(); - - // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable - let SKELETON_DELAY_DEFAULT = 400; - let skeletonTimers = new WeakMap(); - function gatherSkeletons(root){ - if (!root){ return []; } - let list = []; - let scope = (root.nodeType === 9) ? root.documentElement : root; - if (scope && scope.matches && scope.hasAttribute('data-skeleton')){ - list.push(scope); - } - if (scope && scope.querySelectorAll){ - scope.querySelectorAll('[data-skeleton]').forEach(function(el){ - if (list.indexOf(el) === -1){ list.push(el); } - }); - } - return list; - } - function scheduleSkeleton(el){ - let delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10); - let delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr); - clearSkeleton(el, false); - const timer = setTimeout(function(){ - el.classList.add('is-loading'); - el.setAttribute('aria-busy', 'true'); - skeletonTimers.set(el, null); - }, delay); - skeletonTimers.set(el, timer); - } - function clearSkeleton(el: HTMLElement, removeBusy?: boolean): void { - let timer = skeletonTimers.get(el); - if (typeof timer === 'number'){ - clearTimeout(timer); - } - skeletonTimers.delete(el); - el.classList.remove('is-loading'); - if (removeBusy !== false){ el.removeAttribute('aria-busy'); } - } - function showSkeletons(context?: HTMLElement | Document): void { - gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); }); - } - function hideSkeletons(context?: HTMLElement | Document): void { - gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el, true); }); - } - window.skeletons = { show: showSkeletons, hide: hideSkeletons }; - - document.addEventListener('htmx:beforeRequest', function(e){ - const detail = e.detail as any; - const target = detail.target || detail.elt || e.target; - showSkeletons(target); - }); - document.addEventListener('htmx:afterSwap', function(e){ - const detail = e.detail as any; - const target = detail.target || detail.elt || e.target; - hideSkeletons(target); - }); - document.addEventListener('htmx:afterRequest', function(e){ - const detail = e.detail as any; - const target = detail.target || detail.elt || e.target; - hideSkeletons(target); - }); - - // Commander catalog image lazy loader - (function(){ - let PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; - let observer = null; - let supportsIO = 'IntersectionObserver' in window; - - function ensureObserver(){ - if (observer || !supportsIO) return observer; - observer = new IntersectionObserver(function(entries){ - entries.forEach(function(entry){ - if (entry.isIntersecting || entry.intersectionRatio > 0){ - let img = entry.target; - load(img); - if (observer) observer.unobserve(img); - } - }); - }, { rootMargin: '160px 0px', threshold: 0.05 }); - return observer; - } - - function load(img){ - if (!img || img.__lazyLoaded) return; - let src = img.getAttribute('data-lazy-src'); - if (src){ img.setAttribute('src', src); } - let srcset = img.getAttribute('data-lazy-srcset'); - if (srcset){ img.setAttribute('srcset', srcset); } - let sizes = img.getAttribute('data-lazy-sizes'); - if (sizes){ img.setAttribute('sizes', sizes); } - img.classList.remove('is-placeholder'); - img.removeAttribute('data-lazy-image'); - img.removeAttribute('data-lazy-src'); - img.removeAttribute('data-lazy-srcset'); - img.removeAttribute('data-lazy-sizes'); - img.__lazyLoaded = true; - } - - function prime(img){ - if (!img || img.__lazyPrimed) return; - let desired = img.getAttribute('data-lazy-src'); - if (!desired) return; - img.__lazyPrimed = true; - let placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL; - img.setAttribute('loading', 'lazy'); - img.setAttribute('decoding', 'async'); - img.classList.add('is-placeholder'); - img.removeAttribute('srcset'); - img.removeAttribute('sizes'); - img.setAttribute('src', placeholder); - if (supportsIO){ - ensureObserver().observe(img); - } else { - const loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); }; - loader(function(){ load(img); }); - } - } - - function collect(scope){ - if (!scope) scope = document; - if (scope === document){ - return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]')); - } - if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){ - return [scope]; - } - if (scope.querySelectorAll){ - return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]')); - } - return []; - } - - function process(scope){ - collect(scope).forEach(function(img){ - if (img.__lazyLoaded) return; - prime(img); - }); - } - - if (document.readyState === 'loading'){ - document.addEventListener('DOMContentLoaded', function(){ process(document); }); - } else { - process(document); - } - - document.addEventListener('htmx:afterSwap', function(evt){ - let target = evt && evt.detail ? evt.detail.target : null; - process(target || document); - }); - })(); - - const htmxCache = (function(){ - let store = new Map(); - function ttlFor(elt){ - let raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10); - if (isNaN(raw) || raw <= 0){ return 30000; } - return Math.max(1000, raw); - } - function buildKey(detail, elt){ - if (!detail) detail = {}; - if (elt && elt.getAttribute){ - let explicit = elt.getAttribute('data-hx-cache-key'); - if (explicit && explicit.trim()){ return explicit.trim(); } - } - let verb = (detail.verb || 'GET').toUpperCase(); - let path = detail.path || ''; - let params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : ''; - return verb + ' ' + path + ' ' + params; - } - function set(key, html, ttl, meta){ - if (!key || typeof html !== 'string') return; - store.set(key, { - key: key, - html: html, - expires: Date.now() + (ttl || 30000), - meta: meta || {}, - }); - } - function get(key){ - if (!key) return null; - let entry = store.get(key); - if (!entry) return null; - if (entry.expires && entry.expires <= Date.now()){ - store.delete(key); - return null; - } - return entry; - } - function applyCached(elt, detail, entry){ - if (!entry) return; - let target = detail && detail.target ? detail.target : elt; - if (!target) return; - dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); - let swapSpec = ''; - try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ } - swapSpec = (swapSpec || 'innerHTML').toLowerCase(); - if (swapSpec.indexOf('outer') === 0){ - if (target.outerHTML !== undefined){ - target.outerHTML = entry.html; - } - } else if (target.innerHTML !== undefined){ - target.innerHTML = entry.html; - } - if (window.htmx && typeof window.htmx.process === 'function'){ - window.htmx.process(target); - } - dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); - dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key }); - } - function prefetch(url, opts){ - if (!url) return; - opts = opts || {}; - let key = opts.key || ('GET ' + url); - if (get(key)) return; - try { - fetch(url, { - headers: { 'HX-Request': 'true', 'Accept': 'text/html' }, - cache: 'no-store', - }).then(function(resp){ - if (!resp.ok) throw new Error('prefetch failed'); - return resp.text(); - }).then(function(html){ - set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true }); - telemetry.send('htmx.cache.prefetch', { key: key, url: url }); - }).catch(function(){ /* noop */ }); - } catch(_){ } - } - return { - set: set, - get: get, - apply: applyCached, - buildKey: buildKey, - ttlFor: ttlFor, - prefetch: prefetch, - }; - })(); - window.htmxCache = htmxCache; - - document.addEventListener('htmx:configRequest', function(e: any){ - const detail = e && e.detail ? e.detail : {} as any; - const elt = detail.elt as HTMLElement; - if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return; - const verb = (detail.verb || 'GET').toUpperCase(); - if (verb !== 'GET') return; - const key = htmxCache.buildKey(detail, elt); - elt.__hxCacheKey = key; - elt.__hxCacheTTL = htmxCache.ttlFor(elt); - detail.headers = detail.headers || {}; - try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ } - }); - - document.addEventListener('htmx:beforeRequest', function(e: any){ - const detail = e && e.detail ? e.detail : {} as any; - const elt = detail.elt as HTMLElement; - if (!elt || !elt.__hxCacheKey) return; - const entry = htmxCache.get(elt.__hxCacheKey); - if (entry){ - telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' }); - e.preventDefault(); - htmxCache.apply(elt, detail, entry); - } else { - telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' }); - } - }); - - document.addEventListener('htmx:afterSwap', function(e: any){ - const detail = e && e.detail ? e.detail : {} as any; - const elt = detail.elt as HTMLElement; - if (!elt || !elt.__hxCacheKey) return; - try { - const xhr = detail.xhr; - const status = xhr && xhr.status ? xhr.status : 0; - if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){ - const ttl = elt.__hxCacheTTL || 30000; - htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' }); - telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl }); - } - } catch(_){ } - elt.__hxCacheKey = undefined; - elt.__hxCacheTTL = undefined; - }); - - (function(){ - function handlePrefetch(evt: Event){ - try { - const el = (evt.target as HTMLElement)?.closest ? (evt.target as HTMLElement).closest('[data-hx-prefetch]') : null; - if (!el || el.__hxPrefetched) return; - let url = el.getAttribute('data-hx-prefetch'); - if (!url) return; - el.__hxPrefetched = true; - let key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url); - let ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10); - let ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr); - htmxCache.prefetch(url, { key: key, ttl: ttl }); - } catch(_){ } - } - document.addEventListener('pointerenter', handlePrefetch, true); - document.addEventListener('focusin', handlePrefetch, true); - })(); - - // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce) - let hxDebounceGroups = new Map(); - function dispatchHtmx(el, evtName, detail){ - if (!el) return; - if (window.htmx && typeof window.htmx.trigger === 'function'){ - window.htmx.trigger(el, evtName, detail); - } else { - try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ } - } - } - function bindHtmxDebounce(el){ - if (!el || el.__hxDebounceBound) return; - el.__hxDebounceBound = true; - let delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10); - let delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw); - let eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input'; - let events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean); - if (!events.length){ events = ['input']; } - let trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput'; - let group = el.getAttribute('data-hx-debounce-group') || ''; - let flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase(); - let flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true'); - function clearTimer(){ - if (el.__hxDebounceTimer){ - clearTimeout(el.__hxDebounceTimer); - el.__hxDebounceTimer = null; - } - } - function schedule(){ - clearTimer(); - if (group){ - let prev = hxDebounceGroups.get(group); - if (prev && prev !== el && prev.__hxDebounceTimer){ - clearTimeout(prev.__hxDebounceTimer); - prev.__hxDebounceTimer = null; - } - hxDebounceGroups.set(group, el); - } - el.__hxDebounceTimer = setTimeout(function(){ - el.__hxDebounceTimer = null; - dispatchHtmx(el, trigger, {}); - }, delay); - } - events.forEach(function(evt){ - el.addEventListener(evt, schedule, { passive: true }); - }); - if (flushOnBlur){ - el.addEventListener('blur', function(){ - if (el.__hxDebounceTimer){ - clearTimer(); - dispatchHtmx(el, trigger, {}); - } - }); - } - el.addEventListener('htmx:beforeRequest', clearTimer); - } - function initHtmxDebounce(root){ - let scope = root || document; - if (scope === document){ scope = document.body || document; } - if (!scope) return; - let seen = new Set(); - function collect(candidate){ - if (!candidate || seen.has(candidate)) return; - seen.add(candidate); - bindHtmxDebounce(candidate); - } - if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){ - collect(scope); - } - if (scope.querySelectorAll){ - scope.querySelectorAll('[data-hx-debounce]').forEach(collect); - } - } - window.initHtmxDebounce = () => initHtmxDebounce(document.body); - - // Example: persist "show skipped" toggle if present - document.addEventListener('change', function(e){ - const el = e.target as HTMLInputElement; - if (el && el.matches('[data-pref]')){ - let key = el.getAttribute('data-pref'); - let val = (el.type === 'checkbox') ? !!el.checked : el.value; - state.set(key, val); - state.inHash((function(o){ o[key] = val; return o; })({})); - } - }); - // On load, initialize any data-pref elements - document.addEventListener('DOMContentLoaded', function(){ - document.querySelectorAll('[data-pref]').forEach(function(el){ - let key = el.getAttribute('data-pref'); - let saved = state.get(key, undefined); - if (typeof saved !== 'undefined'){ - if ((el as HTMLInputElement).type === 'checkbox') (el as HTMLInputElement).checked = !!saved; else (el as HTMLInputElement).value = saved; - } - }); - hydrateProgress(document); - syncShowSkipped(document); - initCardFilters(document); - initVirtualization(document); - initHtmxDebounce(document); - initMustHaveControls(document); - }); - - // Hydrate progress bars with width based on data-pct - function hydrateProgress(root){ - (root || document).querySelectorAll('.progress[data-pct]') - .forEach(function(p){ - let pct = parseInt(p.getAttribute('data-pct') || '0', 10); - if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100; - let bar = p.querySelector('.bar'); if (!bar) return; - // Animate width for a bit of delight - requestAnimationFrame(function(){ bar.style.width = pct + '%'; }); - }); - } - // Keep hidden inputs for show_skipped in sync with the sticky checkbox - function syncShowSkipped(root){ - let cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]'); - if (!cb) return; - let val = cb.checked ? '1' : '0'; - (root || document).querySelectorAll('section form').forEach(function(f){ - let h = f.querySelector('input[name="show_skipped"]'); - if (h) h.value = val; - }); - } - document.addEventListener('htmx:afterSwap', function(e){ - hydrateProgress(e.target as HTMLElement); - syncShowSkipped(e.target as HTMLElement); - initCardFilters(e.target as HTMLElement); - initVirtualization(e.target as HTMLElement); - initHtmxDebounce(e.target as HTMLElement); - initMustHaveControls(e.target as HTMLElement); - }); - - // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) - function scrollCardIntoView(name){ - if (!name) return; - try{ - let section = document.querySelector('section'); - let grid = section && section.querySelector('.card-grid'); - if (!grid) return; - // If virtualized, force a render around the approximate match by searching stored children - let target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); - if (!target) { - // Trigger a render update and try again - grid.dispatchEvent(new Event('scroll')); // noop but can refresh - target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]'); - } - if (target) { - target.scrollIntoView({ block: 'center', behavior: 'smooth' }); - (target as HTMLElement).focus && (target as HTMLElement).focus(); - } - }catch(_){} - } - window.scrollCardIntoView = scrollCardIntoView; - - // --- Card grid filters, reasons, and collapsible groups --- - function initCardFilters(root){ - let section = (root || document).querySelector('section'); - if (!section) return; - let toolbar = section.querySelector('.cards-toolbar'); - if (!toolbar) return; // nothing to do - let q = toolbar.querySelector('input[name="filter_query"]'); - let ownedSel = toolbar.querySelector('select[name="filter_owned"]'); - let showReasons = toolbar.querySelector('input[name="show_reasons"]'); - let collapseGroups = toolbar.querySelector('input[name="collapse_groups"]'); - let resultsEl = toolbar.querySelector('[data-results]'); - let emptyEl = section.querySelector('[data-empty]'); - let sortSel = toolbar.querySelector('select[name="filter_sort"]'); - let chipOwned = toolbar.querySelector('[data-chip-owned="owned"]'); - let chipNot = toolbar.querySelector('[data-chip-owned="not"]'); - let chipAll = toolbar.querySelector('[data-chip-owned="all"]'); - let chipClear = toolbar.querySelector('[data-chip-clear]'); - - function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; } - // Read URL hash on first init to hydrate controls - try { - let params = window.__mtgState.readHash(); - if (params){ - let hv = params.get('q'); if (q && hv !== null) q.value = hv; - hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv; - hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1'); - hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1'); - hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv; - } - } catch(_){} - function apply(){ - let query = (getVal(q)+ '').toLowerCase().trim(); - let ownedMode = (getVal(ownedSel) || 'all'); - let showR = !!getVal(showReasons); - let collapse = !!getVal(collapseGroups); - let sortMode = (getVal(sortSel) || 'az'); - // Toggle reasons visibility via section class - section.classList.toggle('hide-reasons', !showR); - // Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state - section.querySelectorAll('.group').forEach(function(wrapper){ - let grid = wrapper.querySelector('.group-grid'); if (!grid) return; - let key = wrapper.getAttribute('data-group-key'); - if (collapse){ - grid.setAttribute('data-collapsed','1'); - } else { - // restore stored - if (key){ - let stored = state.get('cards:group:'+key, null); - if (stored === true){ grid.setAttribute('data-collapsed','1'); } - else { grid.removeAttribute('data-collapsed'); } - } else { - grid.removeAttribute('data-collapsed'); - } - } - }); - // Filter tiles - let tiles = section.querySelectorAll('.card-grid .card-tile'); - let visible = 0; - tiles.forEach(function(tile){ - let name = (tile.getAttribute('data-card-name')||'').toLowerCase(); - let role = (tile.getAttribute('data-role')||'').toLowerCase(); - let tags = (tile.getAttribute('data-tags')||'').toLowerCase(); - let tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase(); - let owned = tile.getAttribute('data-owned') === '1'; - let text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug; - let qOk = !query || text.indexOf(query) !== -1; - let oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned); - let show = qOk && oOk; - tile.style.display = show ? '' : 'none'; - if (show) visible++; - }); - // Sort within each grid - function keyFor(tile){ - let name = (tile.getAttribute('data-card-name')||''); - let owned = tile.getAttribute('data-owned') === '1' ? 1 : 0; - let gc = tile.classList.contains('game-changer') ? 1 : 0; - return { name: name.toLowerCase(), owned: owned, gc: gc }; - } - section.querySelectorAll('.card-grid').forEach(function(grid){ - const arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile')); - arr.sort(function(a,b){ - let ka = keyFor(a), kb = keyFor(b); - if (sortMode === 'owned'){ - if (kb.owned !== ka.owned) return kb.owned - ka.owned; - if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next - return ka.name.localeCompare(kb.name); - } else if (sortMode === 'gc'){ - if (kb.gc !== ka.gc) return kb.gc - ka.gc; - if (kb.owned !== ka.owned) return kb.owned - ka.owned; - return ka.name.localeCompare(kb.name); - } - // default A–Z - return ka.name.localeCompare(kb.name); - }); - arr.forEach(function(el){ grid.appendChild(el); }); - }); - // Update group counts based on visible tiles within each group - section.querySelectorAll('.group').forEach(function(wrapper){ - let grid = wrapper.querySelector('.group-grid'); - let count = 0; - if (grid){ - grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; }); - } - let cEl = wrapper.querySelector('[data-count]'); - if (cEl) cEl.textContent = count; - }); - if (resultsEl) resultsEl.textContent = String(visible); - if (emptyEl) emptyEl.hidden = (visible !== 0); - // Persist prefs - if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value); - if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value); - if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked); - if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked); - if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value); - // Update URL hash for shareability - try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ } - } - // Wire events - if (q) q.addEventListener('input', apply); - if (ownedSel) ownedSel.addEventListener('change', apply); - if (showReasons) showReasons.addEventListener('change', apply); - if (collapseGroups) collapseGroups.addEventListener('change', apply); - if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); }); - if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); }); - if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); }); - if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); }); - // Individual group toggles - section.querySelectorAll('.group-header .toggle').forEach(function(btn){ - btn.addEventListener('click', function(){ - let wrapper = btn.closest('.group'); - let grid = wrapper && wrapper.querySelector('.group-grid'); - if (!grid) return; - let key = wrapper.getAttribute('data-group-key'); - let willCollapse = !grid.getAttribute('data-collapsed'); - if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed'); - if (key){ state.set('cards:group:'+key, !!willCollapse); } - // ARIA - btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true'); - }); - }); - // Per-card reason toggle: delegate clicks on .btn-why - section.addEventListener('click', function(e){ - let t = e.target; - if (!t || !t.classList || !t.classList.contains('btn-why')) return; - e.preventDefault(); - let tile = t.closest('.card-tile'); - if (!tile) return; - let globalHidden = section.classList.contains('hide-reasons'); - if (globalHidden){ - // Force-show overrides global hidden - let on = tile.classList.toggle('force-show'); - if (on) tile.classList.remove('force-hide'); - t.textContent = on ? 'Hide why' : 'Why?'; - } else { - // Hide this tile only - let off = tile.classList.toggle('force-hide'); - if (off) tile.classList.remove('force-show'); - t.textContent = off ? 'Show why' : 'Hide why'; - } - }); - // Initial apply on hydrate - apply(); - - // Keyboard helpers: '/' focuses query, Esc clears - function onKey(e){ - // avoid when typing in inputs - if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return; - if (e.key === '/'){ - if (q){ e.preventDefault(); q.focus(); q.select && q.select(); } - } else if (e.key === 'Escape'){ - if (q && q.value){ q.value=''; apply(); } - } - } - document.addEventListener('keydown', onKey); - } - - // --- Lightweight virtualization (feature-flagged via data-virtualize) --- - function initVirtualization(root){ - try{ - let body = document.body || document.documentElement; - const DIAG = !!(body && body.getAttribute('data-diag') === '1'); - const GLOBAL = (function(){ - if (!DIAG) return null; - if (window.__virtGlobal) return window.__virtGlobal; - let store = { grids: [], summaryEl: null }; - function ensure(){ - if (!store.summaryEl){ - let el = document.createElement('div'); - el.id = 'virt-global-diag'; - el.style.position = 'fixed'; - el.style.right = '8px'; - el.style.bottom = '8px'; - el.style.background = 'rgba(17,24,39,.85)'; - el.style.border = '1px solid var(--border)'; - el.style.padding = '.25rem .5rem'; - el.style.borderRadius = '6px'; - el.style.fontSize = '12px'; - el.style.color = '#cbd5e1'; - el.style.zIndex = '50'; - el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)'; - el.style.cursor = 'default'; - el.style.display = 'none'; - document.body.appendChild(el); - store.summaryEl = el; - } - return store.summaryEl; - } - function update(){ - let el = ensure(); if (!el) return; - let g = store.grids; - let total = 0, visible = 0, lastMs = 0; - for (let i=0;i -1 ? 110 : 240); - let minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow; - let rowH = minRowH; - let explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null; - let perRow = explicitCols || 1; - - let diagBox = null; let lastRenderAt = 0; let lastRenderMs = 0; - let renderCount = 0; let measureCount = 0; let swapCount = 0; - let gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6); - let globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null; - - function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } } - function ensureDiag(){ - if (!DIAG) return null; - if (diagBox) return diagBox; - diagBox = document.createElement('div'); - diagBox.className = 'virt-diag'; - diagBox.style.position = 'sticky'; - diagBox.style.top = '0'; - diagBox.style.zIndex = '5'; - diagBox.style.background = 'rgba(17,24,39,.85)'; - diagBox.style.border = '1px solid var(--border)'; - diagBox.style.padding = '.25rem .5rem'; - diagBox.style.borderRadius = '6px'; - diagBox.style.fontSize = '12px'; - diagBox.style.margin = '0 0 .35rem 0'; - diagBox.style.color = '#cbd5e1'; - diagBox.style.display = 'none'; - let controls = document.createElement('div'); - controls.style.display = 'flex'; - controls.style.gap = '.35rem'; - controls.style.alignItems = 'center'; - controls.style.marginBottom = '.25rem'; - let title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af'; - let btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small'; - btnCopy.addEventListener('click', function(){ - try{ - let payload = { - id: gridId, - rowH: rowH, - perRow: perRow, - start: start, - end: end, - total: total, - renderCount: renderCount, - measureCount: measureCount, - swapCount: swapCount, - lastRenderMs: lastRenderMs, - lastRenderAt: lastRenderAt, - }; - navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); - btnCopy.textContent = 'Copied'; - setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); - }catch(_){ } - }); - let btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small'; - btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; }); - controls.appendChild(title); - controls.appendChild(btnCopy); - controls.appendChild(btnHide); - diagBox.appendChild(controls); - let text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text); - let host = (container.id === 'owned-box') ? container : container.parentElement || container; - host.insertBefore(diagBox, host.firstChild); - return diagBox; - } - - function measure(){ - try { - measureCount++; - let probe = store.firstElementChild || all[0]; - if (probe){ - let fake = probe.cloneNode(true); - fake.style.position = 'absolute'; - fake.style.visibility = 'hidden'; - fake.style.pointerEvents = 'none'; - (ownedGrid || container).appendChild(fake); - let rect = fake.getBoundingClientRect(); - rowH = Math.max(minRowH, Math.ceil(rect.height) + 16); - (ownedGrid || container).removeChild(fake); - } - let style = window.getComputedStyle(ownedGrid || container); - let cols = style.getPropertyValue('grid-template-columns'); - try { - let displayMode = style.getPropertyValue('display'); - if (displayMode && displayMode.trim()){ - wrapper.style.display = displayMode; - } else if (!wrapper.style.display){ - wrapper.style.display = 'grid'; - } - if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols; - let gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap'); - if (gap && gap.trim()) wrapper.style.gap = gap; - let ji = style.getPropertyValue('justify-items'); - if (ji && ji.trim()) wrapper.style.justifyItems = ji; - let ai = style.getPropertyValue('align-items'); - if (ai && ai.trim()) wrapper.style.alignItems = ai; - } catch(_){ } - const derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){ - return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1); - }).length : 0); - if (explicitCols){ - perRow = explicitCols; - } else if (derivedCols){ - perRow = Math.max(1, derivedCols); - } else { - perRow = Math.max(1, perRow); - } - } catch(_){ } - } - - measure(); - let total = all.length; - let start = 0, end = 0; - - function render(){ - let t0 = DIAG ? performance.now() : 0; - let scroller = container; - let vh, scrollTop, top; - - if (useWindowScroll) { - // Window-scroll mode: measure relative to viewport - vh = window.innerHeight; - let rect = container.getBoundingClientRect(); - top = Math.max(0, -rect.top); - scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; - } else { - // Container-scroll mode: measure relative to container - vh = scroller.clientHeight || window.innerHeight; - scrollTop = scroller.scrollTop; - top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); - } - - let rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2; - let rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1); - let rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow))); - let newStart = rowStart * Math.max(1, perRow); - let newEnd = Math.min(total, rowEnd * Math.max(1, perRow)); - if (newStart === start && newEnd === end) return; - start = newStart; - end = newEnd; - let beforeRows = Math.floor(start / Math.max(1, perRow)); - let afterRows = Math.ceil((total - end) / Math.max(1, perRow)); - padTop.style.height = (beforeRows * rowH) + 'px'; - padBottom.style.height = (afterRows * rowH) + 'px'; - wrapper.innerHTML = ''; - for (let i = start; i < end; i++){ - let node = all[i]; - if (node) wrapper.appendChild(node); - } - if (DIAG){ - let box = ensureDiag(); - if (box){ - let dt = performance.now() - t0; - lastRenderMs = dt; - renderCount++; - lastRenderAt = Date.now(); - let vis = end - start; - let rowsTotal = Math.ceil(total / Math.max(1, perRow)); - let textEl = box.querySelector('.virt-diag-text'); - let msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount; - textEl.textContent = msg; - let bad = (dt > 33) || (vis > 300); - let warn = (!bad) && ((dt > 16) || (vis > 200)); - box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)'); - box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none'); - if (globalReg && globalReg.set){ - globalReg.set({ total: total, start: start, end: end, lastMs: dt }); - } - } - } - } - - function onScroll(){ render(); } - function onResize(){ measure(); render(); } - - // Support both container-scroll (default) and window-scroll modes - let scrollMode = overflowAttr || container.style.overflow || 'auto'; - let useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window'); - - if (useWindowScroll) { - // Window-scroll mode: listen to window scroll events - window.addEventListener('scroll', onScroll, { passive: true }); - } else { - // Container-scroll mode: listen to container scroll events - container.addEventListener('scroll', onScroll, { passive: true }); - } - window.addEventListener('resize', onResize); - - render(); - - // Track cleanup for disconnected containers - grid.__virtCleanup = function(){ - try { - if (useWindowScroll) { - window.removeEventListener('scroll', onScroll); - } else { - container.removeEventListener('scroll', onScroll); - } - window.removeEventListener('resize', onResize); - } catch(_){} - }; - - document.addEventListener('htmx:afterSwap', function(ev){ - if (!container.isConnected) return; - if (!container.contains(ev.target)) return; - swapCount++; - let merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); - const known = new Map(); - all.forEach(function(node, idx){ - let index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx; - known.set(node, index); - }); - let nextIndex = known.size; - merged.forEach(function(node){ - if (!known.has(node)){ - node.__virtIndex = nextIndex; - known.set(node, nextIndex); - nextIndex++; - } - }); - merged.sort(function(a, b){ - let ia = known.get(a); - const ib = known.get(b); - return (ia - ib); - }); - merged.forEach(function(node, idx){ node.__virtIndex = idx; }); - all = merged; - total = all.length; - measure(); - render(); - }); - - if (DIAG && !window.__virtHotkeyBound){ - window.__virtHotkeyBound = true; - document.addEventListener('keydown', function(e){ - try{ - if (e.target && (/input|textarea|select/i).test((e.target as HTMLElement).tagName)) return; - if (e.key && e.key.toLowerCase() === 'v'){ - e.preventDefault(); - let shown = null; - document.querySelectorAll('.virt-diag').forEach(function(b){ - if (shown === null) shown = ((b as HTMLElement).style.display === 'none'); - (b as HTMLElement).style.display = shown ? '' : 'none'; - }); - if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle(); - } - }catch(_){ } - }); - } - }); - }catch(_){ } - } - - function setTileState(tile, type, active){ - if (!tile) return; - let attr = 'data-must-' + type; - tile.setAttribute(attr, active ? '1' : '0'); - tile.classList.toggle('must-' + type, !!active); - let selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude'); - try { - let btn = tile.querySelector(selector); - if (btn){ - btn.setAttribute('data-active', active ? '1' : '0'); - btn.setAttribute('aria-pressed', active ? 'true' : 'false'); - btn.classList.toggle('is-active', !!active); - } - } catch(_){ } - } - - function restoreMustHaveState(tile, state){ - if (!tile || !state) return; - setTileState(tile, 'include', state.include ? 1 : 0); - setTileState(tile, 'exclude', state.exclude ? 1 : 0); - } - - function applyLocalMustHave(tile, type, enabled){ - if (!tile) return; - if (type === 'include'){ - setTileState(tile, 'include', enabled ? 1 : 0); - if (enabled){ setTileState(tile, 'exclude', 0); } - } else if (type === 'exclude'){ - setTileState(tile, 'exclude', enabled ? 1 : 0); - if (enabled){ setTileState(tile, 'include', 0); } - } - } - - function sendMustHaveRequest(tile, type, enabled, cardName, prevState){ - if (!window.htmx){ - restoreMustHaveState(tile, prevState); - tile.setAttribute('data-must-pending', '0'); - toast('Offline: cannot update preference', 'error', { duration: 4000 }); - return; - } - let summaryTarget = document.getElementById('include-exclude-summary'); - let ajaxOptions = { - source: tile, - target: summaryTarget || tile, - swap: summaryTarget ? 'outerHTML' : 'none', - values: { - card_name: cardName, - list_type: type, - enabled: enabled ? '1' : '0', - }, - }; - let xhr; - try { - xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions); - } catch(_){ - restoreMustHaveState(tile, prevState); - tile.setAttribute('data-must-pending', '0'); - toast('Unable to submit preference update', 'error', { duration: 4500 }); - telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' }); - return; - } - if (!xhr || !xhr.addEventListener){ - tile.setAttribute('data-must-pending', '0'); - return; - } - xhr.addEventListener('load', function(evt){ - tile.setAttribute('data-must-pending', '0'); - let request = evt && evt.currentTarget ? evt.currentTarget : xhr; - let status = request.status || 0; - if (status >= 400){ - restoreMustHaveState(tile, prevState); - let msg = 'Failed to update preference'; - try { - let data = JSON.parse(request.responseText || '{}'); - if (data && data.error) msg = data.error; - } catch(_){ } - toast(msg, 'error', { duration: 5000 }); - telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status }); - return; - } - let message; - if (enabled){ - message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude'; - } else { - message = (type === 'include') ? 'Removed must include' : 'Removed must exclude'; - } - toast(message + ': ' + cardName, 'success', { duration: 2400 }); - telemetry.send('must_have.toggle', { - card: cardName, - list: type, - enabled: enabled, - requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null, - }); - }); - xhr.addEventListener('error', function(){ - tile.setAttribute('data-must-pending', '0'); - restoreMustHaveState(tile, prevState); - toast('Network error updating preference', 'error', { duration: 5000 }); - telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' }); - }); - } - - function initMustHaveControls(root){ - let scope = root && root.querySelectorAll ? root : document; - if (scope === document && document.body) scope = document.body; - if (!scope || !scope.querySelectorAll) return; - scope.querySelectorAll('.must-have-btn').forEach(function(btn){ - if (!btn || btn.__mustHaveBound) return; - btn.__mustHaveBound = true; - let active = btn.getAttribute('data-active') === '1'; - btn.setAttribute('aria-pressed', active ? 'true' : 'false'); - btn.addEventListener('click', function(ev){ - ev.preventDefault(); - let tile = btn.closest('.card-tile'); - if (!tile) return; - if (tile.getAttribute('data-must-pending') === '1') return; - let type = btn.getAttribute('data-toggle'); - if (!type) return; - let prevState = { - include: tile.getAttribute('data-must-include') === '1', - exclude: tile.getAttribute('data-must-exclude') === '1', - }; - let nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude); - let label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || ''; - tile.setAttribute('data-must-pending', '1'); - applyLocalMustHave(tile, type, nextEnabled); - sendMustHaveRequest(tile, type, nextEnabled, label, prevState); - }); - }); - } - - // LQIP blur/fade-in for thumbnails marked with data-lqip - document.addEventListener('DOMContentLoaded', function(){ - try{ - document.querySelectorAll('img[data-lqip]') - .forEach(function(img){ - img.classList.add('lqip'); - img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true }); - }); - }catch(_){ } - }); - - // --- Lazy-loading analytics accordions --- - function initLazyAccordions(root){ - try { - let scope = root || document; - if (!scope || !scope.querySelectorAll) return; - - scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){ - if (!details || details.__lazyBound) return; - details.__lazyBound = true; - - let loaded = false; - - details.addEventListener('toggle', function(){ - if (!details.open || loaded) return; - loaded = true; - - // Mark as loaded to prevent re-initialization - let content = details.querySelector('.analytics-content'); - if (!content) return; - - // Remove placeholder if present - let placeholder = content.querySelector('.analytics-placeholder'); - if (placeholder) { - placeholder.remove(); - } - - // Content is already rendered in the template, just need to initialize any scripts - // Re-run virtualization if needed - try { - initVirtualization(content); - } catch(_){} - - // Re-attach chart interactivity if this is mana overview - let type = details.getAttribute('data-analytics-type'); - if (type === 'mana') { - try { - // Tooltip and highlight logic is already in the template scripts - // Just trigger a synthetic event to re-attach if needed - let event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } }); - details.dispatchEvent(event); - } catch(_){} - } - - // Send telemetry - telemetry.send('analytics.accordion_expand', { - type: type || 'unknown', - accordion: details.id || 'unnamed', - }); - }); - }); - } catch(_){} - } - - // Initialize on load and after HTMX swaps - document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(document.body); }); - document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); }); - - // ============================================================================= - // UTILITIES EXTRACTED FROM BASE.HTML INLINE SCRIPTS (Phase 3) - // ============================================================================= - - /** - * Poll setup status endpoint for progress updates - * Shows dynamic status message in #banner-status element - */ - function initSetupStatusPoller(): void { - let statusEl: HTMLElement | null = null; - - function ensureStatusEl(): HTMLElement | null { - if (!statusEl) statusEl = document.getElementById('banner-status'); - return statusEl; - } - - function renderSetupStatus(data: any): void { - const el = ensureStatusEl(); - if (!el) return; - - if (data && data.running) { - const msg = data.message || 'Preparing data...'; - const pct = (typeof data.percent === 'number') ? data.percent : null; - - // Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed - let suppress = false; - if (pct !== null && pct >= 99) suppress = true; - const lm = (msg || '').toLowerCase(); - if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true; - - if (suppress) { - if (el.innerHTML) { - el.innerHTML = ''; - el.classList.remove('busy'); - } - return; - } - - el.innerHTML = 'Setup/Tagging: ' + msg + ' View progress'; - el.classList.add('busy'); - } else if (data && data.phase === 'done') { - el.innerHTML = ''; - el.classList.remove('busy'); - } else if (data && data.phase === 'error') { - el.innerHTML = 'Setup error.'; - setTimeout(function(){ - el.innerHTML = ''; - el.classList.remove('busy'); - }, 5000); - } else { - if (!el.innerHTML.trim()) el.innerHTML = ''; - el.classList.remove('busy'); - } - } - - function pollStatus(): void { - try { - fetch('/status/setup', { cache: 'no-store' }) - .then(function(r){ return r.json(); }) - .then(renderSetupStatus) - .catch(function(){ /* noop */ }); - } catch(_){} - } - - // Poll every 10 seconds to reduce server load (only for header indicator) - setInterval(pollStatus, 10000); - pollStatus(); // Initial poll - } - - /** - * Highlight active navigation link based on current path - * Matches exact or prefix paths, prioritizing longer matches - */ - function initActiveNavHighlighter(): void { - try { - const path = window.location.pathname || '/'; - const nav = document.getElementById('primary-nav'); - if (!nav) return; - - const links = nav.querySelectorAll('a'); - let best: HTMLAnchorElement | null = null; - let bestLen = -1; - - links.forEach(function(a){ - const href = a.getAttribute('href') || ''; - if (!href) return; - // Exact match or prefix match (ignoring trailing slash) - if (path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){ - if (href.length > bestLen){ - best = a as HTMLAnchorElement; - bestLen = href.length; - } - } - }); - - if (best) best.classList.add('active'); - } catch(_){} - } - - /** - * Initialize theme selector dropdown and persistence - * Handles localStorage, URL overrides, and system preference tracking - */ - function initThemeSelector(enableThemes: boolean, defaultTheme: string): void { - if (!enableThemes) return; - - try { - const sel = document.getElementById('theme-select') as HTMLSelectElement | null; - const resetBtn = document.getElementById('theme-reset'); - const root = document.documentElement; - const KEY = 'mtg:theme'; - const SERVER_DEFAULT = defaultTheme; - - function mapLight(v: string): string { - return v === 'light' ? 'light-blend' : v; - } - - function resolveSystem(): string { - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'dark' : 'light-blend'; - } - - function normalizeUiValue(v: string): string { - const x = (v || 'system').toLowerCase(); - if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light'; - return x; - } - - function apply(val: string): void { - let v = (val || 'system').toLowerCase(); - if (v === 'system') v = resolveSystem(); - v = mapLight(v); - root.setAttribute('data-theme', v); - } - - // Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly - const params = new URLSearchParams(window.location.search || ''); - const urlTheme = (params.get('theme') || '').toLowerCase(); - if (urlTheme) { - // Persist the UI value, not the mapped CSS token - localStorage.setItem(KEY, normalizeUiValue(urlTheme)); - // Clean the URL so reloads don't keep overriding - try { - const u = new URL(window.location.href); - u.searchParams.delete('theme'); - window.history.replaceState({}, document.title, u.toString()); - } catch(_){} - } - - // Determine initial selection: URL -> localStorage -> server default -> system - const stored = localStorage.getItem(KEY); - const initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system')); - apply(initial); - - if (sel) { - sel.value = normalizeUiValue(initial); - sel.addEventListener('change', function(){ - const v = sel.value || 'system'; - localStorage.setItem(KEY, v); - apply(v); - }); - } - - if (resetBtn) { - resetBtn.addEventListener('click', function(){ - try { localStorage.removeItem(KEY); } catch(_){} - const v = SERVER_DEFAULT || 'system'; - apply(v); - if (sel) sel.value = normalizeUiValue(v); - }); - } - - // React to system changes when set to system - if (window.matchMedia) { - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - mq.addEventListener && mq.addEventListener('change', function(){ - const cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system'); - if (cur === 'system') apply('system'); - }); - } - } catch(_){} - } - - /** - * Apply theme from environment variable when selector is disabled - * Resolves 'system' to OS preference - */ - function initThemeEnvOnly(enableThemes: boolean, defaultTheme: string): void { - if (enableThemes) return; // Only run when themes are disabled - - try { - const root = document.documentElement; - const SERVER_DEFAULT = defaultTheme; - - function resolveSystem(): string { - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'dark' : 'light-blend'; - } - - let v = (SERVER_DEFAULT || 'system').toLowerCase(); - if (v === 'system') v = resolveSystem(); - if (v === 'light') v = 'light-blend'; - root.setAttribute('data-theme', v); - - // Track OS changes when using system - if ((SERVER_DEFAULT || 'system').toLowerCase() === 'system' && window.matchMedia) { - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - mq.addEventListener && mq.addEventListener('change', function(){ - root.setAttribute('data-theme', resolveSystem()); - }); - } - } catch(_){} - } - - /** - * Register PWA service worker and handle updates - * Automatically reloads when new version is available - */ - function initServiceWorker(enablePwa: boolean, catalogHash: string): void { - if (!enablePwa) return; - - try { - if ('serviceWorker' in navigator) { - const ver = catalogHash || 'dev'; - const url = '/static/sw.js?v=' + encodeURIComponent(ver); - - navigator.serviceWorker.register(url).then(function(reg){ - (window as any).__pwaStatus = { registered: true, scope: reg.scope, version: ver }; - - // Listen for updates (new worker installing) - if (reg.waiting) { - reg.waiting.postMessage({ type: 'SKIP_WAITING' }); - } - - reg.addEventListener('updatefound', function(){ - try { - const nw = reg.installing; - if (!nw) return; - - nw.addEventListener('statechange', function(){ - if (nw.state === 'installed' && navigator.serviceWorker.controller) { - // New version available; reload silently for freshness - try { - sessionStorage.setItem('mtg:swUpdated', '1'); - } catch(_){} - window.location.reload(); - } - }); - } catch(_){} - }); - }).catch(function(){ - (window as any).__pwaStatus = { registered: false }; - }); - } - } catch(_){} - } - - /** - * Show toast after page reload - * Used when actions replace the whole document - */ - function initToastAfterReload(): void { - try { - const raw = sessionStorage.getItem('mtg:toastAfterReload'); - if (raw) { - sessionStorage.removeItem('mtg:toastAfterReload'); - const data = JSON.parse(raw); - if (data && data.msg) { - window.toast && window.toast(data.msg, data.type || ''); - } - } - } catch(_){} - } - - // Initialize all utilities on DOMContentLoaded - document.addEventListener('DOMContentLoaded', function(){ - initSetupStatusPoller(); - initActiveNavHighlighter(); - initToastAfterReload(); - - // Theme and PWA initialization require server-injected values - // These will be called from base.html inline scripts that pass the values - // window.__initThemeSelector, window.__initThemeEnvOnly, window.__initServiceWorker - }); - - // Expose functions globally for inline script calls (with server values) - (window as any).__initThemeSelector = initThemeSelector; - (window as any).__initThemeEnvOnly = initThemeEnvOnly; - (window as any).__initServiceWorker = initServiceWorker; -})(); diff --git a/code/web/static/ts/cardHover.ts b/code/web/static/ts/cardHover.ts deleted file mode 100644 index 15f0836..0000000 --- a/code/web/static/ts/cardHover.ts +++ /dev/null @@ -1,798 +0,0 @@ -/** - * Card Hover Panel System - * - * Unified hover/tap card preview panel with mobile support. - * Displays card images with metadata (role, tags, themes, overlaps). - * - * Features: - * - Desktop: Hover to show, follows mouse pointer - * - Mobile: Tap to show, centered modal with close button - * - Keyboard accessible with focus/escape handling - * - Image prefetch LRU cache for performance - * - DFC (double-faced card) flip support - * - Tag overlap highlighting - * - Curated-only and reasons toggles for preview modals - * - * NOTE: This module exposes functions globally on window for browser compatibility - */ - -interface PointerEventLike { - clientX: number; - clientY: number; -} - -// Expose globally for browser usage (CommonJS exports don't work in browser without bundler) -(window as any).__initHoverCardPanel = function initHoverCardPanel(): void { - // Global delegated curated-only & reasons controls (works after HTMX swaps and inline render) - function findPreviewRoot(el: Element): Element | null { - return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); - } - - function applyCuratedFor(root: Element): void { - const checkbox = root.querySelector('#curated-only-toggle') as HTMLInputElement | null; - const status = root.querySelector('#preview-status') as HTMLElement | null; - if (!checkbox) return; - - // Persist - try { - localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1' : '0'); - } catch (_) { } - - const curatedOnly = checkbox.checked; - let hidden = 0; - root.querySelectorAll('.card-sample').forEach((card) => { - const role = card.getAttribute('data-role'); - const isCurated = role === 'example' || role === 'curated_synergy' || role === 'synthetic'; - if (curatedOnly && !isCurated) { - (card as HTMLElement).style.display = 'none'; - hidden++; - } else { - (card as HTMLElement).style.display = ''; - } - }); - - if (status) status.textContent = curatedOnly ? (`Hid ${hidden} sampled cards`) : ''; - } - - function applyReasonsFor(root: Element): void { - const cb = root.querySelector('#reasons-toggle') as HTMLInputElement | null; - if (!cb) return; - - try { - localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1' : '0'); - } catch (_) { } - - const show = cb.checked; - root.querySelectorAll('[data-reasons-block]').forEach((el) => { - (el as HTMLElement).style.display = show ? '' : 'none'; - }); - } - - document.addEventListener('change', (e) => { - if (e.target && (e.target as HTMLElement).id === 'curated-only-toggle') { - const root = findPreviewRoot(e.target as HTMLElement); - if (root) applyCuratedFor(root); - } - }); - - document.addEventListener('change', (e) => { - if (e.target && (e.target as HTMLElement).id === 'reasons-toggle') { - const root = findPreviewRoot(e.target as HTMLElement); - if (root) applyReasonsFor(root); - } - }); - - document.addEventListener('htmx:afterSwap', (ev: any) => { - const frag = ev.target; - if (frag && frag.querySelector) { - if (frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag); - if (frag.querySelector('#reasons-toggle')) applyReasonsFor(frag); - } - }); - - document.addEventListener('DOMContentLoaded', () => { - document.querySelectorAll('.preview-modal-content').forEach((root) => { - // Restore persisted states before applying - try { - const cVal = localStorage.getItem('mtg:preview.curatedOnly'); - if (cVal !== null) { - const cb = root.querySelector('#curated-only-toggle') as HTMLInputElement | null; - if (cb) cb.checked = cVal === '1'; - } - const rVal = localStorage.getItem('mtg:preview.showReasons'); - if (rVal !== null) { - const rb = root.querySelector('#reasons-toggle') as HTMLInputElement | null; - if (rb) rb.checked = rVal === '1'; - } - } catch (_) { } - - if (root.querySelector('#curated-only-toggle')) applyCuratedFor(root); - if (root.querySelector('#reasons-toggle')) applyReasonsFor(root); - }); - }); - - function createPanel(): HTMLElement { - const panel = document.createElement('div'); - panel.id = 'hover-card-panel'; - panel.setAttribute('role', 'dialog'); - panel.setAttribute('aria-label', 'Card detail hover panel'); - panel.setAttribute('aria-hidden', 'true'); - panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:var(--text);font-size:14px;line-height:1.45;pointer-events:none;'; - panel.innerHTML = '' + - '
' + - '
 
' + - '
' + - '' + - '
' + - '
' + - '
' + - 'Card image' + - '
' + - '
' + - '
' + - '
 
' + - '
' + - '
' + - '
    ' + - '
    ' + - '
      ' + - '
      ' + - '
      ' + - '
      '; - document.body.appendChild(panel); - return panel; - } - - function ensurePanel(): HTMLElement { - let panel = document.getElementById('hover-card-panel'); - if (panel) return panel; - // Auto-create for direct theme pages where fragment-specific markup not injected - return createPanel(); - } - - function setup(): void { - const panel = ensurePanel(); - if (!panel || (panel as any).__hoverInit) return; - (panel as any).__hoverInit = true; - - const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement; - const nameEl = panel.querySelector('.hcp-name') as HTMLElement; - const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement; - const metaEl = panel.querySelector('.hcp-meta') as HTMLElement; - const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement; - const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement; - const bodyEl = panel.querySelector('.hcp-body') as HTMLElement; - const rightCol = panel.querySelector('.hcp-right') as HTMLElement; - - const coarseQuery = window.matchMedia('(pointer: coarse)'); - - function isMobileMode(): boolean { - return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; - } - - function refreshPosition(): void { - if (panel.style.display === 'block') { - move((window as any).__lastPointerEvent); - } - } - - if (coarseQuery) { - const handler = () => { refreshPosition(); }; - if (coarseQuery.addEventListener) { - coarseQuery.addEventListener('change', handler); - } else if ((coarseQuery as any).addListener) { - (coarseQuery as any).addListener(handler); - } - } - - window.addEventListener('resize', refreshPosition); - - const closeBtn = panel.querySelector('.hcp-close') as HTMLButtonElement; - if (closeBtn && !(closeBtn as any).__bound) { - (closeBtn as any).__bound = true; - closeBtn.addEventListener('click', (ev) => { - ev.preventDefault(); - hide(); - }); - } - - function positionPanel(evt: PointerEventLike): void { - if (isMobileMode()) { - panel.classList.add('mobile'); - panel.style.bottom = 'auto'; - panel.style.left = '50%'; - panel.style.top = '50%'; - panel.style.right = 'auto'; - panel.style.transform = 'translate(-50%, -50%)'; - panel.style.pointerEvents = 'auto'; - } else { - panel.classList.remove('mobile'); - panel.style.pointerEvents = 'none'; - panel.style.transform = 'none'; - const pad = 18; - let x = evt.clientX + pad, y = evt.clientY + pad; - const vw = window.innerWidth, vh = window.innerHeight; - const r = panel.getBoundingClientRect(); - if (x + r.width + 8 > vw) x = evt.clientX - r.width - pad; - if (y + r.height + 8 > vh) y = evt.clientY - r.height - pad; - if (x < 8) x = 8; - if (y < 8) y = 8; - panel.style.left = x + 'px'; - panel.style.top = y + 'px'; - panel.style.bottom = 'auto'; - panel.style.right = 'auto'; - } - } - - function move(evt?: PointerEventLike): void { - if (panel.style.display === 'none') return; - if (!evt) evt = (window as any).__lastPointerEvent; - if (!evt && lastCard) { - const rect = lastCard.getBoundingClientRect(); - evt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; - } - if (!evt) evt = { clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 }; - positionPanel(evt); - } - - // Lightweight image prefetch LRU cache (size 12) - const imgLRU: string[] = []; - function prefetch(src: string): void { - if (!src) return; - if (imgLRU.indexOf(src) === -1) { - imgLRU.push(src); - if (imgLRU.length > 12) imgLRU.shift(); - const im = new Image(); - im.src = src; - } - } - - const activationDelay = 120; // ms - let hoverTimer: number | null = null; - - function schedule(card: Element, evt: PointerEventLike): void { - if (hoverTimer !== null) clearTimeout(hoverTimer); - hoverTimer = window.setTimeout(() => { show(card, evt); }, activationDelay); - } - - function cancelSchedule(): void { - if (hoverTimer !== null) { - clearTimeout(hoverTimer); - hoverTimer = null; - } - } - - let lastCard: Element | null = null; - - function show(card: Element, evt?: PointerEventLike): void { - if (!card) return; - - // Prefer attributes on container, fallback to child (image) if missing - function attr(name: string): string { - return card.getAttribute(name) || - (card.querySelector(`[data-${name.slice(5)}]`)?.getAttribute(name)) || ''; - } - - let simpleSource: Element | null = null; - if (card.closest) { - simpleSource = card.closest('[data-hover-simple]'); - } - - const forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource; - const nm = attr('data-card-name') || attr('data-original-name') || 'Card'; - const rarity = (attr('data-rarity') || '').trim(); - const mana = (attr('data-mana') || '').trim(); - const role = (attr('data-role') || '').trim(); - let reasonsRaw = attr('data-reasons') || ''; - const tagsRaw = attr('data-tags') || ''; - const metadataTagsRaw = attr('data-metadata-tags') || ''; - const roleEl = panel.querySelector('.hcp-role') as HTMLElement; - // Check for flip button on card or its parent container (for elements in commander browser) - let hasFlip = !!card.querySelector('.dfc-toggle'); - if (!hasFlip && card.parentElement) { - hasFlip = !!card.parentElement.querySelector('.dfc-toggle'); - } - const tagListEl = panel.querySelector('.hcp-taglist') as HTMLElement; - const overlapsEl = panel.querySelector('.hcp-overlaps') as HTMLElement; - const overlapsAttr = attr('data-overlaps') || ''; - - function displayLabel(text: string): string { - if (!text) return ''; - let label = String(text); - label = label.replace(/[\u2022\-_]+/g, ' '); - label = label.replace(/\s+/g, ' ').trim(); - return label; - } - - function parseTagList(raw: string): string[] { - if (!raw) return []; - const trimmed = String(raw).trim(); - if (!trimmed) return []; - let result: string[] = []; - let candidate = trimmed; - - if (trimmed[0] === '[' && trimmed[trimmed.length - 1] === ']') { - candidate = trimmed.slice(1, -1); - } - - // Try JSON parsing after normalizing quotes - try { - let normalized = trimmed; - if (trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1) { - normalized = trimmed.replace(/'/g, '"'); - } - const parsed = JSON.parse(normalized); - if (Array.isArray(parsed)) { - result = parsed; - } - } catch (_) { /* fall back below */ } - - if (!result || !result.length) { - result = candidate.split(/\s*,\s*/); - } - - return result.map((t) => String(t || '').trim()).filter(Boolean); - } - - function deriveTagsFromReasons(reasons: string): string[] { - if (!reasons) return []; - const out: string[] = []; - - // Grab bracketed or quoted lists first - const m = reasons.match(/\[(.*?)\]/); - if (m && m[1]) out.push(...m[1].split(/\s*,\s*/)); - - // Common phrasing: "overlap(s) with A, B" or "by A, B" - const rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig; - let r; - while ((r = rx.exec(reasons))) { - out.push(...(r[2] || '').split(/\s*,\s*/)); - } - - const tagRx = /tag:\s*([^.;]+)/ig; - let tMatch; - while ((tMatch = tagRx.exec(reasons))) { - out.push(...(tMatch[1] || '').split(/\s*,\s*/)); - } - - return out.map((s) => s.trim()).filter(Boolean); - } - - let overlapArr: string[] = []; - if (overlapsAttr) { - const parsedOverlaps = parseTagList(overlapsAttr); - if (parsedOverlaps.length) { - overlapArr = parsedOverlaps; - } else { - overlapArr = [String(overlapsAttr).trim()]; - } - } - - const derivedFromReasons = deriveTagsFromReasons(reasonsRaw); - let allTags = parseTagList(tagsRaw); - - if (!allTags.length && derivedFromReasons.length) { - // Fallback: try to derive tags from reasons text when tags missing - allTags = derivedFromReasons.slice(); - } - - if ((!overlapArr || !overlapArr.length) && derivedFromReasons.length) { - const normalizedAll = (allTags || []).map((t) => ({ raw: t, key: t.toLowerCase() })); - const derivedKeys = new Set(derivedFromReasons.map((t) => t.toLowerCase())); - let intersect = normalizedAll.filter((entry) => derivedKeys.has(entry.key)).map((entry) => entry.raw); - - if (!intersect.length) { - intersect = derivedFromReasons.slice(); - } - - overlapArr = Array.from(new Set(intersect)); - } - - overlapArr = (overlapArr || []).map((t) => String(t || '').trim()).filter(Boolean); - allTags = (allTags || []).map((t) => String(t || '').trim()).filter(Boolean); - - nameEl.textContent = nm; - rarityEl.textContent = rarity; - - const roleLabel = displayLabel(role); - const roleKey = (roleLabel || role || '').toLowerCase(); - const isCommanderRole = roleKey === 'commander'; - - metaEl.textContent = [ - roleLabel ? ('Role: ' + roleLabel) : '', - mana ? ('Mana: ' + mana) : '' - ].filter(Boolean).join(' • '); - - reasonsList.innerHTML = ''; - reasonsRaw.split(';').map((r) => r.trim()).filter(Boolean).forEach((r) => { - const li = document.createElement('li'); - li.style.margin = '2px 0'; - li.textContent = r; - reasonsList.appendChild(li); - }); - - // Build inline tag list with overlap highlighting - if (tagListEl) { - tagListEl.innerHTML = ''; - tagListEl.style.display = 'none'; - tagListEl.setAttribute('aria-hidden', 'true'); - } - - if (overlapsEl) { - if (overlapArr && overlapArr.length) { - overlapsEl.innerHTML = overlapArr.map((o) => { - const label = displayLabel(o); - return `${label}`; - }).join(''); - } else { - overlapsEl.innerHTML = ''; - } - } - - if (tagsEl) { - if (isCommanderRole) { - tagsEl.textContent = ''; - tagsEl.style.display = 'none'; - } else { - let tagText = allTags.map(displayLabel).join(', '); - - // M5: Temporarily append metadata tags for debugging - if (metadataTagsRaw && metadataTagsRaw.trim()) { - const metaTags = metadataTagsRaw.split(',').map((t) => t.trim()).filter(Boolean); - if (metaTags.length) { - const metaText = metaTags.map(displayLabel).join(', '); - tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText); - } - } - - tagsEl.textContent = tagText; - tagsEl.style.display = tagText ? '' : 'none'; - } - } - - if (roleEl) { - roleEl.textContent = roleLabel || ''; - roleEl.style.display = roleLabel ? 'inline-block' : 'none'; - } - - panel.classList.toggle('is-payoff', role === 'payoff'); - panel.classList.toggle('is-commander', isCommanderRole); - - const hasDetails = !forceSimple && ( - !!roleLabel || !!mana || !!rarity || - (reasonsRaw && reasonsRaw.trim()) || - (overlapArr && overlapArr.length) || - (allTags && allTags.length) - ); - - panel.classList.toggle('hcp-simple', !hasDetails); - - if (rightCol) { - rightCol.style.display = hasDetails ? 'flex' : 'none'; - } - - if (bodyEl) { - if (!hasDetails) { - bodyEl.style.display = 'flex'; - bodyEl.style.flexDirection = 'column'; - bodyEl.style.alignItems = 'center'; - bodyEl.style.gap = '12px'; - } else { - bodyEl.style.display = ''; - bodyEl.style.flexDirection = ''; - bodyEl.style.alignItems = ''; - bodyEl.style.gap = ''; - } - } - - const rawName = nm || ''; - let hasBack = rawName.indexOf('//') > -1 || (attr('data-original-name') || '').indexOf('//') > -1; - if (hasBack) hasFlip = true; - - const storageKey = 'mtg:face:' + rawName.toLowerCase(); - const storedFace = (() => { - try { - return localStorage.getItem(storageKey); - } catch (_) { - return null; - } - })(); - - if (storedFace === 'front' || storedFace === 'back') { - card.setAttribute('data-current-face', storedFace); - } - - const chosenFace = card.getAttribute('data-current-face') || 'front'; - lastCard = card; - - function renderHoverFace(face: string): void { - const desiredVersion = 'normal'; - const currentKey = nm + ':' + face + ':' + desiredVersion; - const prevFace = imgEl.getAttribute('data-face'); - const faceChanged = prevFace && prevFace !== face; - - if (imgEl.getAttribute('data-current') !== currentKey) { - // For DFC cards, extract the specific face name for cache lookup - let faceName = nm; - const isDFC = nm.indexOf('//') > -1; - if (isDFC) { - const faces = nm.split('//'); - faceName = (face === 'back') ? faces[1].trim() : faces[0].trim(); - } - - let src = '/api/images/' + desiredVersion + '/' + encodeURIComponent(faceName); - if (isDFC && face === 'back') { - src += '?face=back'; - } - - if (faceChanged) imgEl.style.opacity = '0'; - prefetch(src); - imgEl.src = src; - imgEl.setAttribute('data-current', currentKey); - imgEl.setAttribute('data-face', face); - - imgEl.addEventListener('load', function onLoad() { - imgEl.removeEventListener('load', onLoad); - requestAnimationFrame(() => { imgEl.style.opacity = '1'; }); - }); - } - - if (!(imgEl as any).__errBound) { - (imgEl as any).__errBound = true; - imgEl.addEventListener('error', () => { - const cur = imgEl.getAttribute('src') || ''; - // Fallback from normal to small if image fails to load - if (cur.indexOf('/api/images/normal/') > -1) { - imgEl.src = cur.replace('/api/images/normal/', '/api/images/small/'); - } - }); - } - } - - renderHoverFace(chosenFace); - - // Add DFC flip button to popup panel ONLY on mobile - const checkFlip = (window as any).__dfcHasTwoFaces || (() => false); - if (hasFlip && imgEl && checkFlip(card) && isMobileMode()) { - const imgWrap = imgEl.parentElement; - if (imgWrap && !imgWrap.querySelector('.dfc-toggle')) { - const flipBtn = document.createElement('button'); - flipBtn.type = 'button'; - flipBtn.className = 'dfc-toggle'; - flipBtn.setAttribute('aria-pressed', 'false'); - flipBtn.setAttribute('tabindex', '0'); - flipBtn.innerHTML = ''; - - flipBtn.addEventListener('click', (ev) => { - ev.stopPropagation(); - if ((window as any).__dfcFlipCard && lastCard) { - // For image elements, find the parent container with the flip button - let cardToFlip = lastCard; - if (lastCard.tagName === 'IMG' && lastCard.parentElement) { - const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle'); - if (parentWithButton) { - cardToFlip = lastCard.parentElement; - } - } - (window as any).__dfcFlipCard(cardToFlip); - } - }); - - flipBtn.addEventListener('keydown', (ev) => { - if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'f' || ev.key === 'F') { - ev.preventDefault(); - if ((window as any).__dfcFlipCard && lastCard) { - // For image elements, find the parent container with the flip button - let cardToFlip = lastCard; - if (lastCard.tagName === 'IMG' && lastCard.parentElement) { - const parentWithButton = lastCard.parentElement.querySelector('.dfc-toggle'); - if (parentWithButton) { - cardToFlip = lastCard.parentElement; - } - } - (window as any).__dfcFlipCard(cardToFlip); - } - } - }); - - imgWrap.classList.add('dfc-host'); - imgWrap.appendChild(flipBtn); - } - } - - (window as any).__dfcNotifyHover = hasFlip ? (cardRef: Element, face: string) => { - if (cardRef === lastCard) renderHoverFace(face); - } : null; - - if (evt) (window as any).__lastPointerEvent = evt; - - if (isMobileMode()) { - panel.classList.add('mobile'); - panel.style.pointerEvents = 'auto'; - panel.style.maxHeight = '80vh'; - } else { - panel.classList.remove('mobile'); - panel.style.pointerEvents = 'none'; - panel.style.maxHeight = ''; - panel.style.bottom = 'auto'; - } - - panel.style.display = 'block'; - panel.setAttribute('aria-hidden', 'false'); - move(evt); - } - - function hide(): void { - // Blur any focused element inside panel to avoid ARIA focus warning - if (panel.contains(document.activeElement)) { - (document.activeElement as HTMLElement)?.blur(); - } - panel.style.display = 'none'; - panel.setAttribute('aria-hidden', 'true'); - cancelSchedule(); - panel.classList.remove('mobile'); - panel.style.pointerEvents = 'none'; - panel.style.transform = 'none'; - panel.style.bottom = 'auto'; - panel.style.maxHeight = ''; - (window as any).__dfcNotifyHover = null; - } - - document.addEventListener('mousemove', move); - - function getCardFromEl(el: EventTarget | null): Element | null { - if (!el || !(el instanceof Element)) return null; - - if (el.closest) { - const altBtn = el.closest('.alts button[data-card-name]'); - if (altBtn) return altBtn; - } - - // If inside flip button - const btn = el.closest && el.closest('.dfc-toggle'); - if (btn) { - return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card'); - } - - // For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile) - if (el.closest && el.closest('.card-tile')) { - const imgBtn = el.closest('.img-btn'); - if (imgBtn) return imgBtn.closest('.card-tile'); - - // If directly on the image - if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))) { - return el.closest('.card-tile'); - } - - // Don't trigger on other parts of the tile (buttons, text, etc.) - return null; - } - - // Recognized container classes - const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card'); - if (container) return container; - - // Image-based detection (any card image carrying data-card-name) - if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) { - const up = el.closest && el.closest('.stack-card'); - return up || el; - } - - // List view spans (deck summary list mode, finished deck list, etc.) - if (el.hasAttribute && el.hasAttribute('data-card-name')) return el; - - return null; - } - - document.addEventListener('pointermove', (e) => { (window as any).__lastPointerEvent = e; }); - - document.addEventListener('pointerover', (e) => { - if (isMobileMode()) return; - const card = getCardFromEl(e.target); - if (!card) return; - - // If hovering flip button, refresh immediately (no activation delay) - if (e.target instanceof Element && e.target.closest && e.target.closest('.dfc-toggle')) { - show(card, e); - return; - } - - if (lastCard === card && panel.style.display === 'block') return; - schedule(card, e); - }); - - document.addEventListener('pointerout', (e) => { - if (isMobileMode()) return; - const relCard = getCardFromEl(e.relatedTarget); - if (relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button) - if (!panel.contains(e.relatedTarget as Node)) { - cancelSchedule(); - if (!relCard) hide(); - } - }); - - document.addEventListener('click', (e) => { - if (!isMobileMode()) return; - if (panel.contains(e.target as Node)) return; - if (e.target instanceof Element && e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return; - if (e.target instanceof Element && e.target.closest && e.target.closest('button, input, select, textarea, a')) return; - - const card = getCardFromEl(e.target); - if (card) { - cancelSchedule(); - const rect = card.getBoundingClientRect(); - const syntheticEvt = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; - show(card, syntheticEvt); - } else if (panel.style.display === 'block') { - hide(); - } - }); - - // Expose show function for external refresh (flip updates) - (window as any).__hoverShowCard = (card: Element) => { - const ev = (window as any).__lastPointerEvent || { - clientX: card.getBoundingClientRect().left + 12, - clientY: card.getBoundingClientRect().top + 12 - }; - show(card, ev); - }; - - (window as any).hoverShowByName = (name: string) => { - try { - const el = document.querySelector('[data-card-name="' + CSS.escape(name) + '"]'); - if (el) { - (window as any).__hoverShowCard( - el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el - ); - } - } catch (_) { } - }; - - // Keyboard accessibility & focus traversal - document.addEventListener('focusin', (e) => { - const card = e.target instanceof Element && e.target.closest && e.target.closest('.card-sample, .commander-cell, .commander-thumb'); - if (card) { - show(card, { - clientX: card.getBoundingClientRect().left + 10, - clientY: card.getBoundingClientRect().top + 10 - }); - } - }); - - document.addEventListener('focusout', (e) => { - const next = e.relatedTarget instanceof Element && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell, .commander-thumb'); - if (!next) hide(); - }); - - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') hide(); - }); - - // Compact mode event listener - document.addEventListener('mtg:hoverCompactToggle', () => { - panel.classList.toggle('compact-img', !!(window as any).__hoverCompactMode); - }); - } - - document.addEventListener('htmx:afterSwap', setup); - document.addEventListener('DOMContentLoaded', setup); - setup(); -}; - -// Global compact mode toggle function -(window as any).__initHoverCompactMode = function initHoverCompactMode(): void { - (window as any).toggleHoverCompactMode = (state?: boolean) => { - if (typeof state === 'boolean') { - (window as any).__hoverCompactMode = state; - } else { - (window as any).__hoverCompactMode = !(window as any).__hoverCompactMode; - } - document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle')); - }; -}; - -// Auto-initialize on load -if (typeof window !== 'undefined') { - (window as any).__initHoverCardPanel(); - (window as any).__initHoverCompactMode(); -} diff --git a/code/web/static/ts/cardImages.ts b/code/web/static/ts/cardImages.ts deleted file mode 100644 index b7f8455..0000000 --- a/code/web/static/ts/cardImages.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Card Image URL Builders & Retry Logic - * - * Utilities for constructing card image URLs and handling image load failures - * with automatic fallback to different image sizes. - * - * Features: - * - Build card image URLs with face (front/back) support - * - Build Scryfall image URLs with version control - * - Automatic retry on image load failure (different sizes) - * - Cache-busting support for failed loads - * - HTMX swap integration for dynamic content - * - * NOTE: This module exposes functions globally on window for browser compatibility - */ - -interface ImageRetryState { - vi: number; // Current version index - nocache: number; // Cache-busting flag (0 or 1) - versions: string[]; // Image versions to try ['small', 'normal', 'large'] -} - -const IMG_FLAG = '__cardImgRetry'; - -/** - * Normalize card name by removing synergy suffixes - */ -function normalizeCardName(raw: string): string { - if (!raw) return raw; - const normalize = (window as any).__normalizeCardName || ((name: string) => { - if (!name) return name; - const m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(name); - if (m) return m[1].trim(); - return name; - }); - return normalize(raw); -} - -/** - * Build card image URL with face support (front/back) - * @param name - Card name - * @param version - Image version ('small', 'normal', 'large') - * @param nocache - Add cache-busting timestamp - * @param face - Card face ('front' or 'back') - */ -function buildCardUrl(name: string, version?: string, nocache?: boolean, face?: string): string { - name = normalizeCardName(name); - const q = encodeURIComponent(name || ''); - let url = '/api/images/' + (version || 'normal') + '/' + q; - if (face === 'back') url += '?face=back'; - if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now(); - return url; -} - -/** - * Build Scryfall image URL - * @param name - Card name - * @param version - Image version ('small', 'normal', 'large') - * @param nocache - Add cache-busting timestamp - */ -function buildScryfallImageUrl(name: string, version?: string, nocache?: boolean): string { - name = normalizeCardName(name); - const q = encodeURIComponent(name || ''); - let url = '/api/images/' + (version || 'normal') + '/' + q; - if (nocache) url += '?t=' + Date.now(); - return url; -} - -/** - * Bind error handler to an image element for automatic retry with fallback versions - * @param img - Image element with data-card-name attribute - * @param versions - Array of image versions to try in order - */ -function bindCardImageRetry(img: HTMLImageElement, versions?: string[]): void { - try { - if (!img || (img as any)[IMG_FLAG]) return; - const name = img.getAttribute('data-card-name') || ''; - if (!name) return; - - // Default versions: normal -> large - const versionList = versions && versions.length ? versions.slice() : ['normal', 'large']; - (img as any)[IMG_FLAG] = { - vi: 0, - nocache: 0, - versions: versionList - } as ImageRetryState; - - img.addEventListener('error', function() { - const st = (img as any)[IMG_FLAG] as ImageRetryState; - if (!st) return; - - // Try next version - if (st.vi < st.versions.length - 1) { - st.vi += 1; - img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); - } - // Try cache-busting current version - else if (!st.nocache) { - st.nocache = 1; - img.src = buildScryfallImageUrl(name, st.versions[st.vi], true); - } - }); - - // If initial load already failed before binding, try next immediately - if (img.complete && img.naturalWidth === 0) { - const st = (img as any)[IMG_FLAG] as ImageRetryState; - const current = img.src || ''; - const first = buildScryfallImageUrl(name, st.versions[0], false); - - // Check if current src matches first version - if (current.indexOf(encodeURIComponent(name)) !== -1 && - current.indexOf('version=' + st.versions[0]) !== -1) { - st.vi = Math.min(1, st.versions.length - 1); - img.src = buildScryfallImageUrl(name, st.versions[st.vi], false); - } else { - // Re-trigger current request (may succeed if transient error) - img.src = current; - } - } - } catch (_) { - // Silently fail - image retry is a nice-to-have feature - } -} - -/** - * Bind retry handlers to all card images in the document - */ -function bindAllCardImageRetries(): void { - document.querySelectorAll('img[data-card-name]').forEach((img) => { - // Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks - const versions = (img.classList && img.classList.contains('card-thumb')) - ? ['small', 'normal', 'large'] - : ['normal', 'large']; - bindCardImageRetry(img as HTMLImageElement, versions); - }); -} - -// Expose globally for browser usage -(window as any).__initCardImages = function initCardImages(): void { - // Expose retry binding globally for dynamic content - (window as any).bindAllCardImageRetries = bindAllCardImageRetries; - - // Initial bind - bindAllCardImageRetries(); - - // Re-bind after HTMX swaps - document.addEventListener('htmx:afterSwap', bindAllCardImageRetries); -}; - -// Auto-initialize on load -if (typeof window !== 'undefined') { - (window as any).__initCardImages(); -} diff --git a/code/web/static/ts/components.ts b/code/web/static/ts/components.ts deleted file mode 100644 index b9493b2..0000000 --- a/code/web/static/ts/components.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * M3 Component Library - TypeScript Utilities - * - * Core functions for interactive components: - * - Card flip button (dual-faced cards) - * - Collapsible panels - * - Card popups - * - Modal management - * - * Migrated from components.js with TypeScript types - */ - -// ============================================ -// TYPE DEFINITIONS -// ============================================ - -interface CardPopupOptions { - tags?: string[]; - highlightTags?: string[]; - role?: string; - layout?: string; -} - -// ============================================ -// CARD FLIP FUNCTIONALITY -// ============================================ - -/** - * Flip a dual-faced card image between front and back faces - * @param button - The flip button element - */ -function flipCard(button: HTMLElement): void { - const container = button.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null; - if (!container) return; - - const img = container.querySelector('img') as HTMLImageElement | null; - if (!img) return; - - const cardName = img.dataset.cardName; - if (!cardName) return; - - const faces = cardName.split(' // '); - if (faces.length < 2) return; - - // Determine current face (default to 0 = front) - const currentFace = parseInt(img.dataset.currentFace || '0', 10); - const nextFace = currentFace === 0 ? 1 : 0; - const faceName = faces[nextFace]; - - // Determine image version based on container - const isLarge = container.classList.contains('card-thumb-large') || - container.classList.contains('card-popup-image'); - const version = isLarge ? 'normal' : 'small'; - - // Update image source - img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(faceName)}&format=image&version=${version}`; - img.alt = `${faceName} image`; - img.dataset.currentFace = nextFace.toString(); - - // Update button aria-label - const otherFace = faces[currentFace]; - button.setAttribute('aria-label', `Flip to ${otherFace}`); -} - -/** - * Reset all card images to show front face - * Useful when navigating between pages or clearing selections - */ -function resetCardFaces(): void { - document.querySelectorAll('img[data-card-name][data-current-face]').forEach(img => { - const cardName = img.dataset.cardName; - if (!cardName) return; - - const faces = cardName.split(' // '); - if (faces.length > 1) { - const frontFace = faces[0]; - const container = img.closest('.card-thumb-container, .card-popup-image') as HTMLElement | null; - const isLarge = container && (container.classList.contains('card-thumb-large') || - container.classList.contains('card-popup-image')); - const version = isLarge ? 'normal' : 'small'; - - img.src = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(frontFace)}&format=image&version=${version}`; - img.alt = `${frontFace} image`; - img.dataset.currentFace = '0'; - } - }); -} - -// ============================================ -// COLLAPSIBLE PANEL FUNCTIONALITY -// ============================================ - -/** - * Toggle a collapsible panel's expanded/collapsed state - * @param panelId - The ID of the panel element - */ -function togglePanel(panelId: string): void { - const panel = document.getElementById(panelId); - if (!panel) return; - - const button = panel.querySelector('.panel-toggle') as HTMLElement | null; - const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; - if (!button || !content) return; - - const isExpanded = button.getAttribute('aria-expanded') === 'true'; - - // Toggle state - button.setAttribute('aria-expanded', (!isExpanded).toString()); - content.style.display = isExpanded ? 'none' : 'block'; - - // Toggle classes - panel.classList.toggle('panel-expanded', !isExpanded); - panel.classList.toggle('panel-collapsed', isExpanded); -} - -/** - * Expand a collapsible panel - * @param panelId - The ID of the panel element - */ -function expandPanel(panelId: string): void { - const panel = document.getElementById(panelId); - if (!panel) return; - - const button = panel.querySelector('.panel-toggle') as HTMLElement | null; - const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; - if (!button || !content) return; - - button.setAttribute('aria-expanded', 'true'); - content.style.display = 'block'; - panel.classList.add('panel-expanded'); - panel.classList.remove('panel-collapsed'); -} - -/** - * Collapse a collapsible panel - * @param panelId - The ID of the panel element - */ -function collapsePanel(panelId: string): void { - const panel = document.getElementById(panelId); - if (!panel) return; - - const button = panel.querySelector('.panel-toggle') as HTMLElement | null; - const content = panel.querySelector('.panel-collapse-content') as HTMLElement | null; - if (!button || !content) return; - - button.setAttribute('aria-expanded', 'false'); - content.style.display = 'none'; - panel.classList.add('panel-collapsed'); - panel.classList.remove('panel-expanded'); -} - -// ============================================ -// MODAL MANAGEMENT -// ============================================ - -/** - * Open a modal by ID - * @param modalId - The ID of the modal element - */ -function openModal(modalId: string): void { - const modal = document.getElementById(modalId); - if (!modal) return; - - (modal as HTMLElement).style.display = 'flex'; - document.body.style.overflow = 'hidden'; - - // Focus first focusable element in modal - const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); - if (focusable) { - setTimeout(() => focusable.focus(), 100); - } -} - -/** - * Close a modal by ID or element - * @param modalOrId - Modal element or ID - */ -function closeModal(modalOrId: string | HTMLElement): void { - const modal = typeof modalOrId === 'string' - ? document.getElementById(modalOrId) - : modalOrId; - - if (!modal) return; - - modal.remove(); - - // Restore body scroll if no other modals are open - if (!document.querySelector('.modal')) { - document.body.style.overflow = ''; - } -} - -/** - * Close all open modals - */ -function closeAllModals(): void { - document.querySelectorAll('.modal').forEach(modal => modal.remove()); - document.body.style.overflow = ''; -} - -// ============================================ -// CARD POPUP FUNCTIONALITY -// ============================================ - -/** - * Show card details popup on hover or tap - * @param cardName - The card name - * @param options - Popup options - */ -function showCardPopup(cardName: string, options: CardPopupOptions = {}): void { - // Remove any existing popup - closeCardPopup(); - - const { - tags = [], - highlightTags = [], - role = '', - layout = 'normal' - } = options; - - const isDFC = ['modal_dfc', 'transform', 'double_faced_token', 'reversible_card'].includes(layout); - const baseName = cardName.split(' // ')[0]; - - // Create popup HTML - const popup = document.createElement('div'); - popup.className = 'card-popup'; - popup.setAttribute('role', 'dialog'); - popup.setAttribute('aria-label', `${cardName} details`); - - let tagsHTML = ''; - if (tags.length > 0) { - tagsHTML = '
      '; - tags.forEach(tag => { - const isHighlight = highlightTags.includes(tag); - tagsHTML += `${tag}`; - }); - tagsHTML += '
      '; - } - - let roleHTML = ''; - if (role) { - roleHTML = `
      Role: ${role}
      `; - } - - let flipButtonHTML = ''; - if (isDFC) { - flipButtonHTML = ` - - `; - } - - popup.innerHTML = ` -
      -
      -
      - ${cardName} image - ${flipButtonHTML} -
      -
      -

      ${cardName}

      - ${roleHTML} - ${tagsHTML} -
      - -
      - `; - - document.body.appendChild(popup); - document.body.style.overflow = 'hidden'; - - // Focus close button - const closeBtn = popup.querySelector('.card-popup-close'); - if (closeBtn) { - setTimeout(() => closeBtn.focus(), 100); - } -} - -/** - * Close card popup - * @param element - Element to search from (optional) - */ -function closeCardPopup(element?: HTMLElement): void { - const popup = element - ? element.closest('.card-popup') - : document.querySelector('.card-popup'); - - if (popup) { - popup.remove(); - - // Restore body scroll if no modals are open - if (!document.querySelector('.modal')) { - document.body.style.overflow = ''; - } - } -} - -/** - * Setup card thumbnail hover/tap events - * Call this after dynamically adding card thumbnails to the DOM - */ -function setupCardPopups(): void { - document.querySelectorAll('.card-thumb-container[data-card-name]').forEach(container => { - const img = container.querySelector('.card-thumb'); - if (!img) return; - - const cardName = container.dataset.cardName || img.dataset.cardName; - if (!cardName) return; - - // Desktop: hover - container.addEventListener('mouseenter', function(e: MouseEvent) { - if (window.innerWidth > 768) { - const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean); - const role = img.dataset.role || ''; - const layout = img.dataset.layout || 'normal'; - - showCardPopup(cardName, { tags, highlightTags: [], role, layout }); - } - }); - - // Mobile: tap - container.addEventListener('click', function(e: MouseEvent) { - if (window.innerWidth <= 768) { - e.preventDefault(); - - const tags = (img.dataset.tags || '').split(',').map(t => t.trim()).filter(Boolean); - const role = img.dataset.role || ''; - const layout = img.dataset.layout || 'normal'; - - showCardPopup(cardName, { tags, highlightTags: [], role, layout }); - } - }); - }); -} - -// ============================================ -// INITIALIZATION -// ============================================ - -// Setup event listeners when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - // Setup card popups on initial load - setupCardPopups(); - - // Close modals/popups on Escape key - document.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') { - closeCardPopup(); - - // Close topmost modal only - const modals = document.querySelectorAll('.modal'); - if (modals.length > 0) { - closeModal(modals[modals.length - 1] as HTMLElement); - } - } - }); - }); -} else { - // DOM already loaded - setupCardPopups(); -} - -// Make functions globally available for inline onclick handlers -(window as any).flipCard = flipCard; -(window as any).resetCardFaces = resetCardFaces; -(window as any).togglePanel = togglePanel; -(window as any).expandPanel = expandPanel; -(window as any).collapsePanel = collapsePanel; -(window as any).openModal = openModal; -(window as any).closeModal = closeModal; -(window as any).closeAllModals = closeAllModals; -(window as any).showCardPopup = showCardPopup; -(window as any).closeCardPopup = closeCardPopup; -(window as any).setupCardPopups = setupCardPopups; diff --git a/code/web/static/ts/types.ts b/code/web/static/ts/types.ts deleted file mode 100644 index bb7fb65..0000000 --- a/code/web/static/ts/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -/* Shared TypeScript type definitions for MTG Deckbuilder web app */ - -// Toast system types -export interface ToastOptions { - duration?: number; -} - -// State management types -export interface StateManager { - get(key: string, def?: any): any; - set(key: string, val: any): void; - inHash(obj: Record): void; - readHash(): URLSearchParams; -} - -// Telemetry types -export interface TelemetryManager { - send(eventName: string, data?: Record): void; -} - -// Skeleton system types -export interface SkeletonManager { - show(context?: HTMLElement | Document): void; - hide(context?: HTMLElement | Document): void; -} - -// Card popup types (from components.ts) -export interface CardPopupOptions { - tags?: string[]; - highlightTags?: string[]; - role?: string; - layout?: string; - showActions?: boolean; -} - -// HTMX event detail types -export interface HtmxResponseErrorDetail { - xhr?: XMLHttpRequest; - path?: string; - target?: HTMLElement; -} - -export interface HtmxEventDetail { - target?: HTMLElement; - elt?: HTMLElement; - path?: string; - xhr?: XMLHttpRequest; -} - -// HTMX cache interface -export interface HtmxCache { - get(key: string): any; - set(key: string, html: string, ttl?: number, meta?: any): void; - apply(elt: any, detail: any, entry: any): void; - buildKey(detail: any, elt: any): string; - ttlFor(elt: any): number; - prefetch(url: string, opts?: any): void; -} - -// Global window extensions -declare global { - interface Window { - __mtgState: StateManager; - toast: (msg: string | HTMLElement, type?: string, opts?: ToastOptions) => HTMLElement; - toastHTML: (html: string, type?: string, opts?: ToastOptions) => HTMLElement; - appTelemetry: TelemetryManager; - skeletons: SkeletonManager; - __telemetryEndpoint?: string; - showCardPopup?: (cardName: string, options?: CardPopupOptions) => void; - dismissCardPopup?: () => void; - flipCard?: (button: HTMLElement) => void; - htmxCache?: HtmxCache; - htmx?: any; // HTMX library - use any for external library - initHtmxDebounce?: () => void; - scrollCardIntoView?: (card: HTMLElement) => void; - __virtGlobal?: any; - __virtHotkeyBound?: boolean; - } - - interface CustomEvent { - readonly detail: T; - } - - // HTMX custom events - interface DocumentEventMap { - 'htmx:responseError': CustomEvent; - 'htmx:sendError': CustomEvent; - 'htmx:afterSwap': CustomEvent; - 'htmx:beforeRequest': CustomEvent; - 'htmx:afterSettle': CustomEvent; - 'htmx:afterRequest': CustomEvent; - } - - interface HTMLElement { - __hxCacheKey?: string; - __hxCacheTTL?: number; - } - - interface Element { - __hxPrefetched?: boolean; - } -} - -// Empty export to make this a module file -export {}; diff --git a/code/web/templates/base.html b/code/web/templates/base.html index c17b53f..b8a0d88 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -6,6 +6,10 @@ MTG Deckbuilder + - - @@ -57,16 +50,22 @@ {% endif %} - +
      - +
      + + + + {# Theme controls moved to sidebar #}
      -
      @@ -86,7 +85,6 @@ Build from JSON {% if show_setup %}Setup/Tag{% endif %} Owned Library - All Cards {% if show_commanders %}Commanders{% endif %} Finished Decks Themes @@ -119,7 +117,115 @@ Scryfall. This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast. - + - -
      -
      -
      -

      Similar Cards

      -

      - Similarities based on shared themes and tags. Cards may differ in power level, cost, or function. -

      -
      - {% if similar_cards and similar_cards|length > 0 %} - - {% endif %} -
      - - {% if similar_cards and similar_cards|length > 0 %} -
      - {% for card in similar_cards %} -
      - -
      - {{ card.name }} - {# Fallback for missing images #} -
      - {{ card.name }} -
      -
      - - -
      -
      {{ card.name }}
      - - - {% if card.themeTags and card.themeTags|length > 0 %} - {% set main_card_tags = main_card_tags|default([]) %} - {% set matching_tags = [] %} - {% for tag in card.themeTags %} - {% if tag in main_card_tags %} - {% set _ = matching_tags.append(tag) %} - {% endif %} - {% endfor %} - {% if matching_tags|length > 0 %} -
      - ✓ {{ matching_tags|length }} matching theme{{ 's' if matching_tags|length > 1 else '' }} -
      - {% endif %} - {% endif %} - - - {% if card.edhrecRank %} -
      - EDHREC Rank: #{{ card.edhrecRank }} -
      - {% endif %} - - - {% if card.themeTags and card.themeTags|length > 0 %} -
      - {% set main_card_tags = main_card_tags|default([]) %} - {% for tag in card.themeTags %} - {% set is_overlap = tag in main_card_tags %} - - {{ tag }} - - {% endfor %} -
      - {% endif %} -
      - - - - Card Details - - - - -
      - {% endfor %} -
      - {% else %} -
      -
      🔍
      -
      No similar cards found
      -

      - This card has unique theme tags or no cards share similar characteristics. -

      -
      - {% endif %} -
      diff --git a/code/web/templates/browse/cards/detail.html b/code/web/templates/browse/cards/detail.html deleted file mode 100644 index 35a9f46..0000000 --- a/code/web/templates/browse/cards/detail.html +++ /dev/null @@ -1,273 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ card.name }} - Card Details{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
      - - - - - - Back to Card Browser - - - -
      - -
      - {{ card.name }} - {# Fallback for missing images #} -
      - {{ card.name }} -
      -
      - - -
      -

      {{ card.name }}

      - -
      {{ card.type }}
      - - - {% if card.colors %} -
      - {% for color in card.colors %} - {{ color }} - {% endfor %} -
      - {% endif %} - - -
      - {% if card.manaValue is not none %} -
      - Mana Value - {{ card.manaValue }} -
      - {% endif %} - - {% if card.power is not none and card.power != 'NaN' and card.power|string != 'nan' %} -
      - Power / Toughness - {{ card.power }} / {{ card.toughness }} -
      - {% endif %} - - {% if card.edhrecRank %} -
      - EDHREC Rank - #{{ card.edhrecRank }} -
      - {% endif %} - - {% if card.rarity %} -
      - Rarity - {{ card.rarity | capitalize }} -
      - {% endif %} -
      - - - {% if card.text %} -
      {{ card.text | replace('\\n', '\n') }}
      - {% endif %} - - - {% if card.themeTags_parsed and card.themeTags_parsed|length > 0 %} -
      - {% for tag in card.themeTags_parsed %} - {{ tag }} - {% endfor %} -
      - {% endif %} -
      -
      - - -
      - {% include "browse/cards/_similar_cards.html" %} -
      -
      -{% endblock %} diff --git a/code/web/templates/browse/cards/index.html b/code/web/templates/browse/cards/index.html deleted file mode 100644 index 1a4c31a..0000000 --- a/code/web/templates/browse/cards/index.html +++ /dev/null @@ -1,959 +0,0 @@ -{% extends "base.html" %} -{% block content %} - - -
      -

      Card Browser

      -

      Browse all {{ total_cards }} cards with filters and search.

      - - {# Error message #} - {% if error %} -
      - {{ error }} -
      - {% endif %} - - {# Filters Panel #} -
      - {# Keyboard shortcuts help button (desktop only) #} - - - {# Shortcuts help tooltip #} - - - {# Search bar #} -
      -
      -
      - -
      - -
      -
      - {% if search %} - - {% endif %} - -
      -
      -
      - - {# Filter controls #} -
      -
      - {# Multi-select theme filter #} - -
      - {# Selected themes as chips #} -
      - {% if themes %} - {% for t in themes %} - - {{ t }} - - - {% endfor %} - {% endif %} -
      - - {# Autocomplete input #} -
      - -
      -
      -
      -
      - -
      - {# Color filter #} - {% if all_colors %} - - - {% endif %} - - {# Type filter #} - {% if all_types %} - - - {% endif %} - - {# Rarity filter #} - {% if all_rarities %} - - - {% endif %} - - {# Sort dropdown #} - - - - - -
      - - {# Advanced filters row #} -
      - {# CMC range filter #} - -
      - - - -
      - - {# Power range filter #} - -
      - - - -
      - - {# Toughness range filter #} - -
      - - - -
      -
      -
      -
      - - {# Results info bar with page indicator #} -
      - - {% if filtered_count is defined and filtered_count != total_cards %} - Showing {{ cards|length }} of {{ filtered_count }} filtered cards ({{ total_cards }} total) - {% else %} - Showing {{ cards|length }} of {{ total_cards }} cards - {% endif %} - {% if search %} matching "{{ search }}"{% endif %} - -
      - - {# Card grid container or no results message #} - {% if cards and cards|length %} -
      800 %}data-virtualize="1"{% endif %}> -
      - {% for card in cards %} - {% include "browse/cards/_card_tile.html" %} - {% endfor %} -
      -
      - - {# Pagination controls #} - {% if has_next %} -
      - - - Loading... - -
      - {% endif %} - {% else %} - {# No results message with helpful info #} -
      -
      No cards found
      -
      - {% if search or color or card_type or rarity or theme or cmc_min or cmc_max %} - No cards match your current filters. - {% if search %}Try a different search term{% endif %}{% if search and (color or card_type or rarity or theme or cmc_min or cmc_max) %} or {% endif %}{% if color or card_type or rarity or theme or cmc_min or cmc_max %}adjust your filters{% endif %}. - {% else %} - Unable to load cards. Please try refreshing the page. - {% endif %} -
      - - {% if search or color or card_type or rarity or theme or cmc_min or cmc_max %} -
      - Active filters: - {% if search %} - Search: "{{ search }}" - {% endif %} - {% if theme %} - Theme: {{ theme }} - {% endif %} - {% if color %} - Color: {{ color }} - {% endif %} - {% if card_type %} - Type: {{ card_type }} - {% endif %} - {% if rarity %} - Rarity: {{ rarity|title }} - {% endif %} - {% if cmc_min or cmc_max %} - CMC: {% if cmc_min %}{{ cmc_min }}{% else %}0{% endif %}–{% if cmc_max %}{{ cmc_max }}{% else %}16+{% endif %} - {% endif %} -
      -

      Clear All Filters

      - {% endif %} -
      - {% endif %} -
      - - -{% endblock %} \ No newline at end of file diff --git a/code/web/templates/build/_alternatives.html b/code/web/templates/build/_alternatives.html index f2fb4f8..025c6af 100644 --- a/code/web/templates/build/_alternatives.html +++ b/code/web/templates/build/_alternatives.html @@ -3,12 +3,9 @@ { 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] } ] #} -
      +
      -
      - Alternatives - -
      + Alternatives {% set toggle_q = '0' if require_owned else '1' %} {% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
      @@ -24,116 +21,23 @@ {% else %}
        {% for it in items %} + {% set badge = '✔' if it.owned else '✖' %} + {% set title = 'Owned' if it.owned else 'Not owned' %} {% set tags = (it.tags or []) %}
      • + {{ badge }}
      • {% endfor %}
      {% endif %}
      - diff --git a/code/web/templates/build/_batch_progress.html b/code/web/templates/build/_batch_progress.html deleted file mode 100644 index 7aa06b9..0000000 --- a/code/web/templates/build/_batch_progress.html +++ /dev/null @@ -1,8 +0,0 @@ -{# Batch Build Progress Indicator - Multiple Builds Running in Parallel #} -
      -
      - {% include "build/_batch_progress_content.html" %} -
      -
      diff --git a/code/web/templates/build/_batch_progress_content.html b/code/web/templates/build/_batch_progress_content.html deleted file mode 100644 index 2339528..0000000 --- a/code/web/templates/build/_batch_progress_content.html +++ /dev/null @@ -1,37 +0,0 @@ -{# Batch Build Progress Content (inner content only, for HTMX updates) #} -
      -

      Building {{ build_count }} Decks...

      - -
      -
      - {{ completed }} / {{ build_count }} -
      -
      - {{ status }} -
      -
      - - {# Progress Bar #} -
      -
      -
      - -
      -

      - What's happening?
      - We're running your deck configuration {{ build_count }} times in parallel to see how card selection varies. - Each build uses the same commander, themes, and preferences but produces different results due to randomness in card selection. -

      -
      - - {% if has_errors %} -
      - ⚠️ Some builds encountered errors -

      {{ error_count }} of {{ build_count }} builds failed. Completed builds will still be available for comparison.

      -
      - {% endif %} - -

      - This may take {{ time_estimate|default("1-3 minutes") }} depending on number of decks, theme complexity, and color count... -

      -
      diff --git a/code/web/templates/build/_compliance_panel.html b/code/web/templates/build/_compliance_panel.html index 14537b9..e1d9f66 100644 --- a/code/web/templates/build/_compliance_panel.html +++ b/code/web/templates/build/_compliance_panel.html @@ -29,8 +29,8 @@ {% set sev = (f.severity or 'FAIL')|upper %}
      - {{ f.name }} image
      {% if f.owned %}✔{% else %}✖{% endif %}
      diff --git a/code/web/templates/build/_new_deck_additional_themes.html b/code/web/templates/build/_new_deck_additional_themes.html index c8180bf..190a02e 100644 --- a/code/web/templates/build/_new_deck_additional_themes.html +++ b/code/web/templates/build/_new_deck_additional_themes.html @@ -35,8 +35,7 @@ style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
      @@ -76,12 +75,12 @@ {% set reason_code = match.reason if match.reason is defined else match['reason'] %}
      {{ matched }} - {% if original and original.casefold() != matched.casefold() %} - (from "{{ original }}") - {% endif %} - -
      - {% endfor %} + {% if original and original.casefold() != matched.casefold() %} + (from “{{ original }}”) + {% endif %} + +
      + {% endfor %} {% if not matches and resolved_labels %} {% for label in resolved_labels %}
      @@ -103,7 +102,7 @@
      {{ item.input }} - +
      {% if item.reason %}
      Reason: {{ item.reason|replace('_',' ')|title }}
      @@ -114,7 +113,7 @@ {% set suggestion_theme = suggestion.theme if suggestion.theme is defined else suggestion.get('theme') %} {% set suggestion_score = suggestion.score if suggestion.score is defined else suggestion.get('score') %} {% if suggestion_theme %} - {% endif %} diff --git a/code/web/templates/build/_new_deck_candidates.html b/code/web/templates/build/_new_deck_candidates.html index 8f1bae8..7c68d49 100644 --- a/code/web/templates/build/_new_deck_candidates.html +++ b/code/web/templates/build/_new_deck_candidates.html @@ -3,9 +3,11 @@ {% for cand in candidates %}
    • -
      0/10
      -
      +
      0/10
      +
    • -
      +
      -