mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-04 12:17:17 +02:00
feat: smart land bases — auto land count, mana profile, slot earmarking, and backfill (#63)
This commit is contained in:
parent
ac6c9f4daa
commit
0ab2183277
21 changed files with 1408 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
CHANGELOG.md
10
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=<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_
|
||||
|
|
|
|||
15
DOCKER.md
15
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=<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. |
|
||||
|
|
|
|||
19
README.md
19
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=<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 |
|
||||
| --- | --- | --- |
|
||||
|
|
|
|||
|
|
@ -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=<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.
|
||||
|
|
|
|||
|
|
@ -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']:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
385
code/deck_builder/phases/phase2_lands_analysis.py
Normal file
385
code/deck_builder/phases/phase2_lands_analysis.py
Normal 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,
|
||||
})
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
304
code/tests/test_land_analysis.py
Normal file
304
code/tests/test_land_analysis.py
Normal 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
|
||||
181
code/tests/test_land_optimization_service.py
Normal file
181
code/tests/test_land_optimization_service.py
Normal 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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
61
code/web/services/land_optimization_service.py
Normal file
61
code/web/services/land_optimization_service.py
Normal 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 {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
— <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 — 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted summary-type-heading mb-1">
|
||||
{{ land.headline or ('Lands: ' ~ (land.traditional or 0)) }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
116
docs/user_guides/land_bases.md
Normal file
116
docs/user_guides/land_bases.md
Normal 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** (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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue