mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
798 lines
28 KiB
Python
798 lines
28 KiB
Python
"""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
|
|
import ast
|
|
import random as _rand
|
|
|
|
from . import builder_constants as bc
|
|
import math
|
|
|
|
COLOR_LETTERS = ['W', 'U', 'B', 'R', 'G']
|
|
|
|
|
|
def parse_theme_tags(val) -> list[str]:
|
|
"""Robustly parse a themeTags cell that may be a list, nested list, or string-repr.
|
|
|
|
Handles formats like:
|
|
['Tag1', 'Tag2']
|
|
"['Tag1', 'Tag2']"
|
|
Tag1, Tag2
|
|
Returns list of stripped string tags (may be empty)."""
|
|
if isinstance(val, list):
|
|
flat: list[str] = []
|
|
for v in val:
|
|
if isinstance(v, list):
|
|
flat.extend(str(x) for x in v)
|
|
else:
|
|
flat.append(str(v))
|
|
return [s.strip() for s in flat if s and str(s).strip()]
|
|
if isinstance(val, str):
|
|
s = val.strip()
|
|
# Try literal list first
|
|
try:
|
|
parsed = ast.literal_eval(s)
|
|
if isinstance(parsed, list):
|
|
return [str(x).strip() for x in parsed if str(x).strip()]
|
|
except Exception:
|
|
pass
|
|
# Fallback comma split
|
|
if s.startswith('[') and s.endswith(']'):
|
|
s = s[1:-1]
|
|
parts = [p.strip().strip("'\"") for p in s.split(',')]
|
|
out: list[str] = []
|
|
for p in parts:
|
|
if not p:
|
|
continue
|
|
clean = re.sub(r"^[\[\s']+|[\]\s']+$", '', p)
|
|
if clean:
|
|
out.append(clean)
|
|
return out
|
|
return []
|
|
|
|
|
|
def normalize_theme_list(raw) -> list[str]:
|
|
"""Parse then lowercase + strip each tag."""
|
|
tags = parse_theme_tags(raw)
|
|
return [t.lower().strip() for t in tags if t and t.strip()]
|
|
|
|
|
|
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
|
|
"""Build a matrix mapping card name -> {color: 0/1} indicating if that card
|
|
can (reliably) produce each color of mana on the battlefield.
|
|
|
|
Notes:
|
|
- Includes lands and non-lands (artifacts/creatures/enchantments/planeswalkers) that produce mana.
|
|
- Excludes instants/sorceries (rituals) by design; this is a "source" count, not ramp burst.
|
|
- Any-color effects set W/U/B/R/G (not C). Colorless '{C}' is tracked separately.
|
|
- For lands, we also infer from basic land types in the type line. For non-lands, we rely on text.
|
|
- Fallback name mapping applies only to exact basic lands (incl. Snow-Covered) and Wastes.
|
|
|
|
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():
|
|
row = lookup.get(name, {})
|
|
entry_type = str(entry.get('Card Type') or entry.get('Type') or '').lower()
|
|
tline_full = str(row.get('type', row.get('type_line', '')) or '').lower()
|
|
# Land or permanent that could produce mana via text
|
|
is_land = ('land' in entry_type) or ('land' in tline_full)
|
|
text_field = str(row.get('text', row.get('oracleText', '')) or '').lower()
|
|
# Skip obvious non-permanents (rituals etc.)
|
|
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
|
|
continue
|
|
# Keep only candidates that are lands OR whose text indicates mana production
|
|
produces_from_text = False
|
|
tf = text_field
|
|
if tf:
|
|
# Common patterns: "Add {G}", "Add {C}{C}", "Add one mana of any color/colour"
|
|
produces_from_text = (
|
|
('add one mana of any color' in tf) or
|
|
('add one mana of any colour' in tf) or
|
|
('add ' in tf and ('{w}' in tf or '{u}' in tf or '{b}' in tf or '{r}' in tf or '{g}' in tf or '{c}' in tf))
|
|
)
|
|
if not (is_land or produces_from_text):
|
|
continue
|
|
# Combine entry type and snapshot type line for robust parsing
|
|
tline = (entry_type + ' ' + tline_full).strip()
|
|
colors = {c: 0 for c in (COLOR_LETTERS + ['C'])}
|
|
# Land type-based inference
|
|
if is_land:
|
|
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
|
|
# Text-based inference for both lands and non-lands
|
|
if (
|
|
'add one mana of any color' in tf or
|
|
'add one mana of any colour' in tf or
|
|
('add' in tf and ('mana of any color' in tf or 'mana of any one color' in tf or 'any color of mana' in tf))
|
|
):
|
|
for k in COLOR_LETTERS:
|
|
colors[k] = 1
|
|
# Explicit colored/colorless symbols in add context
|
|
if 'add' in tf:
|
|
if '{w}' in tf:
|
|
colors['W'] = 1
|
|
if '{u}' in tf:
|
|
colors['U'] = 1
|
|
if '{b}' in tf:
|
|
colors['B'] = 1
|
|
if '{r}' in tf:
|
|
colors['R'] = 1
|
|
if '{g}' in tf:
|
|
colors['G'] = 1
|
|
if '{c}' in tf or 'colorless' in tf:
|
|
colors['C'] = 1
|
|
# Fallback: infer only for exact basic land names (incl. Snow-Covered) and Wastes
|
|
if not any(colors.values()) and is_land:
|
|
nm = str(name)
|
|
base = nm
|
|
if nm.startswith('Snow-Covered '):
|
|
base = nm[len('Snow-Covered '):]
|
|
mapping = {
|
|
'Plains': 'W',
|
|
'Island': 'U',
|
|
'Swamp': 'B',
|
|
'Mountain': 'R',
|
|
'Forest': 'G',
|
|
'Wastes': 'C',
|
|
}
|
|
col = mapping.get(base)
|
|
if col:
|
|
colors[col] = 1
|
|
# Only include cards that produced at least one color
|
|
if any(colors.values()):
|
|
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',
|
|
'parse_theme_tags',
|
|
'normalize_theme_list',
|
|
'detect_viable_multi_copy_archetypes',
|
|
'prefer_owned_first',
|
|
'compute_adjusted_target',
|
|
'normalize_tag_cell',
|
|
'sort_by_priority',
|
|
'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 compute_adjusted_target(category_label: str,
|
|
original_cfg: int,
|
|
existing: int,
|
|
output_func,
|
|
plural_word: str | None = None,
|
|
bonus_max_pct: float = 0.2,
|
|
rng=None) -> tuple[int, int]:
|
|
"""Compute how many additional cards of a category to add applying a random bonus.
|
|
|
|
Returns (to_add, bonus). to_add may be 0 if target already satisfied and bonus doesn't push above existing.
|
|
|
|
Parameters
|
|
----------
|
|
category_label : str
|
|
Human-readable label (e.g. 'Ramp', 'Removal').
|
|
original_cfg : int
|
|
Configured target count.
|
|
existing : int
|
|
How many already present.
|
|
output_func : callable
|
|
Function for emitting messages (e.g. print or logger).
|
|
plural_word : str | None
|
|
Phrase used in messages for plural additions. If None derives from label (lower + ' spells').
|
|
bonus_max_pct : float
|
|
Upper bound for random bonus percent (default 0.2 => up to +20%).
|
|
rng : object | None
|
|
Optional random-like object with uniform().
|
|
"""
|
|
if original_cfg <= 0:
|
|
return 0, 0
|
|
plural_word = plural_word or f"{category_label.lower()} spells"
|
|
# Random bonus between 0 and bonus_max_pct inclusive
|
|
roll = (rng.uniform(0.0, bonus_max_pct) if rng else _rand.uniform(0.0, bonus_max_pct))
|
|
bonus = math.ceil(original_cfg * roll) if original_cfg > 0 else 0
|
|
if existing >= original_cfg:
|
|
to_add = original_cfg + bonus - existing
|
|
if to_add <= 0:
|
|
output_func(f"{category_label} target met ({existing}/{original_cfg}). Random bonus {bonus} -> no additional {plural_word} needed.")
|
|
return 0, bonus
|
|
output_func(f"{category_label} target met ({existing}/{original_cfg}). Adding random bonus {bonus}; scheduling {to_add} extra {plural_word}.")
|
|
return to_add, bonus
|
|
remaining_need = original_cfg - existing
|
|
to_add = remaining_need + bonus
|
|
output_func(f"Existing {category_label.lower()} {existing}/{original_cfg}. Remaining need {remaining_need}. Random bonus {bonus}. Adding {to_add} {plural_word}.")
|
|
return to_add, bonus
|
|
|
|
|
|
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 []
|
|
# _rand imported at module level
|
|
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]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic DataFrame helpers (tag normalization & sorting)
|
|
# ---------------------------------------------------------------------------
|
|
def normalize_tag_cell(cell):
|
|
"""Normalize a themeTags-like cell into a lowercase list of tags.
|
|
|
|
Accepts list, nested list, or string forms. Mirrors logic previously in multiple
|
|
methods inside builder.py.
|
|
"""
|
|
if isinstance(cell, list):
|
|
out: list[str] = []
|
|
for v in cell:
|
|
if isinstance(v, list):
|
|
out.extend(str(x).strip().lower() for x in v if str(x).strip())
|
|
else:
|
|
vs = str(v).strip().lower()
|
|
if vs:
|
|
out.append(vs)
|
|
return out
|
|
if isinstance(cell, str):
|
|
raw = cell.lower()
|
|
for ch in '[]"':
|
|
raw = raw.replace(ch, ' ')
|
|
parts = [p.strip().strip("'\"") for p in raw.replace(';', ',').split(',') if p.strip()]
|
|
return [p for p in parts if p]
|
|
return []
|
|
|
|
|
|
def sort_by_priority(df, columns: list[str]):
|
|
"""Sort DataFrame by listed columns ascending if present; ignores missing.
|
|
|
|
Returns new DataFrame (does not mutate original)."""
|
|
present = [c for c in columns if c in df.columns]
|
|
if not present:
|
|
return df
|
|
return df.sort_values(by=present, ascending=[True]*len(present), na_position='last')
|
|
|
|
|
|
def _normalize_tags_list(tags: list[str]) -> list[str]:
|
|
out: list[str] = []
|
|
seen = set()
|
|
for t in tags or []:
|
|
tt = str(t).strip().lower()
|
|
if tt and tt not in seen:
|
|
out.append(tt)
|
|
seen.add(tt)
|
|
return out
|
|
|
|
|
|
def _color_subset_ok(required: list[str], commander_ci: list[str]) -> bool:
|
|
if not required:
|
|
return True
|
|
ci = {c.upper() for c in commander_ci}
|
|
need = {c.upper() for c in required}
|
|
return need.issubset(ci)
|
|
|
|
|
|
def detect_viable_multi_copy_archetypes(builder) -> list[dict]:
|
|
"""Return ranked viable multi-copy archetypes for the given builder.
|
|
|
|
Output items: { id, name, printed_cap, type_hint, score, reasons }
|
|
Never raises; returns [] on missing data.
|
|
"""
|
|
try:
|
|
from . import builder_constants as bc
|
|
except Exception:
|
|
return []
|
|
# Commander color identity and tags
|
|
try:
|
|
ci = list(getattr(builder, 'color_identity', []) or [])
|
|
except Exception:
|
|
ci = []
|
|
# Gather tags from selected + commander summary
|
|
tags: list[str] = []
|
|
try:
|
|
tags.extend([t for t in getattr(builder, 'selected_tags', []) or []])
|
|
except Exception:
|
|
pass
|
|
try:
|
|
cmd = getattr(builder, 'commander_dict', {}) or {}
|
|
themes = cmd.get('Themes', [])
|
|
if isinstance(themes, list):
|
|
tags.extend(themes)
|
|
except Exception:
|
|
pass
|
|
tags_norm = _normalize_tags_list(tags)
|
|
out: list[dict] = []
|
|
# Exclusivity prep: if multiple in same group qualify, we still compute score, suppression happens in consumer or by taking top one.
|
|
for aid, meta in getattr(bc, 'MULTI_COPY_ARCHETYPES', {}).items():
|
|
try:
|
|
# Color gate
|
|
if not _color_subset_ok(meta.get('color_identity', []), ci):
|
|
continue
|
|
# Tag triggers
|
|
trig = meta.get('triggers', {}) or {}
|
|
any_tags = _normalize_tags_list(trig.get('tags_any', []) or [])
|
|
all_tags = _normalize_tags_list(trig.get('tags_all', []) or [])
|
|
score = 0
|
|
reasons: list[str] = []
|
|
# +2 for color match baseline
|
|
if meta.get('color_identity'):
|
|
score += 2
|
|
reasons.append('color identity fits')
|
|
# +1 per matched any tag (cap small to avoid dwarfing)
|
|
matches_any = [t for t in any_tags if t in tags_norm]
|
|
if matches_any:
|
|
bump = min(3, len(matches_any))
|
|
score += bump
|
|
reasons.append('tags: ' + ', '.join(matches_any[:3]))
|
|
# +1 if all required tags matched
|
|
if all_tags and all(t in tags_norm for t in all_tags):
|
|
score += 1
|
|
reasons.append('all required tags present')
|
|
if score <= 0:
|
|
continue
|
|
out.append({
|
|
'id': aid,
|
|
'name': meta.get('name', aid),
|
|
'printed_cap': meta.get('printed_cap'),
|
|
'type_hint': meta.get('type_hint', 'noncreature'),
|
|
'exclusive_group': meta.get('exclusive_group'),
|
|
'default_count': meta.get('default_count', 25),
|
|
'rec_window': meta.get('rec_window', (20,30)),
|
|
'thrumming_stone_synergy': bool(meta.get('thrumming_stone_synergy', True)),
|
|
'score': score,
|
|
'reasons': reasons,
|
|
})
|
|
except Exception:
|
|
continue
|
|
# Suppress lower-scored siblings within the same exclusive group, keep the highest per group
|
|
grouped: dict[str, list[dict]] = {}
|
|
rest: list[dict] = []
|
|
for item in out:
|
|
grp = item.get('exclusive_group')
|
|
if grp:
|
|
grouped.setdefault(grp, []).append(item)
|
|
else:
|
|
rest.append(item)
|
|
kept: list[dict] = rest[:]
|
|
for grp, items in grouped.items():
|
|
items.sort(key=lambda d: d.get('score', 0), reverse=True)
|
|
kept.append(items[0])
|
|
kept.sort(key=lambda d: d.get('score', 0), reverse=True)
|
|
return kept
|
|
|
|
|
|
def prefer_owned_first(df, owned_names_lower: set[str], name_col: str = 'name'):
|
|
"""Stable-reorder DataFrame to put owned names first while preserving prior sort.
|
|
|
|
- Adds a temporary column to flag ownership, sorts by it desc with mergesort, then drops it.
|
|
- If the name column is missing or owned_names_lower empty, returns df unchanged.
|
|
"""
|
|
try:
|
|
if df is None or getattr(df, 'empty', True):
|
|
return df
|
|
if not owned_names_lower:
|
|
return df
|
|
if name_col not in df.columns:
|
|
return df
|
|
tmp_col = '_ownedPref'
|
|
# Avoid clobbering if already present
|
|
while tmp_col in df.columns:
|
|
tmp_col = tmp_col + '_x'
|
|
ser = df[name_col].astype(str).str.lower().isin(owned_names_lower).astype(int)
|
|
df = df.assign(**{tmp_col: ser})
|
|
df = df.sort_values(by=[tmp_col], ascending=[False], kind='mergesort')
|
|
df = df.drop(columns=[tmp_col])
|
|
return df
|
|
except Exception:
|
|
return df
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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()
|
|
land_names = set(matrix_current.keys()) # ensure we only ever remove lands
|
|
# Flex lands first
|
|
for name, entry in builder.card_library.items():
|
|
if name not in land_names:
|
|
continue
|
|
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 and candidate in land_names:
|
|
return candidate
|
|
# Mono-color non-flex lands
|
|
for name, entry in builder.card_library.items():
|
|
if name not in land_names:
|
|
continue
|
|
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))
|
|
# math not needed; using ceil via BASIC_FLOOR_FACTOR logic only
|
|
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}).")
|
|
|