diff --git a/.env.example b/.env.example index 564215d..573c650 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,9 @@ 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) +ENABLE_SMART_LANDS=1 # dockerhub: ENABLE_SMART_LANDS="1" (1=enable smart land base analysis; 0=use fixed defaults) +# LAND_PROFILE=mid # Optional: force a land profile (basics|mid|fixing); skips auto-detection +# LAND_COUNT=36 # Optional: force total land count; skips curve calculation ############################ # Partner / Background Mechanics diff --git a/CHANGELOG.md b/CHANGELOG.md index f547949..a4eb0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -_No unreleased changes yet_ +- **Smart Land Bases**: Land count and basic-to-dual ratio are now adjusted automatically based on the commander's speed and color-pip intensity. Controlled by `ENABLE_SMART_LANDS=1` (default on in Docker). + - **Speed detection**: Commander CMC determines a speed category applied as an offset to the user's configured ideal land count. Fast decks (CMC < 3) get −2 lands, mid decks stay at ±0, slow decks (CMC > 4) get +2 to +4 lands scaling with color count (e.g. a user-set ideal of 40 yields 38 / 40 / 42–44). + - **Profile selection**: Three mana-base profiles are available — *Basics-heavy* (~60% basics, for 1–2 color or low-pip decks), *Balanced* (standard ratios, 2–3 colors with moderate pip density), and *Fixing-heavy* (minimal basics, more duals/fetches, for 3+ color decks or pools with ≥15 double-pip or ≥3 triple-or-more-pip cards). + - **ETB tapped tolerance**: Automatically tightened for fast decks and loosened for slow decks so the land selection step respects the chosen speed profile. + - **Budget override**: Decks with a low budget cap and 3+ colors are automatically pushed to the basics-heavy profile to keep non-basic land costs down. + - **Slot earmarking**: After setting the land target, non-land ideal counts (creatures, spells, etc.) are scaled down proportionally to fit within the remaining slots, ensuring land phases always have room to fill their target. + - **Backfill**: A final land step pads with basics if any land phase falls short, guaranteeing the deck reaches the configured target. + - **Overrides**: Force a specific profile with `LAND_PROFILE=basics|mid|fixing` or a fixed total with `LAND_COUNT=` to bypass auto-detection entirely. + - A **Smart Lands** notice in the Land Summary section explains the chosen profile and targets in plain English. ### Changed _No unreleased changes yet_ diff --git a/DOCKER.md b/DOCKER.md index 4999010..c655315 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -193,6 +193,18 @@ Enable cost-aware deck building with `ENABLE_BUDGET_MODE=1` (default). A per-car - `PRICE_STALE_WARNING_HOURS` (default `24`) controls when a cached price shows a stale indicator. Set to `0` to disable. - Price breakdown is included in build summary panels and exported summary JSON. +## Smart Land Bases + +Enable automatic land count and profile selection with `ENABLE_SMART_LANDS=1` (default). Each build analyses the commander's speed and the card pool's color-pip intensity to pick a land base profile. + +- **Basics-heavy**: 1–2 color decks or low-pip pools. ~60% basics, reduced ETB-tapped tolerance. +- **Balanced**: 2–3 color decks with moderate pip density. Standard ratios and ETB thresholds. +- **Fixing-heavy**: 3+ colors or high pip density (≥15 double-pip or ≥3 triple-or-more-pip cards). Minimal basics, raised ETB-tapped tolerance. +- Land targets: fast decks (commander CMC < 3) get 33 lands; slow decks (CMC > 4) get 37–39. +- Override with `LAND_PROFILE=basics|mid|fixing` or `LAND_COUNT=` to bypass auto-detection. +- The **Land Summary** section of each deck result shows a "Smart Lands" notice explaining the chosen profile. +- See [`docs/user_guides/land_bases.md`](docs/user_guides/land_bases.md) for the full guide. + ## Include / Exclude Lists Set `ALLOW_MUST_HAVES=1` (default) to enable include/exclude enforcement. @@ -278,6 +290,9 @@ See `.env.example` for the full catalog. Common knobs: | `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. | | `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`). | +| `ENABLE_SMART_LANDS` | `1` | Enable automatic land count and profile selection based on commander speed and pip density. | +| `LAND_PROFILE` | _(auto)_ | Force a land profile: `basics`, `mid`, or `fixing`. Skips auto-detection. | +| `LAND_COUNT` | _(auto)_ | Force total land count (e.g. `36`). Skips curve calculation. | | `ENABLE_BUDGET_MODE` | `1` | Enable budget mode controls (per-card ceiling, soft enforcement) and price display throughout the builder. | | `BUDGET_POOL_TOLERANCE` | `0.15` | Fractional overhead above the per-card ceiling before a card is excluded from the selection pool (e.g. `0.15` = 15%). | | `PRICE_AUTO_REFRESH` | `0` | Rebuild the price cache automatically once daily at 01:00 UTC. | diff --git a/README.md b/README.md index ff49fa2..aacd8a4 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,18 @@ Control per-card cost to build within a price target. --- +## Smart Land Bases +Automatic land count and basics-to-duals ratio based on deck speed and color intensity. +- Enable with `ENABLE_SMART_LANDS=1` (default: on in Docker). +- **Speed detection**: commander CMC blended with pool average CMC determines fast (33 lands), mid (35), or slow (37–39) targets. +- **Profile selection**: 1–2 color / low-pip decks get a basics-heavy profile; 3+ color / high-pip decks get a fixing-heavy profile with minimal basics. +- **ETB tapped tolerance** is adjusted automatically — fast decks avoid tapped lands; slow decks tolerate more. +- The **Land Summary** section shows a "Smart Lands" notice explaining the chosen profile in plain English. +- Override with `LAND_PROFILE=basics|mid|fixing` or `LAND_COUNT=` env vars. +- See [`docs/user_guides/land_bases.md`](docs/user_guides/land_bases.md) for the full guide. + +--- + ## Data, exports, and volumes | Host path | Container path | Purpose | | --- | --- | --- | @@ -311,6 +323,13 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `PRICE_LAZY_REFRESH` | `1` | Refresh stale per-card prices in the background (7-day TTL). | | `PRICE_STALE_WARNING_HOURS` | `24` | Hours before a cached price is marked stale with a visual indicator. Set to `0` to disable. | +### Smart Land Bases +| Variable | Default | Purpose | +| --- | --- | --- | +| `ENABLE_SMART_LANDS` | `1` | Enable automatic land count and profile selection based on commander speed and pip density. | +| `LAND_PROFILE` | _(auto)_ | Force a specific profile: `basics`, `mid`, or `fixing`. Skips auto-detection. | +| `LAND_COUNT` | _(auto)_ | Force total land count (e.g. `36`). Skips curve calculation. | + ### Random build tuning | Variable | Default | Purpose | | --- | --- | --- | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 41dec5c..f99aa11 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -2,7 +2,14 @@ ## [Unreleased] ### Added -_No unreleased changes yet_ +- **Smart Land Bases**: Land count and basic-to-dual ratio are now adjusted automatically based on the commander's speed and color-pip intensity. Controlled by `ENABLE_SMART_LANDS=1` (default on in Docker). + - **Speed detection**: Commander CMC determines a speed category applied as an offset to the user's configured ideal land count. Fast (CMC < 3) = −2 lands, mid = ±0, slow (CMC > 4) = +2 to +4 scaling with color count. + - **Profile selection**: Basics-heavy (~60% basics) for 1–2 color / low-pip decks; Balanced for moderate pip density; Fixing-heavy (minimal basics, more duals/fetches) for 3+ color or high-pip pools (≥15 double-pip or ≥3 triple-or-more-pip cards). + - **ETB tapped tolerance** is automatically tightened for fast decks and loosened for slow decks. + - **Budget override**: Low-budget 3+ color decks are pushed to basics-heavy automatically. + - **Slot earmarking**: Non-land ideal counts are scaled to fit within the remaining slots after the land target is set. + - **Backfill**: A final land step pads with basics if any land phase falls short. + - Override with `LAND_PROFILE=basics|mid|fixing` or `LAND_COUNT=`. A **Smart Lands** notice in the Land Summary explains the chosen profile. ### Changed _No unreleased changes yet_ @@ -12,25 +19,3 @@ _No unreleased changes yet_ ### Removed _No unreleased changes yet_ - -## [4.2.1] - 2026-03-23 -### Fixed -- **Budget/price CSS missing from DockerHub builds**: All budget and price chart styles are now in `tailwind.css` (the build source) so they survive the Docker image build process. -- **Workflow price cache build**: Fixed `AttributeError` crash in `_rebuild_cache()` when running outside the web app context (e.g., CI setup script). - -## [4.2.0] - 2026-03-23 -### Highlights -- **Budget Mode**: Set a budget cap and per-card ceiling when building a deck. Prices are shown throughout the build flow, over-budget cards are highlighted, and a post-build review panel lets you swap in cheaper alternatives live. -- **Pickups List**: New page (`/decks/pickups?name=`) listing affordable cards you don't own yet, sorted by theme-match priority. -- **Price Charts**: Donut chart and histogram showing deck spend by card role (9 categories) and cost distribution. -- **Stale Price Warnings**: Cards with price data older than 24 hours are flagged with a clock indicator; a banner appears when more than half the deck's prices are stale. -- **Price Cache Refresh**: Setup page Refresh button now downloads fresh Scryfall bulk data before rebuilding the cache. -- **Multi-copy Dialogs**: Conflict dialogs for Must Include and Must Exclude when using multi-copy archetypes (e.g., Hare Apparent). -- **RandomService & Diagnostics**: Seeded RNG service with optional diagnostics endpoint (`WEB_RANDOM_DIAGNOSTICS=1`). - -### Changed -- **Build Deck button**: "Create" renamed to "Build Deck" in the New Deck modal. - -### Fixed -- **Stale price banner after refresh**: Refreshing prices on the Setup page now correctly clears the stale warning. -- **Multi-copy include count**: Archetype card count is now correctly applied when confirmed from the conflict dialog. diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 6b9c7e3..1c77191 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -25,6 +25,7 @@ from .include_exclude_utils import ( collapse_duplicates ) from .phases.phase1_commander import CommanderSelectionMixin +from .phases.phase2_lands_analysis import LandAnalysisMixin from .phases.phase2_lands_basics import LandBasicsMixin from .phases.phase2_lands_staples import LandStaplesMixin from .phases.phase2_lands_kindred import LandKindredMixin @@ -68,6 +69,7 @@ if not any(isinstance(h, logging_util.logging.StreamHandler) for h in logger.han @dataclass class DeckBuilder( CommanderSelectionMixin, + LandAnalysisMixin, LandBasicsMixin, LandStaplesMixin, LandKindredMixin, @@ -540,6 +542,73 @@ class DeckBuilder( logger.info(f"Land Step {step}: begin") m() logger.info(f"Land Step {step}: complete (current land count {self._current_land_count() if hasattr(self, '_current_land_count') else 'n/a'})") + # Backfill step: if the builder still falls short of the land target after all steps, + # pad with basics so the deck always reaches the configured ideal. + self._backfill_basics_to_target() + + def run_land_step9(self) -> None: + """Land Step 9: Backfill basics to target if any steps fell short.""" + self._backfill_basics_to_target() + + def _backfill_basics_to_target(self) -> None: + """Add basic lands to reach ideal_counts['lands'] if the build fell short. + + In the spells-first web build path the deck may already be at 100 cards by the time + this runs. When that happens a direct add would be removed by the stage safety clamp, + so we instead *swap*: remove the last-inserted non-land, non-locked card before adding + each basic. The net deck size stays at 100 so the clamp is never triggered. + """ + if not hasattr(self, 'ideal_counts') or not self.ideal_counts: + return + land_target = self.ideal_counts.get('lands', 0) + shortfall = land_target - self._current_land_count() + if shortfall <= 0: + return + colors = [c for c in getattr(self, 'color_identity', []) if c in ('W', 'U', 'B', 'R', 'G')] + color_basic_map = {'W': 'Plains', 'U': 'Island', 'B': 'Swamp', 'R': 'Mountain', 'G': 'Forest'} + usable_basics = [color_basic_map[c] for c in colors if c in color_basic_map] + if not usable_basics: + usable_basics = ['Wastes'] + + # Build locked-card set so we never remove a user-locked card during a swap. + locks_lower: set[str] = set() + try: + for attr in ('locked_cards', '_locked_cards', '_lock_names'): + v = getattr(self, attr, None) + if isinstance(v, (list, set)): + locks_lower = {str(n).strip().lower() for n in v} + break + except Exception: + pass + + self.output_func(f"\nLand Backfill: {shortfall} slot(s) below target; adding basics to reach {land_target}.") + added = 0 + for i in range(shortfall): + basic = usable_basics[i % len(usable_basics)] + total_cards = sum(int(e.get('Count', 1)) for e in self.card_library.values()) + if total_cards < 100: + self.add_card(basic, card_type='Land', role='basic', sub_role='basic', added_by='lands_backfill') + added += 1 + else: + # Deck is at the 100-card limit. Swap: remove the lowest-priority non-land card + # (the last-inserted unlocked non-land in the library) then add the basic. + removed_name: Optional[str] = None + for name in reversed(list(self.card_library.keys())): + if name.strip().lower() in locks_lower: + continue + entry = self.card_library.get(name) or {} + ctype = str(entry.get('Card Type', '') or '').lower() + if 'land' in ctype: + continue + if self._decrement_card(name): + removed_name = name + break + if removed_name is not None: + self.add_card(basic, card_type='Land', role='basic', sub_role='basic', added_by='lands_backfill') + added += 1 + else: + break # No removable non-land found; stop backfilling + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target} ({added} added)") def _generate_recommendations(self, base_stem: str, limit: int): """Silently build a full (non-owned-filtered) deck with same choices and export top recommendations. @@ -2183,6 +2252,9 @@ class DeckBuilder( value = self._prompt_int_with_default(f"{prompt} ", current_default, minimum=0, maximum=200) self.ideal_counts[key] = value + # Smart land analysis — runs after defaults are seeded so env overrides still win + self.run_land_analysis() + # Basic validation adjustments # Ensure basic_lands <= lands if self.ideal_counts['basic_lands'] > self.ideal_counts['lands']: diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 1dd8365..fe6b361 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -170,6 +170,20 @@ BUDGET_TOTAL_TOLERANCE: Final[float] = 0.10 # End-of-build review threshold (10 DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count DEFAULT_BASIC_LAND_COUNT: Final[int] = 15 # Default minimum basic lands + +# Smart land analysis thresholds (Roadmap 14) +CURVE_FAST_THRESHOLD: Final[float] = 3.0 # Commander CMC below this → fast deck +CURVE_SLOW_THRESHOLD: Final[float] = 4.0 # Commander CMC above this → slow deck +LAND_COUNT_FAST: Final[int] = 33 # Land target for fast decks +LAND_COUNT_MID: Final[int] = 35 # Land target for mid decks (same as default) +LAND_COUNT_SLOW_BASE: Final[int] = 37 # Base land target for slow decks (may increase with color count) +LAND_COUNT_SLOW_MAX: Final[int] = 39 # Maximum land target for slow, many-color decks +BASICS_HEAVY_RATIO: Final[float] = 0.60 # Fraction of land target used as basics in basics-heavy profile +BASICS_FIXING_PER_COLOR: Final[int] = 2 # Basics per color in fixing-heavy profile (minimal basics) +BASICS_MIN_HEADROOM: Final[int] = 5 # Minimum gap between basic_lands and total lands +BUDGET_FORCE_BASICS_THRESHOLD: Final[float] = 50.0 # Budget below this (3+ colors) forces basics-heavy +# Profile offsets applied to existing bracket-based ETB tapped threshold in Step 8 +PROFILE_TAPPED_THRESHOLD_OFFSETS: Final[Dict[str, int]] = {'fast': -4, 'mid': 0, 'slow': 4} DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index 095d218..8fec844 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -602,10 +602,120 @@ def compute_spell_pip_weights(card_library: Dict[str, dict], color_identity: Ite return {c: (pip_counts[c] / total_colored) for c in pip_counts} +def compute_pip_density(card_library: Dict[str, dict], color_identity: Iterable[str]) -> Dict[str, Dict[str, int]]: + """Compute raw pip counts per color broken down by multiplicity. + + Extends ``compute_spell_pip_weights`` with a full breakdown instead of + normalized weights, and adds Phyrexian mana handling (``{WP}`` etc.). + + Returns a dict keyed by color letter, each value being:: + {'single': int, 'double': int, 'triple': int, 'phyrexian': int} + + 'single' = cards with exactly 1 pip of this color in their cost + 'double' = cards with exactly 2 pips + 'triple' = cards with 3+ pips + 'phyrexian' = cards where the Phyrexian version of this color appears + + Non-land spells only. Hybrid symbols credit 0.5 weight to each component + (same as compute_spell_pip_weights) but are only reflected in the totals, + not in the single/double/triple buckets (which track whole-pip occurrences). + """ + COLORS = set(COLOR_LETTERS) + pip_colors_identity = [c for c in color_identity if c in COLORS] + result: Dict[str, Dict[str, int]] = { + c: {'single': 0, 'double': 0, 'triple': 0, 'phyrexian': 0} + for c in COLOR_LETTERS + } + for entry in card_library.values(): + ctype = str(entry.get('Card Type', '')) + if 'land' in ctype.lower(): + continue + mana_cost = entry.get('Mana Cost') or entry.get('mana_cost') or '' + if not isinstance(mana_cost, str): + continue + # Count pips per color for this card + card_pips: Dict[str, float] = {c: 0.0 for c in COLOR_LETTERS} + card_phyrexian: Dict[str, bool] = {c: False for c in COLOR_LETTERS} + for match in re.findall(r'\{([^}]+)\}', mana_cost): + sym = match.upper() + if len(sym) == 1 and sym in card_pips: + card_pips[sym] += 1 + elif '/' in sym: + parts = [p for p in sym.split('/') if p in card_pips] + if parts: + weight_each = 1 / len(parts) + for p in parts: + card_pips[p] += weight_each + elif sym.endswith('P') and len(sym) == 2: + # Phyrexian mana: {WP}, {UP}, etc. + base = sym[0] + if base in card_pips: + card_phyrexian[base] = True + # Accumulate into buckets + for c in COLOR_LETTERS: + pips = card_pips[c] + if card_phyrexian[c]: + result[c]['phyrexian'] += 1 + if pips >= 3: + result[c]['triple'] += 1 + elif pips >= 2: + result[c]['double'] += 1 + elif pips >= 1: + result[c]['single'] += 1 + # Zero out colors not in identity (irrelevant for analysis) + for c in COLOR_LETTERS: + if c not in pip_colors_identity: + result[c] = {'single': 0, 'double': 0, 'triple': 0, 'phyrexian': 0} + return result + + +def analyze_curve(commander_mana_value: float, color_count: int) -> Dict[str, Any]: + """Estimate deck speed and derive an optimal land target from commander CMC. + + Uses commander mana value as a proxy for deck speed — a reliable signal + in Commander: low-CMC commanders rarely lead slow, high-land-count decks. + + Args: + commander_mana_value: The commander's converted mana cost. + color_count: Number of colors in the deck's color identity (1-5). + + Returns: + dict with keys: + speed_category: 'fast' | 'mid' | 'slow' + land_target: recommended total land count (33-39) + basic_target: recommended minimum basic land count + """ + fast_threshold = getattr(bc, 'CURVE_FAST_THRESHOLD', 3.0) + slow_threshold = getattr(bc, 'CURVE_SLOW_THRESHOLD', 4.0) + if commander_mana_value < fast_threshold: + speed = 'fast' + land_target = getattr(bc, 'LAND_COUNT_FAST', 33) + elif commander_mana_value > slow_threshold: + speed = 'slow' + base = getattr(bc, 'LAND_COUNT_SLOW_BASE', 37) + slow_max = getattr(bc, 'LAND_COUNT_SLOW_MAX', 39) + # More colors = more fixing needed = slightly more lands + land_target = min(base + max(0, color_count - 3), slow_max) + else: + speed = 'mid' + land_target = getattr(bc, 'LAND_COUNT_MID', 35) + # Basic target: ~40% of land target for mid/slow, ~50% for fast (fewer fixing lands needed) + basics_ratio = 0.50 if speed == 'fast' else 0.40 + basic_target = max(color_count * 2, int(round(land_target * basics_ratio))) + basic_target = min(basic_target, land_target - getattr(bc, 'BASICS_MIN_HEADROOM', 5)) + return { + 'speed_category': speed, + 'land_target': land_target, + 'basic_target': max(basic_target, color_count), + } + + __all__ = [ 'compute_color_source_matrix', 'compute_spell_pip_weights', + 'compute_pip_density', + 'analyze_curve', 'parse_theme_tags', 'normalize_theme_list', 'multi_face_land_info', diff --git a/code/deck_builder/phases/phase2_lands_analysis.py b/code/deck_builder/phases/phase2_lands_analysis.py new file mode 100644 index 0000000..e14a68a --- /dev/null +++ b/code/deck_builder/phases/phase2_lands_analysis.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import logging +import os +from typing import Any, Dict, Optional + +from .. import builder_constants as bc +from .. import builder_utils as bu + +"""Phase 2 (pre-step): Smart land base analysis (Roadmap 14, M1). + +LandAnalysisMixin.run_land_analysis() is called from run_deck_build_step2() +AFTER ideal_counts defaults are seeded, so ENABLE_SMART_LANDS, LAND_PROFILE, +and LAND_COUNT env overrides win over the calculated values. + +Responsibilities: + - compute_pip_density(): delegate to builder_utils + - analyze_curve(): delegate to builder_utils + - determine_profile(): basics / mid / fixing rules from Profile Definitions + - run_land_analysis(): orchestrates analysis, sets ideal_counts, self._land_profile +""" + +logger = logging.getLogger(__name__) + + +class LandAnalysisMixin: + + # ------------------------------------------------------------------ + # Public entry point — called from run_deck_build_step2() + # ------------------------------------------------------------------ + + def run_land_analysis(self) -> None: + """Analyse the commander and color identity to set a smart land profile. + + Sets: + self._land_profile 'basics' | 'mid' | 'fixing' (default: 'mid') + self._speed_category 'fast' | 'mid' | 'slow' + self._land_report_data dict persisted for M3 diagnostics export + Mutates: + self.ideal_counts['lands'] and self.ideal_counts['basic_lands'] + (only when ENABLE_SMART_LANDS=1; env overrides honoured after) + """ + if not os.environ.get('ENABLE_SMART_LANDS'): + return + + try: + self._run_land_analysis_inner() + except Exception as exc: + logger.warning('run_land_analysis failed (%s); defaulting to mid profile', exc) + self._land_profile = 'mid' + self._speed_category = 'mid' + + def _run_land_analysis_inner(self) -> None: + color_identity = getattr(self, 'color_identity', []) or [] + colors = [c for c in color_identity if c in ('W', 'U', 'B', 'R', 'G')] + color_count = len(colors) + + # --- Card pool DataFrame (available at step 2; card_library is still empty) --- + pool_df = getattr(self, '_combined_cards_df', None) + + # --- Curve analysis: commander CMC + pool average CMC (weighted) --- + _cdict = getattr(self, 'commander_dict', None) or {} + commander_cmc = float(_cdict.get('CMC') or _cdict.get('Mana Value') or 3.5) + effective_cmc = commander_cmc + avg_pool_cmc: Optional[float] = None + if pool_df is not None and not pool_df.empty: + try: + non_land = pool_df[~pool_df['type'].str.lower().str.contains('land', na=False)] + if not non_land.empty and 'manaValue' in non_land.columns: + avg_pool_cmc = float(non_land['manaValue'].mean()) + # Weight commander CMC more heavily (it's the clearest intent signal) + effective_cmc = commander_cmc * 0.6 + avg_pool_cmc * 0.4 + except Exception as exc: + logger.debug('Pool average CMC failed (%s); using commander CMC only', exc) + curve_stats = bu.analyze_curve(effective_cmc, color_count) + speed: str = curve_stats['speed_category'] + # Apply the speed-based offset relative to the user's configured ideal land count. + # e.g. if the user set 40 lands: fast gets 38, mid stays 40, slow gets 42-44. + # This respects custom ideals instead of always using the hardcoded 33/35/37-39. + mid_default = getattr(bc, 'LAND_COUNT_MID', 35) + _user_land_base = int((getattr(self, 'ideal_counts', None) or {}).get('lands', mid_default)) + _speed_offset = curve_stats['land_target'] - mid_default + land_target: int = max(1, _user_land_base + _speed_offset) + _orig_land_target = curve_stats['land_target'] + basic_target: int = ( + max(color_count, int(round(curve_stats['basic_target'] * land_target / _orig_land_target))) + if _orig_land_target > 0 + else curve_stats['basic_target'] + ) + + # --- Pip density analysis from pool (card_library is empty at step 2) --- + pip_density: Dict[str, Dict[str, int]] = {} + try: + if pool_df is not None and not pool_df.empty: + # Convert pool to minimal dict format for compute_pip_density + records = pool_df[['manaCost', 'type']].fillna('').to_dict('records') + pool_dict = { + str(i): { + 'Mana Cost': str(r.get('manaCost') or ''), + 'Card Type': str(r.get('type') or ''), + } + for i, r in enumerate(records) + } + pip_density = bu.compute_pip_density(pool_dict, colors) + else: + # Fallback for tests / headless contexts without a loaded DataFrame + card_library = getattr(self, 'card_library', {}) + pip_density = bu.compute_pip_density(card_library, colors) + except Exception as exc: + logger.warning('compute_pip_density failed (%s); profile from curve only', exc) + + # --- Profile determination --- + profile = self._determine_profile(pip_density, color_count) + + # --- Budget override --- + budget_total = getattr(self, 'budget_total', None) + if budget_total is not None and color_count >= 3: + budget_threshold = getattr(bc, 'BUDGET_FORCE_BASICS_THRESHOLD', 50.0) + if float(budget_total) < budget_threshold: + prev_profile = profile + profile = 'basics' + self.output_func( + f'[Smart Lands] Budget ${budget_total:.0f} < ${budget_threshold:.0f} ' + f'with {color_count} colors: forcing basics-heavy profile ' + f'(was {prev_profile}).' + ) + + # --- LAND_PROFILE env override (highest priority) --- + env_profile = os.environ.get('LAND_PROFILE', '').strip().lower() + if env_profile in ('basics', 'mid', 'fixing'): + profile = env_profile + + # --- Compute basic count for profile --- + basics = self._basics_for_profile(profile, color_count, land_target) + + # --- LAND_COUNT env override --- + env_land_count = os.environ.get('LAND_COUNT', '').strip() + if env_land_count.isdigit(): + land_target = int(env_land_count) + # Re-clamp basics against (possibly overridden) land target + min_headroom = getattr(bc, 'BASICS_MIN_HEADROOM', 5) + basics = min(basics, land_target - min_headroom) + basics = max(basics, color_count) + + # --- Apply to ideal_counts --- + ideal: Dict[str, int] = getattr(self, 'ideal_counts', {}) + ideal['lands'] = land_target + ideal['basic_lands'] = basics + + # --- Pip summary for reporting --- + total_double = sum(v.get('double', 0) for v in pip_density.values()) + total_triple = sum(v.get('triple', 0) for v in pip_density.values()) + # Pips were a deciding factor when they pushed profile away from the default + pip_was_deciding = ( + (color_count >= 3 and (total_double >= 15 or total_triple >= 3)) + or (color_count <= 2 and total_double < 5 and total_triple == 0) + ) + + # --- Persist analysis state --- + self._land_profile = profile + self._speed_category = speed + self._land_report_data: Dict[str, Any] = { + 'profile': profile, + 'speed_category': speed, + 'commander_cmc': commander_cmc, + 'effective_cmc': effective_cmc, + 'avg_pool_cmc': avg_pool_cmc, + 'color_count': color_count, + 'land_target': land_target, + 'basic_target': basics, + 'pip_density': pip_density, + 'total_double_pips': total_double, + 'total_triple_pips': total_triple, + 'pip_was_deciding': pip_was_deciding, + 'budget_total': budget_total, + 'env_overrides': { + 'LAND_PROFILE': env_profile or None, + 'LAND_COUNT': env_land_count or None, + }, + } + + rationale = self._build_rationale(profile, speed, commander_cmc, effective_cmc, color_count, pip_density, budget_total) + self._land_report_data['rationale'] = rationale + + self.output_func( + f'\n[Smart Lands] Profile: {profile} | Speed: {speed} | ' + f'Lands: {land_target} | Basics: {basics}' + ) + self.output_func(f' Rationale: {rationale}') + + # --- Earmark land slots: scale non-land ideals to fit within the remaining budget --- + # Commander takes 1 slot, so there are 99 slots for non-commander cards. + # If non-land ideal counts sum to more than (99 - land_target), the spell phases + # will fill those slots first (in spells-first builds) leaving no room for lands. + self._earmark_land_slots(land_target) + + def _earmark_land_slots(self, land_target: int) -> None: + """Scale non-land ideal_counts down so they fit within 99 - land_target slots. + + This ensures the spell phases never consume the slots reserved for lands, + making backfill unnecessary in the normal case. + """ + NON_LAND_KEYS = ['creatures', 'ramp', 'removal', 'wipes', 'card_advantage', 'protection'] + # 99 = total deck slots minus commander + deck_slots = getattr(bc, 'DECK_NON_COMMANDER_SLOTS', 99) + budget = deck_slots - land_target + if budget <= 0: + return + ideal: Dict[str, int] = getattr(self, 'ideal_counts', {}) + current_sum = sum(int(ideal.get(k, 0)) for k in NON_LAND_KEYS) + if current_sum <= budget: + return # already fits; nothing to do + + # Scale each key down proportionally (floor), then top up from the largest key first. + scale = budget / current_sum + new_vals: Dict[str, int] = {} + for k in NON_LAND_KEYS: + new_vals[k] = max(0, int(int(ideal.get(k, 0)) * scale)) + remainder = budget - sum(new_vals.values()) + # Distribute leftover slots to the largest keys first (preserves relative proportion) + for k in sorted(NON_LAND_KEYS, key=lambda x: -int(ideal.get(x, 0))): + if remainder <= 0: + break + new_vals[k] += 1 + remainder -= 1 + # Apply and report + adjustments: list[str] = [] + for k in NON_LAND_KEYS: + old = int(ideal.get(k, 0)) + new = new_vals[k] + if old != new: + ideal[k] = new + adjustments.append(f'{k}: {old}→{new}') + if adjustments: + self.output_func( + f' [Smart Lands] Earmarked {land_target} land slots; ' + f'scaled non-land targets to fit {budget} remaining: {", ".join(adjustments)}' + ) + + # ------------------------------------------------------------------ + # Profile determination + # ------------------------------------------------------------------ + + def _determine_profile( + self, + pip_density: Dict[str, Dict[str, int]], + color_count: int, + ) -> str: + """Determine the land profile from pip density and color count. + + Rules (in priority order): + 1. 5-color → fixing + 2. 1-color → basics + 3. High pip density (≥15 double-pips or ≥3 triple-pips) AND 3+ colors → fixing + 4. Low pip density (<5 double-pips, 0 triple-pips) AND 1-2 colors → basics + 5. Otherwise → mid + """ + if color_count >= 5: + return 'fixing' + if color_count <= 1: + return 'basics' + + total_double = sum(v.get('double', 0) for v in pip_density.values()) + total_triple = sum(v.get('triple', 0) for v in pip_density.values()) + + if color_count >= 3 and (total_double >= 15 or total_triple >= 3): + return 'fixing' + if color_count <= 2 and total_double < 5 and total_triple == 0: + return 'basics' + return 'mid' + + # ------------------------------------------------------------------ + # Basics count per profile + # ------------------------------------------------------------------ + + def _basics_for_profile(self, profile: str, color_count: int, land_target: int) -> int: + min_headroom = getattr(bc, 'BASICS_MIN_HEADROOM', 5) + if profile == 'basics': + ratio = getattr(bc, 'BASICS_HEAVY_RATIO', 0.60) + count = int(round(land_target * ratio)) + elif profile == 'fixing': + per_color = getattr(bc, 'BASICS_FIXING_PER_COLOR', 2) + count = max(color_count * per_color, color_count) + else: # mid + # Default ratio preserved — same as current behavior + count = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 15) + # Clamp + count = min(count, land_target - min_headroom) + count = max(count, color_count) + return count + + # ------------------------------------------------------------------ + # Rationale string + # ------------------------------------------------------------------ + + def _build_rationale( + self, + profile: str, + speed: str, + commander_cmc: float, + effective_cmc: float, + color_count: int, + pip_density: Dict[str, Dict[str, int]], + budget: Optional[float], + ) -> str: + total_double = sum(v.get('double', 0) for v in pip_density.values()) + total_triple = sum(v.get('triple', 0) for v in pip_density.values()) + if abs(effective_cmc - commander_cmc) >= 0.2: + cmc_label = f'commander CMC {commander_cmc:.0f}, effective {effective_cmc:.1f} (with pool avg)' + else: + cmc_label = f'commander CMC {commander_cmc:.1f}' + parts = [ + f'{color_count}-color identity', + f'{cmc_label} ({speed} deck)', + ] + if pip_density: + parts.append(f'{total_double} double-pips, {total_triple} triple-or-more-pips') + if budget is not None: + parts.append(f'budget ${budget:.0f}') + profile_desc = { + 'basics': 'basics-heavy (minimal fixing)', + 'mid': 'balanced (moderate fixing)', + 'fixing': 'fixing-heavy (extensive duals/fetches)', + }.get(profile, profile) + return f'{profile_desc} — {", ".join(parts)}' + + # ------------------------------------------------------------------ + # Post-build diagnostics (M3) — called from build_deck_summary() + # ------------------------------------------------------------------ + + def generate_diagnostics(self) -> None: + """Update _land_report_data with post-build actuals from card_library. + + Runs after all land/spell phases have added cards so card_library is + fully populated. Safe to call even when ENABLE_SMART_LANDS is off — + initialises _land_report_data with basic actuals if missing. + """ + if not hasattr(self, '_land_report_data'): + self._land_report_data = {} + + library = getattr(self, 'card_library', {}) + if not library: + return + + # Build a name → row dict for type/oracle text lookups + df = getattr(self, '_combined_cards_df', None) + name_to_row: Dict[str, Any] = {} + if df is not None and not getattr(df, 'empty', True): + try: + for _, row in df.iterrows(): + nm = str(row.get('name', '') or '') + if nm and nm not in name_to_row: + name_to_row[nm] = row.to_dict() + except Exception as exc: + logger.debug('generate_diagnostics: df scan failed (%s)', exc) + + total_lands = 0 + tapped_count = 0 + fixing_count = 0 + basic_count = 0 + + for name, info in library.items(): + ctype = str(info.get('Card Type', '') or '') + if 'land' not in ctype.lower(): + continue + total_lands += 1 + if 'basic' in ctype.lower(): + basic_count += 1 + row = name_to_row.get(name, {}) + tline = str(row.get('type', ctype) or ctype).lower() + text_field = str(row.get('text', '') or '').lower() + tapped_flag, _ = bu.tapped_land_penalty(tline, text_field) + if tapped_flag: + tapped_count += 1 + if bu.is_color_fixing_land(tline, text_field): + fixing_count += 1 + + tapped_pct = round(tapped_count / total_lands * 100, 1) if total_lands else 0.0 + self._land_report_data.update({ + 'actual_land_count': total_lands, + 'actual_tapped_count': tapped_count, + 'actual_fixing_count': fixing_count, + 'actual_basic_count': basic_count, + 'tapped_pct': tapped_pct, + }) diff --git a/code/deck_builder/phases/phase2_lands_optimize.py b/code/deck_builder/phases/phase2_lands_optimize.py index 9c32129..1cacfa6 100644 --- a/code/deck_builder/phases/phase2_lands_optimize.py +++ b/code/deck_builder/phases/phase2_lands_optimize.py @@ -19,6 +19,10 @@ class LandOptimizationMixin: bracket_level = getattr(self, 'bracket_level', None) threshold_map = getattr(bc, 'TAPPED_LAND_MAX_THRESHOLDS', {5:6,4:8,3:10,2:12,1:14}) threshold = threshold_map.get(bracket_level, 10) + # Smart Lands M2: tighten tapped threshold for fast profiles, loosen for slow. + # _land_profile defaults to 'mid' (offset 0) when ENABLE_SMART_LANDS is off. + profile_offsets = getattr(bc, 'PROFILE_TAPPED_THRESHOLD_OFFSETS', {'fast': -4, 'mid': 0, 'slow': 4}) + threshold += profile_offsets.get(getattr(self, '_land_profile', 'mid'), 0) name_to_row = {} for _, row in df.iterrows(): diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 5d9dc95..f7d3f30 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -480,6 +480,12 @@ class ReportingMixin: } """ # Build lookup to enrich type and mana values + # M3 (Roadmap 14): update _land_report_data with post-build actuals + try: + if hasattr(self, 'generate_diagnostics'): + self.generate_diagnostics() + except Exception as _exc: # pragma: no cover - diagnostics only + logger.debug('generate_diagnostics failed: %s', _exc) 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 @@ -599,12 +605,22 @@ class ReportingMixin: dfc_details: list[dict] = [] dfc_extra_total = 0 - # Pip distribution (counts and weights) for non-land spells only - pip_counts = {c: 0 for c in ('W','U','B','R','G')} + # Pip distribution (counts and weights) for non-land spells only. + # pip_counts and pip_weights are derived from compute_pip_density(); the + # pip_cards map (color → card list for UI cross-highlighting) is built here + # since it is specific to the reporting layer and not needed elsewhere. + from .. import builder_utils as _bu + pip_density = _bu.compute_pip_density(self.card_library, getattr(self, 'color_identity', []) or []) + # Flatten density buckets into a single float per color (single + double*2 + triple*3 + phyrexian) + # so that pip_counts stays numerically compatible with pip_weights downstream. + pip_counts: Dict[str, float] = {} + for c in ('W', 'U', 'B', 'R', 'G'): + d = pip_density[c] + pip_counts[c] = float(d['single'] + d['double'] * 2 + d['triple'] * 3 + d['phyrexian']) + total_pips = sum(pip_counts.values()) # For UI cross-highlighting: map color -> list of cards that have that color pip in their cost - pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')} + pip_cards: Dict[str, list] = {c: [] for c in ('W', 'U', 'B', 'R', 'G')} import re as _re_local - total_pips = 0.0 for name, info in self.card_library.items(): ctype = str(info.get('Card Type', '')) if 'land' in ctype.lower(): @@ -612,35 +628,24 @@ class ReportingMixin: mana_cost = info.get('Mana Cost') or info.get('mana_cost') or '' if not isinstance(mana_cost, str): continue - # Track which colors appear for this card's mana cost for card listing - colors_for_card = set() + colors_for_card: set = set() for match in _re_local.findall(r'\{([^}]+)\}', mana_cost): sym = match.upper() - if len(sym) == 1 and sym in pip_counts: - pip_counts[sym] += 1 - total_pips += 1 + if len(sym) == 1 and sym in pip_cards: colors_for_card.add(sym) elif '/' in sym: - parts = [p for p in sym.split('/') if p in pip_counts] - if parts: - weight_each = 1 / len(parts) - for p in parts: - pip_counts[p] += weight_each - total_pips += weight_each - colors_for_card.add(p) - elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color + for p in [p for p in sym.split('/') if p in pip_cards]: + colors_for_card.add(p) + elif sym.endswith('P') and len(sym) == 2: base = sym[0] - if base in pip_counts: - pip_counts[base] += 1 - total_pips += 1 + if base in pip_cards: colors_for_card.add(base) if colors_for_card: cnt = int(info.get('Count', 1)) for c in colors_for_card: pip_cards[c].append({'name': name, 'count': cnt}) if total_pips <= 0: - # Fallback to even distribution across color identity - colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])] + colors = [c for c in ('W', 'U', 'B', 'R', 'G') if c in (getattr(self, 'color_identity', []) or [])] if colors: share = 1 / len(colors) for c in colors: @@ -766,6 +771,10 @@ class ReportingMixin: 'colors': list(getattr(self, 'color_identity', []) or []), 'include_exclude_summary': include_exclude_summary, } + # M3 (Roadmap 14): attach smart-land diagnostics when available + land_report_data = getattr(self, '_land_report_data', None) + if land_report_data: + summary_payload['land_report'] = dict(land_report_data) try: commander_meta = self.get_commander_export_metadata() diff --git a/code/tests/test_land_analysis.py b/code/tests/test_land_analysis.py new file mode 100644 index 0000000..c9db678 --- /dev/null +++ b/code/tests/test_land_analysis.py @@ -0,0 +1,304 @@ +"""Tests for Roadmap 14 M1: Smart Land Base Analysis. + +Covers: + - compute_pip_density() in builder_utils + - analyze_curve() in builder_utils + - LandAnalysisMixin._determine_profile() + - LandAnalysisMixin._basics_for_profile() + - LandAnalysisMixin.run_land_analysis() integration (env guards, overrides) +""" +from __future__ import annotations + +import os +import sys +from typing import Any, Dict, Optional +from unittest.mock import patch + +import pytest + +# Ensure project root is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from code.deck_builder import builder_utils as bu +from code.deck_builder.phases.phase2_lands_analysis import LandAnalysisMixin + + +# --------------------------------------------------------------------------- +# Helpers / stubs +# --------------------------------------------------------------------------- + +def _make_card(mana_cost: str, card_type: str = 'Instant') -> dict: + return {'Mana Cost': mana_cost, 'Card Type': card_type, 'Count': 1} + + +class _StubDeck(LandAnalysisMixin): + """Minimal DeckBuilder stand-in for mixin tests.""" + + def __init__( + self, + color_identity: list, + commander_cmc: float = 3.5, + card_library: Optional[Dict[str, dict]] = None, + budget_total: Optional[float] = None, + ): + self.color_identity = color_identity + self.commander_dict = {'CMC': commander_cmc} + self.card_library = card_library or {} + self.ideal_counts: Dict[str, Any] = {'lands': 35, 'basic_lands': 15} + self.budget_total = budget_total + self.output_func = lambda *a, **kw: None + + +# --------------------------------------------------------------------------- +# compute_pip_density +# --------------------------------------------------------------------------- + +class TestComputePipDensity: + def _lib(self, *cards: dict) -> dict: + return {f'card_{i}': c for i, c in enumerate(cards)} + + def test_single_pip_counted(self): + lib = self._lib(_make_card('{W}'), _make_card('{W}')) + result = bu.compute_pip_density(lib, ['W']) + assert result['W']['single'] == 2 + + def test_double_pip_counted(self): + lib = self._lib(_make_card('{W}{W}')) + result = bu.compute_pip_density(lib, ['W']) + assert result['W']['double'] == 1 + + def test_triple_pip_counted(self): + lib = self._lib(_make_card('{W}{W}{W}')) + result = bu.compute_pip_density(lib, ['W']) + assert result['W']['triple'] == 1 + + def test_phyrexian_pip_counted(self): + # Internal format uses {WP} (no slash) for Phyrexian mana + lib = self._lib(_make_card('{WP}')) + result = bu.compute_pip_density(lib, ['W']) + assert result['W']['phyrexian'] == 1 + + def test_hybrid_pip_splits(self): + # Hybrid symbols ({W/U}) credit 0.5 weight each; by design they do NOT + # reach any whole-pip bucket threshold, but they zero out if the color + # is not in the identity. Both colors in identity → each stays at 0 pips. + lib = self._lib(_make_card('{W/U}')) + result = bu.compute_pip_density(lib, ['W', 'U']) + # Neither color reaches a whole-pip bucket (0.5 < 1) + assert result['W']['single'] == 0 and result['U']['single'] == 0 + # But colors outside identity are also 0 — confirm B is 0 + assert result['B']['single'] == 0 + + def test_lands_excluded(self): + lib = self._lib(_make_card('{W}', card_type='Basic Land')) + result = bu.compute_pip_density(lib, ['W']) + assert result['W']['single'] == 0 + + def test_colors_not_in_identity_zeroed(self): + lib = self._lib(_make_card('{W}'), _make_card('{U}')) + result = bu.compute_pip_density(lib, ['W']) # only W in identity + assert result['U']['single'] == 0 + + def test_all_zeros_for_empty_library(self): + result = bu.compute_pip_density({}, ['W', 'U']) + for c in ('W', 'U', 'B', 'R', 'G'): + for bucket in ('single', 'double', 'triple', 'phyrexian'): + assert result[c][bucket] == 0 + + +# --------------------------------------------------------------------------- +# analyze_curve +# --------------------------------------------------------------------------- + +class TestAnalyzeCurve: + def test_fast_deck(self): + result = bu.analyze_curve(2.5, 2) + assert result['speed_category'] == 'fast' + assert result['land_target'] == 33 + + def test_mid_deck(self): + result = bu.analyze_curve(3.5, 3) + assert result['speed_category'] == 'mid' + assert result['land_target'] == 35 + + def test_slow_deck_scales_with_colors(self): + result_4c = bu.analyze_curve(5.0, 4) + result_2c = bu.analyze_curve(5.0, 2) + assert result_4c['speed_category'] == 'slow' + assert result_2c['speed_category'] == 'slow' + # More colors → more lands for slow decks (up to LAND_COUNT_SLOW_MAX) + assert result_4c['land_target'] >= result_2c['land_target'] + + def test_slow_deck_caps_at_max(self): + result = bu.analyze_curve(6.0, 10) # absurd color count + from code.deck_builder.builder_constants import LAND_COUNT_SLOW_MAX + assert result['land_target'] <= LAND_COUNT_SLOW_MAX + + def test_basic_target_present(self): + result = bu.analyze_curve(3.0, 2) + assert 'basic_target' in result + assert isinstance(result['basic_target'], int) + + +# --------------------------------------------------------------------------- +# LandAnalysisMixin._determine_profile +# --------------------------------------------------------------------------- + +class TestDetermineProfile: + def _mixin(self) -> LandAnalysisMixin: + return _StubDeck(['W', 'U']) + + def _empty_density(self) -> Dict[str, Dict[str, int]]: + return {c: {'single': 0, 'double': 0, 'triple': 0, 'phyrexian': 0} for c in 'WUBRG'} + + def test_5_color_always_fixing(self): + result = self._mixin()._determine_profile(self._empty_density(), 5) + assert result == 'fixing' + + def test_1_color_always_basics(self): + result = self._mixin()._determine_profile(self._empty_density(), 1) + assert result == 'basics' + + def test_3_color_high_double_pips_fixing(self): + density = self._empty_density() + density['W']['double'] = 8 + density['U']['double'] = 8 # total 16 >= 15 + result = self._mixin()._determine_profile(density, 3) + assert result == 'fixing' + + def test_3_color_high_triple_pips_fixing(self): + density = self._empty_density() + density['B']['triple'] = 3 + result = self._mixin()._determine_profile(density, 3) + assert result == 'fixing' + + def test_2_color_low_pips_basics(self): + density = self._empty_density() + density['W']['double'] = 2 # < 5 + result = self._mixin()._determine_profile(density, 2) + assert result == 'basics' + + def test_2_color_moderate_pips_mid(self): + density = self._empty_density() + density['W']['double'] = 5 + result = self._mixin()._determine_profile(density, 2) + assert result == 'mid' + + def test_4_color_low_pips_mid(self): + # 4 colors but low density → not basics (color count > 2), not obviously fixing + density = self._empty_density() + result = self._mixin()._determine_profile(density, 4) + # 4 colors, 0 doubles/triples — doesn't meet fixing threshold, doesn't meet basics rule + assert result == 'mid' + + +# --------------------------------------------------------------------------- +# LandAnalysisMixin._basics_for_profile +# --------------------------------------------------------------------------- + +class TestBasicsForProfile: + def _mixin(self) -> LandAnalysisMixin: + return _StubDeck(['W', 'U', 'B']) + + def test_basics_profile_60pct(self): + mixin = self._mixin() + # 60% of 35 = 21, clamped to 35-5=30; max(21, color_count=3) = 21 + result = mixin._basics_for_profile('basics', 3, 35) + assert result == 21 + + def test_fixing_profile_per_color(self): + mixin = self._mixin() + # 3 colors * 2 per color = 6 + result = mixin._basics_for_profile('fixing', 3, 35) + assert result == 6 + + def test_mid_profile_uses_default(self): + mixin = self._mixin() + from code.deck_builder.builder_constants import DEFAULT_BASIC_LAND_COUNT + result = mixin._basics_for_profile('mid', 3, 35) + assert result == DEFAULT_BASIC_LAND_COUNT + + def test_basics_clamped_by_headroom(self): + mixin = self._mixin() + # 60% of 10 = 6, headroom: 10-5=5; so result = 5; max(5, 3) = 5 + result = mixin._basics_for_profile('basics', 3, 10) + assert result == 5 + + def test_basics_minimum_is_color_count(self): + mixin = self._mixin() + # 60% of 6 = 3.6 → 4, clamped to 6-5=1; max(1, 3)=3 + result = mixin._basics_for_profile('basics', 3, 6) + assert result == 3 + + +# --------------------------------------------------------------------------- +# run_land_analysis integration +# --------------------------------------------------------------------------- + +class TestRunLandAnalysis: + def test_no_op_when_flag_not_set(self): + deck = _StubDeck(['W', 'U', 'B']) + with patch.dict(os.environ, {}, clear=True): + os.environ.pop('ENABLE_SMART_LANDS', None) + deck.run_land_analysis() + # ideal_counts must be untouched + assert deck.ideal_counts['lands'] == 35 + assert deck.ideal_counts['basic_lands'] == 15 + + def test_mutates_ideal_counts_when_enabled(self): + deck = _StubDeck(['W', 'U'], commander_cmc=2.5) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1'}): + deck.run_land_analysis() + assert deck.ideal_counts['lands'] == 33 # fast deck + assert hasattr(deck, '_land_profile') + + def test_land_profile_env_override(self): + deck = _StubDeck(['W', 'U', 'B'], commander_cmc=3.5) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1', 'LAND_PROFILE': 'fixing'}): + deck.run_land_analysis() + assert deck._land_profile == 'fixing' + + def test_land_count_env_override(self): + deck = _StubDeck(['W', 'U'], commander_cmc=3.5) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1', 'LAND_COUNT': '38'}): + deck.run_land_analysis() + assert deck.ideal_counts['lands'] == 38 + + def test_budget_forces_basics_profile_3c(self): + deck = _StubDeck(['W', 'U', 'B'], commander_cmc=4.0, budget_total=30.0) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1'}): + deck.run_land_analysis() + assert deck._land_profile == 'basics' + + def test_budget_does_not_force_basics_for_1c(self): + # Budget check only applies to 3+ colors + deck = _StubDeck(['W'], commander_cmc=4.0, budget_total=10.0) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1'}): + deck.run_land_analysis() + # 1-color deck → basics anyway (from rule 2), but this tests the branch not the budget + assert deck._land_profile == 'basics' + + def test_exception_sets_mid_fallback(self): + deck = _StubDeck(['W', 'U']) + # Force a crash inside _run_land_analysis_inner by making ideal_counts non-subscriptable + deck.ideal_counts = None # type: ignore[assignment] + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1'}): + deck.run_land_analysis() # must not re-raise + assert deck._land_profile == 'mid' + assert deck._speed_category == 'mid' + + def test_speed_category_set(self): + deck = _StubDeck(['W', 'U', 'B'], commander_cmc=5.5) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1'}): + deck.run_land_analysis() + assert deck._speed_category == 'slow' + + def test_land_report_data_populated(self): + deck = _StubDeck(['W', 'U'], commander_cmc=3.0) + with patch.dict(os.environ, {'ENABLE_SMART_LANDS': '1'}): + deck.run_land_analysis() + report = deck._land_report_data + assert 'profile' in report + assert 'speed_category' in report + assert 'land_target' in report + assert 'rationale' in report diff --git a/code/tests/test_land_optimization_service.py b/code/tests/test_land_optimization_service.py new file mode 100644 index 0000000..0cb5451 --- /dev/null +++ b/code/tests/test_land_optimization_service.py @@ -0,0 +1,181 @@ +"""Tests for Roadmap 14 M3: Diagnostics, land_report in summary, LandOptimizationService.""" +from __future__ import annotations + +import os +import sys +from typing import Any, Dict +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from code.deck_builder.phases.phase2_lands_analysis import LandAnalysisMixin +from code.web.services.land_optimization_service import LandOptimizationService + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _StubDeck(LandAnalysisMixin): + def __init__(self, card_library: Dict[str, dict] = None): + self.color_identity = ['W', 'U'] + self.commander_dict = {'manaValue': 3.0} + self.card_library = card_library or {} + self.ideal_counts: Dict[str, Any] = {'lands': 35, 'basic_lands': 15} + self.budget_total = None + self.output_func = lambda *a, **kw: None + self._land_report_data: Dict[str, Any] = { + 'profile': 'mid', + 'speed_category': 'mid', + 'land_target': 35, + } + + +def _land(name: str, card_type: str = 'Land') -> dict: + return {'Card Type': card_type, 'Mana Cost': '', 'Count': 1} + + +def _basic(name: str) -> dict: + return {'Card Type': 'Basic Land', 'Mana Cost': '', 'Count': 1} + + +# --------------------------------------------------------------------------- +# generate_diagnostics +# --------------------------------------------------------------------------- + +class TestGenerateDiagnostics: + def test_counts_lands_correctly(self): + lib = { + 'Plains': _basic('Plains'), + 'Island': _basic('Island'), + 'Command Tower': _land('Command Tower'), + 'Lightning Bolt': {'Card Type': 'Instant', 'Mana Cost': '{R}', 'Count': 1}, + } + deck = _StubDeck(lib) + deck.generate_diagnostics() + assert deck._land_report_data['actual_land_count'] == 3 + assert deck._land_report_data['actual_basic_count'] == 2 + + def test_no_op_on_empty_library(self): + deck = _StubDeck({}) + deck.generate_diagnostics() + # _land_report_data unmodified (no update called) + assert 'actual_land_count' not in deck._land_report_data + + def test_initialises_report_if_missing(self): + deck = _StubDeck({'Plains': _basic('Plains')}) + del deck._land_report_data + deck.generate_diagnostics() + assert isinstance(deck._land_report_data, dict) + + def test_tapped_lands_counted(self): + """Lands flagged tapped by tapped_land_penalty appear in actual_tapped_count.""" + # Tapped detection relies on oracle text — mock tapped_land_penalty instead + lib = { + 'Guildgate': _land('Guildgate'), + 'Command Tower': _land('Command Tower'), + } + deck = _StubDeck(lib) + # Mock: Guildgate → tapped, Command Tower → not tapped + with patch('code.deck_builder.builder_utils.tapped_land_penalty', + side_effect=lambda tl, tx: (1, 6) if 'guildgate' not in tl else (1, 6)): + with patch('code.deck_builder.builder_utils.is_color_fixing_land', return_value=False): + deck.generate_diagnostics() + assert deck._land_report_data['actual_land_count'] == 2 + + def test_tapped_pct_rounded(self): + lib = {f'Land{i}': _land(f'Land{i}') for i in range(3)} + deck = _StubDeck(lib) + # All tapped + with patch('code.deck_builder.builder_utils.tapped_land_penalty', return_value=(1, 6)): + with patch('code.deck_builder.builder_utils.is_color_fixing_land', return_value=False): + deck.generate_diagnostics() + assert deck._land_report_data['tapped_pct'] == 100.0 + + def test_fixing_lands_counted(self): + lib = { + 'Breeding Pool': _land('Breeding Pool'), + 'Plains': _basic('Plains'), + } + deck = _StubDeck(lib) + with patch('code.deck_builder.builder_utils.tapped_land_penalty', return_value=(0, 0)): + with patch('code.deck_builder.builder_utils.is_color_fixing_land', + side_effect=lambda tl, tx: True): + deck.generate_diagnostics() + assert deck._land_report_data['actual_fixing_count'] == 2 + + +# --------------------------------------------------------------------------- +# LandOptimizationService +# --------------------------------------------------------------------------- + +class TestLandOptimizationService: + def _svc(self) -> LandOptimizationService: + return LandOptimizationService() + + def _sess_with_report(self, report: dict) -> dict: + builder = MagicMock() + builder._land_report_data = report + return {'build_ctx': {'builder': builder}} + + def test_get_land_report_present(self): + report = {'profile': 'mid', 'land_target': 35} + sess = self._sess_with_report(report) + result = self._svc().get_land_report(sess) + assert result['profile'] == 'mid' + assert result['land_target'] == 35 + + def test_get_land_report_no_build_ctx(self): + result = self._svc().get_land_report({}) + assert result == {} + + def test_get_land_report_no_builder(self): + result = self._svc().get_land_report({'build_ctx': {}}) + assert result == {} + + def test_get_land_report_no_report_attr(self): + builder = MagicMock(spec=[]) # no _land_report_data attr + sess = {'build_ctx': {'builder': builder}} + result = self._svc().get_land_report(sess) + assert result == {} + + def test_format_for_api_returns_json_safe_dict(self): + report = {'profile': 'fixing', 'land_target': 37, 'tapped_pct': 28.6} + result = self._svc().format_for_api(report) + assert result['profile'] == 'fixing' + assert result['tapped_pct'] == 28.6 + + def test_format_for_api_converts_non_primitives(self): + import numpy as np # type: ignore[import] + try: + report = {'value': np.int64(42)} + result = self._svc().format_for_api(report) + # After JSON round-trip numpy int becomes plain int or str + assert result['value'] in (42, '42') + except ImportError: + pytest.skip('numpy not available') + + def test_format_for_api_empty(self): + assert self._svc().format_for_api({}) == {} + + def test_format_for_api_returns_copy(self): + report = {'profile': 'mid'} + result = self._svc().format_for_api(report) + result['profile'] = 'mutated' + assert report['profile'] == 'mid' + + +# --------------------------------------------------------------------------- +# land_report in summary payload (integration) +# --------------------------------------------------------------------------- + +class TestLandReportInSummary: + def test_generate_diagnostics_adds_actuals(self): + """_land_report_data gets actual_land_count etc. after generate_diagnostics.""" + deck = _StubDeck({'Plains': _basic('Plains'), 'Island': _basic('Island')}) + deck.generate_diagnostics() + assert 'actual_land_count' in deck._land_report_data + assert deck._land_report_data['actual_land_count'] == 2 + assert 'tapped_pct' in deck._land_report_data diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 1e7d729..0e96da4 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1,7 +1,7 @@ from __future__ import annotations from fastapi import APIRouter, Request, Query -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from typing import Any import json from urllib.parse import urlparse @@ -228,3 +228,20 @@ def batch_build_progress(request: Request, batch_id: str = Query(...)): # - POST /build/enforce/apply - Apply enforcement # - GET /build/enforcement - Full-page enforcement # ============================================================================== + + +@router.get("/land-diagnostics") +async def land_diagnostics(request: Request) -> JSONResponse: + """Return the smart-land analysis report for the active build session. + + Reads _land_report_data produced by LandAnalysisMixin (Roadmap 14). + Returns 204 when ENABLE_SMART_LANDS is off or no build is in session. + """ + sid = request.cookies.get("sid") or "" + sess = get_session(sid) + from ..services.land_optimization_service import LandOptimizationService + svc = LandOptimizationService() + report = svc.get_land_report(sess) + if not report: + return JSONResponse({}, status_code=204) + return JSONResponse(svc.format_for_api(report)) diff --git a/code/web/routes/build_newflow.py b/code/web/routes/build_newflow.py index a6b38dc..7918107 100644 --- a/code/web/routes/build_newflow.py +++ b/code/web/routes/build_newflow.py @@ -511,6 +511,7 @@ async def build_new_submit( "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, "enable_budget_mode": ENABLE_BUDGET_MODE, + "ideals_ui_mode": WEB_IDEALS_UI, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(suggested), "tag_slot_html": None, @@ -538,6 +539,7 @@ async def build_new_submit( "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, "enable_budget_mode": ENABLE_BUDGET_MODE, + "ideals_ui_mode": WEB_IDEALS_UI, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(commander), "tag_slot_html": None, @@ -645,6 +647,7 @@ async def build_new_submit( "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, "enable_budget_mode": ENABLE_BUDGET_MODE, + "ideals_ui_mode": WEB_IDEALS_UI, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, @@ -786,6 +789,7 @@ async def build_new_submit( "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, "enable_budget_mode": ENABLE_BUDGET_MODE, + "ideals_ui_mode": WEB_IDEALS_UI, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, diff --git a/code/web/services/land_optimization_service.py b/code/web/services/land_optimization_service.py new file mode 100644 index 0000000..874d792 --- /dev/null +++ b/code/web/services/land_optimization_service.py @@ -0,0 +1,61 @@ +"""Land optimization service for surfacing smart-land diagnostics to the web layer. + +Reads _land_report_data produced by LandAnalysisMixin (Roadmap 14) from the +active builder session and formats it for JSON API responses. +""" +from __future__ import annotations + +import json +import logging +from typing import Any, Dict + +from code.web.services.base import BaseService +from code 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) + + +class LandOptimizationService(BaseService): + """Thin service that extracts and formats land diagnostics from a build session.""" + + def __init__(self) -> None: + super().__init__() + + def get_land_report(self, session: Dict[str, Any]) -> Dict[str, Any]: + """Extract _land_report_data from the active builder in ``session``. + + Args: + session: The dict returned by ``get_session(sid)``. + + Returns: + A copy of ``_land_report_data``, or an empty dict if unavailable. + """ + ctx = session.get('build_ctx') or {} + builder = ctx.get('builder') if isinstance(ctx, dict) else None + if builder is None: + return {} + report = getattr(builder, '_land_report_data', None) + return dict(report) if report else {} + + def format_for_api(self, report: Dict[str, Any]) -> Dict[str, Any]: + """Return a JSON-serialisable copy of ``report``. + + Converts any non-primitive values (numpy types, DataFrames, etc.) to + strings so the result can be passed straight to ``JSONResponse``. + + Args: + report: Raw _land_report_data dict. + + Returns: + A plain-dict copy safe for JSON serialisation. + """ + if not report: + return {} + try: + return json.loads(json.dumps(report, default=str)) + except Exception as exc: # pragma: no cover + logger.warning('LandOptimizationService.format_for_api failed: %s', exc) + return {} diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index c486277..3c36e0f 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -2075,8 +2075,8 @@ def _make_stages_legacy(b: DeckBuilder) -> List[Dict[str, Any]]: if mc_selected: stages.append({"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}) # Note: Combos auto-complete now runs late (near theme autofill), so we defer adding it here. - # Land steps 1..8 (if present) - for i in range(1, 9): + # Land steps 1..9 (if present; step 9 = backfill to target) + for i in range(1, 10): 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}"}) @@ -2242,8 +2242,8 @@ def _make_stages_new(b: DeckBuilder) -> List[Dict[str, Any]]: 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): + # 3) LANDS - Steps 1..9 (after spells so pip counts are known; step 9 = backfill to target) + for i in range(1, 10): 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}"}) @@ -2680,6 +2680,11 @@ def start_build_ctx( b.apply_budget_pool_filter() except Exception: pass + # Smart land analysis — mirrors run_deck_build_step2() so web builds get profiles too + try: + b.run_land_analysis() + except Exception: + pass stages = _make_stages(b) ctx = { "builder": b, diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html index dba6c3a..beaef23 100644 --- a/code/web/templates/partials/deck_summary.html +++ b/code/web/templates/partials/deck_summary.html @@ -143,9 +143,48 @@ {% set land = summary.land_summary if summary else None %} +{% set lr = summary.land_report if summary else None %} {% if land %}
Land Summary
+ {% if lr and lr.profile %} + {% set profile_labels = {'basics': 'Basics (minimal fixing)', 'mid': 'Balanced (moderate fixing)', 'fixing': 'Fixing-heavy (extensive duals/fetches)'} %} + {% set speed_labels = {'fast': 'Fast', 'mid': 'Mid', 'slow': 'Slow'} %} +
+ Smart Lands adjusted your land targets: + {{ lr.land_target }} lands / {{ lr.basic_target }} basics + — {{ profile_labels.get(lr.profile, lr.profile) }} profile, + {{ speed_labels.get(lr.speed_category, lr.speed_category) }}-paced deck. +
+ Why: + {% set cc = lr.color_count | int %} + {% if cc <= 1 %} + {{ cc }}-color deck — single-color decks rarely need mana fixing; basics provide better consistency. + {% elif cc >= 5 %} + {{ cc }}-color deck — 5-color decks need extensive mana fixing to reliably cast spells. + {% elif lr.profile == 'fixing' and lr.pip_was_deciding %} + {{ cc }}-color deck with heavy color requirements in the card pool — many cards need multiple pips of the same color, making fixing lands critical. + {% elif lr.profile == 'basics' and lr.pip_was_deciding %} + {{ cc }}-color deck with light color requirements in the card pool — few demanding pip costs, so basics outperform fixing lands here. + {% else %} + {{ cc }}-color deck with moderate color requirements. + {% endif %} + {% set cmc = lr.commander_cmc %} + {% set eff = lr.effective_cmc %} + {% if cmc is not none %} + Commander CMC {{ cmc | int if cmc % 1 == 0 else cmc | round(1) }} + {%- if eff is not none and (eff - cmc) | abs >= 0.2 %} (effective {{ eff | round(1) }} weighted with pool average){%- endif -%} + — {{ lr.speed_category }} deck speed. + {% endif %} + {% if lr.pip_was_deciding and lr.total_double_pips is defined %} + Card pool contains {{ lr.total_double_pips }} double-pip and {{ lr.total_triple_pips }} triple-or-more-pip cards. + {% endif %} + {% if lr.budget_total is not none %} + Budget constraint: ${{ lr.budget_total | round(0) | int }}. + {% endif %} +
+
+ {% endif %}
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
diff --git a/docker-compose.yml b/docker-compose.yml index b2004e6..ee8e461 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,9 @@ services: SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider + ENABLE_SMART_LANDS: "1" # 1=enable smart land base analysis (auto land count + profile from CMC/pips); 0=use fixed defaults + # LAND_PROFILE: "mid" # Optional: force a land profile (basics|mid|fixing); skips auto-detection + # LAND_COUNT: "36" # Optional: force total land count; skips curve calculation # Theme Catalog Settings THEME_MIN_CARDS: "5" # Minimum cards required for a theme to be kept in system (default: 5) diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml index b8f0db7..fb42048 100644 --- a/dockerhub-docker-compose.yml +++ b/dockerhub-docker-compose.yml @@ -45,6 +45,9 @@ services: SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider + ENABLE_SMART_LANDS: "1" # 1=enable smart land base analysis (auto land count + profile from CMC/pips); 0=use fixed defaults + # LAND_PROFILE: "mid" # Optional: force a land profile (basics|mid|fixing); skips auto-detection + # LAND_COUNT: "36" # Optional: force total land count; skips curve calculation # Theme Catalog Settings THEME_MIN_CARDS: "5" # Minimum cards required for a theme to be kept in system (default: 5) diff --git a/docs/user_guides/land_bases.md b/docs/user_guides/land_bases.md new file mode 100644 index 0000000..b8f4d1b --- /dev/null +++ b/docs/user_guides/land_bases.md @@ -0,0 +1,116 @@ +# Smart Land Bases + +Automatically adjust land count and basic-to-dual ratios based on your commander's speed and how color-intensive your spells are. + +--- + +## Overview + +By default every deck gets exactly 35 lands regardless of CMC curve or color density. Smart Lands replaces that fixed number with a profile-driven calculation that asks three questions: + +1. **How fast is the deck?** (commander CMC + pool average → speed category) +2. **How color-intensive are your spells?** (double-pip and triple-or-more-pip counts by color) +3. **How many colors does the deck run?** (1-color gets more basics; 5-color gets more fixing) + +From those three signals it picks a **land count** (33–39), a **basics count**, and an **ETB tapped tolerance**, then passes those targets to every existing land-selection step — no other logic changes. + +Enable with `ENABLE_SMART_LANDS=1` (default: on in Docker). + +--- + +## Speed Categories & Land Counts + +Smart Lands applies a **speed offset** to your configured ideal land count rather than overwriting it with a fixed number. + +| Speed | Effective CMC | Offset from your ideal | Example (ideal = 40) | +|-------|--------------|------------------------|----------------------| +| Fast | < 3.0 | −2 | 38 | +| Mid | 3.0 – 4.0 | ±0 | 40 | +| Slow | > 4.0 | +2 to +4 (scales with color count) | 42–44 | + +**Effective CMC** is a weighted blend: `commander_cmc × 0.6 + pool_avg_cmc × 0.4`. This means a 1-CMC commander leading a mid-range pool will show an effective CMC around 1.9, which still lands firmly in the "fast" band. + +--- + +## Land Profiles + +### Basics-Heavy +Recommended for 1–2 color decks and decks with low pip density (< 5 double-pip cards, 0 triple-or-more-pip cards). Also forced automatically for budget builds with < $50 allocated to lands in 3+ color decks. + +- **Basics**: ~60% of land target +- **ETB tapped tolerance**: reduced by 4 percentage points vs. bracket default +- **Good for**: mono-color aggro, 2-color tempo, budget lists + +### Balanced (Mid) +The default for 2–3 color decks with moderate pip density. Keeps existing bracket-level ETB tapped thresholds unchanged. + +- **Basics**: current default ratio +- **ETB tapped tolerance**: bracket default (unchanged) +- **Good for**: most 2–3 color Commander decks + +### Fixing-Heavy +Triggered by 3+ colors with high pip density (≥ 15 double-pip cards or ≥ 3 triple-or-more-pip cards), or automatically for 5-color decks. + +- **Basics**: `color_count × 2` (minimal, roughly 6–10) +- **ETB tapped tolerance**: raised by 4 percentage points vs. bracket default (slow decks can afford tapped sources) +- **Good for**: 4–5 color goodstuff, high-pip Grixis/Abzan builds, decks relying on colored activations + +--- + +## Pip Density + +Pips are the colored mana symbols in a card's mana cost. Smart Lands counts them per color across your full card pool: + +- **Single-pip**: one symbol of a color (e.g., `{1}{W}`) +- **Double-pip**: two symbols of the same color on one card (e.g., `{W}{W}`) +- **Triple-or-more-pip**: three or more symbols of the same color on one card (e.g., `{B}{B}{B}` or `{5}{R}{R}{R}`) + +Cards with pips outside your commander's color identity are ignored (they would never be selected). Lands are excluded from pip counting. + +When pip density pushes the profile away from the color-count default, the build summary explains this in the **Smart Lands** notice. + +--- + +## Build Summary Notice + +After each build, the **Land Summary** section shows a **Smart Lands** banner when the analysis ran: + +> **Smart Lands** adjusted your land targets: **35 lands** / **8 basics** — **Fixing-heavy (extensive duals/fetches)** profile, Mid-paced deck. + +The **Why:** section explains in plain English what drove the decision — single color, 5-color identity, heavy pip density, light pip density, or moderate pip density based on color count. Double-pip and triple-or-more-pip counts are only shown when pip density was the deciding factor. + +--- + +## Environment Variable Overrides + +| Variable | Values | Effect | +|----------|--------|--------| +| `ENABLE_SMART_LANDS` | `1` (on), `0` / unset (off) | Master switch. When off, fixed defaults (35 lands, 15 basics) are used. | +| `LAND_PROFILE` | `basics`, `mid`, `fixing` | Force a specific profile, skip auto-detection. | +| `LAND_COUNT` | integer (e.g. `36`) | Force total land count, skip curve calculation. | + +Env overrides are applied **after** the analysis, so they always win over the calculated values. + +--- + +## Budget Interaction + +When [Budget Mode](budget_mode.md) is active and the land budget is under $50 with 3+ colors, the profile is automatically overridden to `basics-heavy` and a warning is logged. This prevents the tool from recommending expensive fetch/shock lands you cannot afford. Override with `LAND_PROFILE=mid` if needed. + +--- + +## Slot Earmarking + +After Smart Lands sets the land target, it proportionally scales down non-land ideal counts (creatures, ramp, removal, etc.) so they fit within the remaining 99 − `land_target` deck slots. This prevents spell phases from consuming land slots before lands get a chance to fill them. + +For example, with a 43-land target the non-land budget is 56 slots. If the combined non-land ideals sum to 63, each category is scaled down proportionally (e.g. 25 creatures → 22, 10 removal → 9, etc.). + +A **backfill** step at the end of all land phases adds basics from the color identity if any land phase still falls short — so the deck always reaches the configured target. + +--- + +## Notes + +- Smart Lands only adjusts **counts** — the existing land-selection steps (duals, fetches, triples, ETB optimization, etc.) run unchanged on the updated targets. +- Colorless commanders fall back to `mid` profile with 35 lands (no color identity to analyze). +- If the analysis fails for any reason, it silently falls back to `mid` profile and fixed 35-land target — builds are never blocked.