feat: smart land bases — auto land count, mana profile, slot earmarking, and backfill (#63)

This commit is contained in:
mwisnowski 2026-03-25 18:05:28 -07:00 committed by GitHub
parent ac6c9f4daa
commit 0ab2183277
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1408 additions and 51 deletions

View file

@ -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

View file

@ -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 / 4244).
- **Profile selection**: Three mana-base profiles are available — *Basics-heavy* (~60% basics, for 12 color or low-pip decks), *Balanced* (standard ratios, 23 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=<n>` 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_

View file

@ -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**: 12 color decks or low-pip pools. ~60% basics, reduced ETB-tapped tolerance.
- **Balanced**: 23 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 3739.
- Override with `LAND_PROFILE=basics|mid|fixing` or `LAND_COUNT=<n>` 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. |

View file

@ -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 (3739) targets.
- **Profile selection**: 12 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=<n>` 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 |
| --- | --- | --- |

View file

@ -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 12 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=<n>`. 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.

View file

@ -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']:

View file

@ -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

View file

@ -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',

View file

@ -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,
})

View file

@ -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():

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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,

View file

@ -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 {}

View file

@ -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,

View file

@ -143,9 +143,48 @@
<!-- Land Summary -->
{% set land = summary.land_summary if summary else None %}
{% set lr = summary.land_report if summary else None %}
{% if land %}
<section class="summary-section-lg">
<h5>Land Summary</h5>
{% 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'} %}
<div class="notice" style="margin-bottom:.75rem; font-size:.85rem; line-height:1.5;">
<strong>Smart Lands</strong> adjusted your land targets:
<strong>{{ lr.land_target }} lands</strong> / <strong>{{ lr.basic_target }} basics</strong>
&mdash; <strong>{{ profile_labels.get(lr.profile, lr.profile) }}</strong> profile,
<strong>{{ speed_labels.get(lr.speed_category, lr.speed_category) }}</strong>-paced deck.
<div class="muted" style="margin-top:.3rem; font-size:.8rem;">
<strong>Why:</strong>
{% set cc = lr.color_count | int %}
{% if cc <= 1 %}
{{ cc }}-color deck &mdash; single-color decks rarely need mana fixing; basics provide better consistency.
{% elif cc >= 5 %}
{{ cc }}-color deck &mdash; 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 &mdash; 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 &mdash; 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 -%}
&mdash; {{ 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 %}
</div>
</div>
{% endif %}
<div class="muted summary-type-heading mb-1">
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
</div>

View file

@ -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)

View file

@ -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)

View file

@ -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** (3339), 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) | 4244 |
**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 12 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 23 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 23 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 610)
- **ETB tapped tolerance**: raised by 4 percentage points vs. bracket default (slow decks can afford tapped sources)
- **Good for**: 45 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.