Builder tool has been restrcutured up through adding lands, still a bit of cleanup to do, but will come back after other parts

This commit is contained in:
mwisnowski 2025-08-19 09:00:51 -07:00
parent a8a181c4af
commit 411f042af8
4 changed files with 1878 additions and 18 deletions

File diff suppressed because it is too large Load diff

View file

@ -150,7 +150,7 @@ DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
# Deck composition defaults
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] = 20 # Default minimum basic lands
DEFAULT_BASIC_LAND_COUNT: Final[int] = 10 # Default minimum basic lands
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
@ -158,9 +158,49 @@ DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add
MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add
MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add
MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from
MISC_LAND_TOP_POOL_SIZE: Final[int] = 30 # For utility step: sample from top N by EDHREC rank
MISC_LAND_COLOR_FIX_PRIORITY_WEIGHT: Final[int] = 2 # Weight multiplier for color-fixing candidates
# Default fetch land count
# Default fetch land count & cap
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
FETCH_LAND_MAX_CAP: Final[int] = 7 # Absolute maximum fetch lands allowed in final manabase
# Default dual land (two-color nonbasic) total target
DUAL_LAND_DEFAULT_COUNT: Final[int] = 4 # Heuristic total; actual added may be less based on colors/capacity
# Default triple land (three-color typed) total target (kept low; usually only 1-2 high quality available)
TRIPLE_LAND_DEFAULT_COUNT: Final[int] = 2 # User preference: add only one or two
# Maximum acceptable ETB tapped land counts per power bracket (1-5)
TAPPED_LAND_MAX_THRESHOLDS: Final[Dict[int,int]] = {
1: 14, # Exhibition
2: 12, # Core / Precon
3: 10, # Upgraded
4: 8, # Optimized
5: 6, # cEDH (fast mana expectations)
}
# Minimum penalty score to consider swapping (higher scores swapped first); kept for tuning
TAPPED_LAND_SWAP_MIN_PENALTY: Final[int] = 6
# Basic land floor ratio (ceil of ratio * configured basic count)
BASIC_FLOOR_FACTOR: Final[float] = 0.9
# Shared textual heuristics / keyword lists
BASIC_LAND_TYPE_KEYWORDS: Final[List[str]] = ['plains','island','swamp','mountain','forest']
ANY_COLOR_MANA_PHRASES: Final[List[str]] = [
'add one mana of any color',
'add one mana of any colour'
]
TAPPED_LAND_PHRASE: Final[str] = 'enters the battlefield tapped'
SHOCK_LIKE_PHRASE: Final[str] = 'you may pay 2 life'
CONDITIONAL_UNTAP_KEYWORDS: Final[List[str]] = [
'unless you control',
'if you control',
'as long as you control'
]
COLORED_MANA_SYMBOLS: Final[List[str]] = ['{w}','{u}','{b}','{r}','{g}']
# Basic Lands
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
@ -377,12 +417,12 @@ THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
'primary': 1.0,
'secondary': 0.6,
'tertiary': 0.3,
'tertiary': 0.2,
'hidden': 0.0
}
WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = {
'kindred_primary': 1.5, # Boost for Kindred themes as primary
'kindred_primary': 1.4, # Boost for Kindred themes as primary
'kindred_secondary': 1.3, # Boost for Kindred themes as secondary
'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary
'theme_synergy': 1.2 # Boost for themes that work well together

View file

@ -0,0 +1,462 @@
"""Utility helper functions for deck builder.
This module houses pure/stateless helper logic that was previously embedded
inside the large builder.py module. Extracting them here keeps the DeckBuilder
class leaner and makes the logic easier to test independently.
Only import lightweight standard library modules here to avoid import cycles.
"""
from __future__ import annotations
from typing import Dict, Iterable
import re
from . import builder_constants as bc
COLOR_LETTERS = ['W', 'U', 'B', 'R', 'G']
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
"""Build a matrix mapping land name -> {color: 0/1} indicating if that land
can (reliably) produce each color.
Heuristics:
- Presence of basic land types in type line grants that color.
- Text containing "add one mana of any color/colour" grants all colors.
- Explicit mana symbols in rules text (e.g. "{R}") grant that color.
Parameters
----------
card_library : Dict[str, dict]
Current deck card entries (expects 'Card Type' and 'Count').
full_df : pandas.DataFrame | None
Full card dataset used for type/text lookups. May be None/empty.
"""
matrix: Dict[str, Dict[str, int]] = {}
lookup = {}
if full_df is not None and not getattr(full_df, 'empty', True) and 'name' in full_df.columns:
for _, r in full_df.iterrows(): # type: ignore[attr-defined]
nm = str(r.get('name', ''))
if nm and nm not in lookup:
lookup[nm] = r
for name, entry in card_library.items():
if 'land' not in str(entry.get('Card Type', '')).lower():
continue
row = lookup.get(name, {})
tline = str(row.get('type', row.get('type_line', ''))).lower()
text_field = str(row.get('text', row.get('oracleText', ''))).lower()
colors = {c: 0 for c in COLOR_LETTERS}
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
if 'add one mana of any color' in text_field or 'add one mana of any colour' in text_field:
for k in colors:
colors[k] = 1
for sym, c in [(' {w}', 'W'), (' {u}', 'U'), (' {b}', 'B'), (' {r}', 'R'), (' {g}', 'G')]:
if sym in text_field:
colors[c] = 1
matrix[name] = colors
return matrix
def compute_spell_pip_weights(card_library: Dict[str, dict], color_identity: Iterable[str]) -> Dict[str, float]:
"""Compute relative colored mana pip weights from non-land spells.
Hybrid symbols are split evenly among their component colors. If no colored
pips are found we fall back to an even distribution across the commander's
color identity (or 0s if identity empty).
"""
pip_counts = {c: 0 for c in COLOR_LETTERS}
total_colored = 0.0
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
for match in re.findall(r'\{([^}]+)\}', mana_cost):
sym = match.upper()
if len(sym) == 1 and sym in pip_counts:
pip_counts[sym] += 1
total_colored += 1
else:
if '/' 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_colored += weight_each
if total_colored <= 0:
colors = [c for c in color_identity if c in pip_counts]
if not colors:
return {c: 0.0 for c in pip_counts}
share = 1 / len(colors)
return {c: (share if c in colors else 0.0) for c in pip_counts}
return {c: (pip_counts[c] / total_colored) for c in pip_counts}
__all__ = [
'compute_color_source_matrix',
'compute_spell_pip_weights',
'COLOR_LETTERS',
'tapped_land_penalty',
'replacement_land_score',
'build_tag_driven_suggestions',
'select_color_balance_removal',
'color_balance_addition_candidates',
'basic_land_names',
'count_basic_lands',
'choose_basic_to_trim',
'enforce_land_cap',
'is_color_fixing_land',
'weighted_sample_without_replacement',
'count_existing_fetches',
'select_top_land_candidates',
]
def tapped_land_penalty(tline: str, text_field: str) -> tuple[int, int]:
"""Classify a land for tapped optimization.
Returns (tapped_flag, penalty). tapped_flag is 1 if the land counts toward
the tapped threshold. Penalty is higher for worse (slower) lands. Non-tapped
lands return (0, 0).
"""
tline_l = tline.lower()
text_l = text_field.lower()
if 'land' not in tline_l:
return 0, 0
always_tapped = 'enters the battlefield tapped' in text_l
shock_like = 'you may pay 2 life' in text_l # shocks can be untapped
conditional = any(kw in text_l for kw in ['unless you control', 'if you control', 'as long as you control']) or shock_like
tapped_flag = 0
if always_tapped and not shock_like:
tapped_flag = 1
elif conditional:
tapped_flag = 1
if not tapped_flag:
return 0, 0
tri_types = sum(1 for b in bc.BASIC_LAND_TYPE_KEYWORDS if b in tline_l) >= 3
any_color = any(p in text_l for p in bc.ANY_COLOR_MANA_PHRASES)
cycling = 'cycling' in text_l
life_gain = 'gain' in text_l and 'life' in text_l and 'you gain' in text_l
produces_basic_colors = any(sym in text_l for sym in bc.COLORED_MANA_SYMBOLS)
penalty = 8 if always_tapped and not conditional else 6
if tri_types:
penalty -= 3
if any_color:
penalty -= 3
if cycling:
penalty -= 2
if conditional:
penalty -= 2
if not produces_basic_colors and not any_color:
penalty += 1
if life_gain:
penalty += 1
return tapped_flag, penalty
def replacement_land_score(name: str, tline: str, text_field: str) -> int:
"""Heuristic scoring of candidate replacement lands (higher is better)."""
tline_l = tline.lower()
text_l = text_field.lower()
score = 0
lname = name.lower()
# Prioritize shocks explicitly
if any(kw in lname for kw in ['blood crypt', 'steam vents', 'watery grave', 'breeding pool', 'godless shrine', 'hallowed fountain', 'overgrown tomb', 'stomping ground', 'temple garden', 'sacred foundry']):
score += 20
if 'you may pay 2 life' in text_l:
score += 15
if any(p in text_l for p in bc.ANY_COLOR_MANA_PHRASES):
score += 10
types_present = [b for b in bc.BASIC_LAND_TYPE_KEYWORDS if b in tline_l]
score += len(types_present) * 3
if 'unless you control' in text_l:
score += 2
if 'cycling' in text_l:
score += 1
return score
def is_color_fixing_land(tline: str, text_lower: str) -> bool:
"""Heuristic to detect if a land significantly fixes colors.
Criteria:
- Two or more basic land types
- Produces any color (explicit text)
- Text shows two or more distinct colored mana symbols
"""
basic_count = sum(1 for bk in bc.BASIC_LAND_TYPE_KEYWORDS if bk in tline.lower())
if basic_count >= 2:
return True
if any(p in text_lower for p in bc.ANY_COLOR_MANA_PHRASES):
return True
distinct = {cw for cw in bc.COLORED_MANA_SYMBOLS if cw in text_lower}
return len(distinct) >= 2
# ---------------------------------------------------------------------------
# Weighted sampling & fetch helpers
# ---------------------------------------------------------------------------
def weighted_sample_without_replacement(pool: list[tuple[str, int | float]], k: int, rng=None) -> list[str]:
"""Sample up to k unique names from (name, weight) pool without replacement.
If total weight becomes 0, stops early. Stable for small pools used here.
"""
if k <= 0 or not pool:
return []
import random as _rand
local_rng = rng if rng is not None else _rand
working = pool.copy()
chosen: list[str] = []
while working and len(chosen) < k:
total_w = sum(max(0, float(w)) for _, w in working)
if total_w <= 0:
break
r = local_rng.random() * total_w
acc = 0.0
pick_idx = 0
for idx, (nm, w) in enumerate(working):
acc += max(0, float(w))
if r <= acc:
pick_idx = idx
break
nm, _w = working.pop(pick_idx)
chosen.append(nm)
return chosen
def count_existing_fetches(card_library: dict) -> int:
bc = __import__('deck_builder.builder_constants', fromlist=['FETCH_LAND_MAX_CAP'])
total = 0
generic = getattr(bc, 'GENERIC_FETCH_LANDS', [])
for n in generic:
if n in card_library:
total += card_library[n].get('Count', 1)
for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values():
for n in seq:
if n in card_library:
total += card_library[n].get('Count', 1)
return total
def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: int) -> list[tuple[int,str,str,str]]:
"""Return list of (edh_rank, name, type_line, text_lower) for top_n remaining lands.
Falls back to large rank number if edhrecRank missing/unparseable.
"""
out: list[tuple[int,str,str,str]] = []
if df is None or getattr(df, 'empty', True):
return out
for _, row in df.iterrows(): # type: ignore[attr-defined]
try:
name = str(row.get('name',''))
if not name or name in already or name in basics:
continue
tline = str(row.get('type', row.get('type_line','')))
if 'land' not in tline.lower():
continue
edh = row.get('edhrecRank') if 'edhrecRank' in df.columns else None
try:
edh_val = int(edh) if edh not in (None,'','nan') else 999999
except Exception:
edh_val = 999999
text_lower = str(row.get('text', row.get('oracleText',''))).lower()
out.append((edh_val, name, tline, text_lower))
except Exception:
continue
out.sort(key=lambda x: x[0])
return out[:top_n]
# ---------------------------------------------------------------------------
# Tag-driven land suggestion helpers
# ---------------------------------------------------------------------------
def build_tag_driven_suggestions(builder) -> list[dict]: # type: ignore[override]
"""Return a list of suggestion dicts based on selected commander tags.
Each dict fields:
name, reason, condition (callable taking builder), flex (bool), defer_if_full (bool)
"""
tags_lower = [t.lower() for t in getattr(builder, 'selected_tags', [])]
existing = set(builder.card_library.keys())
suggestions: list[dict] = []
def cond_always(_):
return True
def cond_artifact_threshold(b):
art_count = sum(1 for v in b.card_library.values() if 'artifact' in str(v.get('Card Type', '')).lower())
return art_count >= 10
mapping = [
(['+1/+1 counters', 'counters matter'], 'Gavony Township', cond_always, '+1/+1 Counters support', True),
(['token', 'tokens', 'wide'], 'Castle Ardenvale', cond_always, 'Token strategy support', True),
(['graveyard', 'recursion', 'reanimator'], 'Boseiju, Who Endures', cond_always, 'Graveyard interaction / utility', False),
(['graveyard', 'recursion', 'reanimator'], 'Takenuma, Abandoned Mire', cond_always, 'Recursion utility', True),
(['artifact'], "Inventors' Fair", cond_artifact_threshold, 'Artifact payoff (conditional)', True),
]
for tag_keys, land_name, condition, reason, flex in mapping:
if any(k in tl for k in tag_keys for tl in tags_lower):
if land_name not in existing:
suggestions.append({
'name': land_name,
'reason': reason,
'condition': condition,
'flex': flex,
'defer_if_full': True
})
# Landfall fetch cap soft bump (side-effect set on builder)
if any('landfall' in tl for tl in tags_lower) and not hasattr(builder, '_landfall_fetch_bump_applied'):
setattr(builder, '_landfall_fetch_bump_applied', True)
builder.dynamic_fetch_cap = getattr(__import__('deck_builder.builder_constants', fromlist=['FETCH_LAND_MAX_CAP']), 'FETCH_LAND_MAX_CAP', 7) + 1 # safe fallback
return suggestions
# ---------------------------------------------------------------------------
# Color balance swap helpers
# ---------------------------------------------------------------------------
def select_color_balance_removal(builder, deficit_colors: set[str], overages: dict[str, float]) -> str | None:
"""Select a land to remove when performing color balance swaps.
Preference order:
1. Flex lands not producing any deficit colors
2. Basic land of the most overrepresented color
3. Mono-color non-flex land not producing deficit colors
"""
matrix_current = builder._compute_color_source_matrix()
# Flex first
for name, entry in builder.card_library.items():
if entry.get('Role') == 'flex':
colors = matrix_current.get(name, {})
if not any(colors.get(c, 0) for c in deficit_colors):
return name
# Basic of most overrepresented color
if overages:
color_remove = max(overages.items(), key=lambda x: x[1])[0]
basic_map = {'W': 'Plains', 'U': 'Island', 'B': 'Swamp', 'R': 'Mountain', 'G': 'Forest'}
candidate = basic_map.get(color_remove)
if candidate and candidate in builder.card_library:
return candidate
# Mono-color non-flex
for name, entry in builder.card_library.items():
if entry.get('Role') == 'flex':
continue
colors = matrix_current.get(name, {})
color_count = sum(1 for v in colors.values() if v)
if color_count <= 1 and not any(colors.get(c, 0) for c in deficit_colors):
return name
return None
def color_balance_addition_candidates(builder, target_color: str, combined_df) -> list[str]:
"""Rank potential addition lands for a target color (best first)."""
if combined_df is None or getattr(combined_df, 'empty', True):
return []
existing = set(builder.card_library.keys())
out: list[tuple[str, int]] = []
for _, row in combined_df.iterrows(): # type: ignore[attr-defined]
name = str(row.get('name', ''))
if not name or name in existing or any(name == o[0] for o in out):
continue
tline = str(row.get('type', row.get('type_line', ''))).lower()
if 'land' not in tline:
continue
text_field = str(row.get('text', row.get('oracleText', ''))).lower()
produces = False
if target_color == 'W' and ('plains' in tline or '{w}' in text_field):
produces = True
if target_color == 'U' and ('island' in tline or '{u}' in text_field):
produces = True
if target_color == 'B' and ('swamp' in tline or '{b}' in text_field):
produces = True
if target_color == 'R' and ('mountain' in tline or '{r}' in text_field):
produces = True
if target_color == 'G' and ('forest' in tline or '{g}' in text_field):
produces = True
if not produces:
continue
any_color = 'add one mana of any color' in text_field
basic_types = sum(1 for b in bc.BASIC_LAND_TYPE_KEYWORDS if b in tline)
score = 0
if any_color:
score += 30
score += basic_types * 10
if 'enters the battlefield tapped' in text_field and 'you may pay 2 life' not in text_field:
score -= 5
out.append((name, score))
out.sort(key=lambda x: x[1], reverse=True)
return [n for n, _ in out]
# ---------------------------------------------------------------------------
# Basic land / land cap helpers
# ---------------------------------------------------------------------------
def basic_land_names() -> set[str]:
names = set(getattr(__import__('deck_builder.builder_constants', fromlist=['BASIC_LANDS']), 'BASIC_LANDS', []))
names.update(getattr(__import__('deck_builder.builder_constants', fromlist=['SNOW_BASIC_LAND_MAPPING']), 'SNOW_BASIC_LAND_MAPPING', {}).values())
names.add('Wastes')
return names
def count_basic_lands(card_library: dict) -> int:
basics = basic_land_names()
total = 0
for name, entry in card_library.items():
if name in basics:
total += entry.get('Count', 1)
return total
def choose_basic_to_trim(card_library: dict) -> str | None:
basics = basic_land_names()
candidates: list[tuple[int, str]] = []
for name, entry in card_library.items():
if name in basics:
cnt = entry.get('Count', 1)
if cnt > 0:
candidates.append((cnt, name))
if not candidates:
return None
candidates.sort(reverse=True)
return candidates[0][1]
def enforce_land_cap(builder, step_label: str = ""):
if not hasattr(builder, 'ideal_counts') or not getattr(builder, 'ideal_counts'):
return
bc = __import__('deck_builder.builder_constants', fromlist=['DEFAULT_LAND_COUNT'])
land_target = builder.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35))
min_basic = builder.ideal_counts.get('basic_lands', getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20))
import math
floor_basics = math.ceil(bc.BASIC_FLOOR_FACTOR * min_basic)
current_land = builder._current_land_count()
if current_land <= land_target:
return
builder.output_func(f"\nLand Cap Enforcement after {step_label}: Over target ({current_land}/{land_target}). Trimming basics...")
removed = 0
while current_land > land_target:
basic_total = count_basic_lands(builder.card_library)
if basic_total <= floor_basics:
builder.output_func(f"Stopped trimming: basic lands at floor {basic_total} (floor {floor_basics}). Still {current_land}/{land_target}.")
break
target_basic = choose_basic_to_trim(builder.card_library)
if not target_basic or not builder._decrement_card(target_basic):
builder.output_func("No basic lands available to trim further.")
break
removed += 1
current_land = builder._current_land_count()
if removed:
builder.output_func(f"Trimmed {removed} basic land(s). New land count: {current_land}/{land_target}. Basic total now {count_basic_lands(builder.card_library)} (floor {floor_basics}).")

View file

@ -2,7 +2,7 @@ from deck_builder.builder import DeckBuilder
# Non-interactive harness: chooses specified commander, first tag, first bracket, accepts defaults
def run(command_name: str = "Finneas, Ace Archer"):
def run(command_name: str = "Rocco, Street Chef"):
scripted_inputs = []
# Commander query
scripted_inputs.append(command_name) # initial query
@ -16,7 +16,7 @@ def run(command_name: str = "Finneas, Ace Archer"):
# Stop after primary (tertiary prompt enters 0)
scripted_inputs.append("0")
# Bracket selection: choose 3 (Typical Casual mid default) else 2 maybe; pick 3
scripted_inputs.append("3")
scripted_inputs.append("5")
# Ideal counts prompts (8 prompts) -> press Enter (empty) to accept defaults
for _ in range(8):
scripted_inputs.append("")
@ -32,7 +32,21 @@ def run(command_name: str = "Finneas, Ace Archer"):
b.run_deck_build_step2()
b.run_land_step1()
b.run_land_step2()
# Land Step 3: Kindred lands (if applicable)
b.run_land_step3()
# Land Step 4: Fetch lands (request exactly 3)
b.run_land_step4(requested_count=3)
# Land Step 5: Dual lands (use default desired)
b.run_land_step5()
# Land Step 6: Triple lands (use default desired 1-2)
b.run_land_step6()
# Land Step 7: Misc utility lands
b.run_land_step7()
# Land Step 8: Optimize tapped lands
b.run_land_step8()
b.print_card_library()
# Run post-spell (currently just analysis since spells not added in this harness)
b.post_spell_land_adjust()
return b
if __name__ == "__main__":