mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
232 lines
9.6 KiB
Python
232 lines
9.6 KiB
Python
![]() |
from __future__ import annotations
|
||
|
|
||
|
from typing import Optional, List, Dict, Set
|
||
|
import re
|
||
|
|
||
|
from .. import builder_constants as bc
|
||
|
|
||
|
|
||
|
class LandTripleMixin:
|
||
|
"""Mixin providing logic for adding three-color (triple) lands (Step 6).
|
||
|
|
||
|
Extraction rationale:
|
||
|
- Isolates a coherent land selection concern from the monolithic builder.
|
||
|
- Mirrors earlier land step mixins with add_* and run_land_step6 methods.
|
||
|
|
||
|
Strategy:
|
||
|
1. Determine if the deck's color identity has at least 3 colors; else skip.
|
||
|
2. Build a pool of candidate triple lands whose type line / name indicates they
|
||
|
produce at least three of the deck colors (heuristic; full rules parsing is
|
||
|
intentionally avoided for speed / simplicity with CSV data).
|
||
|
3. Avoid adding duplicates or previously selected lands.
|
||
|
4. Trim basics (above a computed floor) if capacity is reached and we still
|
||
|
desire triple lands.
|
||
|
5. Respect user-provided requested_count if supplied; otherwise fall back to
|
||
|
default constant and capacity.
|
||
|
6. Apply a simple ranking + slight randomization for determinism + variety.
|
||
|
"""
|
||
|
|
||
|
def add_triple_lands(self, requested_count: Optional[int] = None):
|
||
|
# Preconditions: color identity & dataframes
|
||
|
if not getattr(self, 'files_to_load', None):
|
||
|
try:
|
||
|
self.determine_color_identity()
|
||
|
self.setup_dataframes()
|
||
|
except Exception as e: # pragma: no cover - defensive
|
||
|
self.output_func(f"Cannot add triple lands until setup complete: {e}")
|
||
|
return
|
||
|
|
||
|
colors = [c for c in getattr(self, 'color_identity', []) if c in ['W','U','B','R','G']]
|
||
|
if len(colors) < 3:
|
||
|
self.output_func("Triple Lands: Fewer than three colors; skipping step 6.")
|
||
|
return
|
||
|
|
||
|
land_target = getattr(self, 'ideal_counts', {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) if getattr(self, 'ideal_counts', None) else getattr(bc, 'DEFAULT_LAND_COUNT', 35)
|
||
|
|
||
|
df = getattr(self, '_combined_cards_df', None)
|
||
|
if df is None or df.empty or not {'name','type'}.issubset(df.columns):
|
||
|
self.output_func("Triple Lands: No combined card dataframe or missing columns; skipping.")
|
||
|
return
|
||
|
|
||
|
pool: List[str] = []
|
||
|
meta: Dict[str, str] = {}
|
||
|
wanted: Set[str] = set(colors)
|
||
|
basic_map = {
|
||
|
'plains': 'W',
|
||
|
'island': 'U',
|
||
|
'swamp': 'B',
|
||
|
'mountain': 'R',
|
||
|
'forest': 'G',
|
||
|
}
|
||
|
|
||
|
for _, row in df.iterrows(): # type: ignore
|
||
|
try:
|
||
|
name = str(row.get('name',''))
|
||
|
if not name or name in self.card_library:
|
||
|
continue
|
||
|
tline = str(row.get('type','')).lower()
|
||
|
if 'land' not in tline:
|
||
|
continue
|
||
|
# Heuristic: count unique basic types in type line
|
||
|
types_present = [b for b in basic_map if b in tline]
|
||
|
mapped = {basic_map[b] for b in types_present}
|
||
|
|
||
|
# Extract color production from rules text if present
|
||
|
text_field = str(row.get('text', row.get('oracleText',''))).lower()
|
||
|
color_syms = set(re.findall(r'\{([wubrg])\}', text_field))
|
||
|
color_syms_mapped = {c.upper() for c in color_syms}
|
||
|
|
||
|
lname = name.lower()
|
||
|
tri_keywords = [
|
||
|
'triome','panorama','citadel','tower','hub','garden','headquarters','sanctuary',
|
||
|
'stronghold','outpost','campus','shrine','domain','estate'
|
||
|
]
|
||
|
|
||
|
# Decide if candidate qualifies:
|
||
|
qualifies_by_types = len(mapped) >= 3
|
||
|
qualifies_by_text = len(color_syms_mapped) >= 3 and color_syms_mapped.issubset(wanted)
|
||
|
qualifies_by_name = any(kw in lname for kw in tri_keywords)
|
||
|
|
||
|
if not (qualifies_by_types or qualifies_by_text or (qualifies_by_name and (len(mapped) >= 2 or len(color_syms_mapped) >= 2))):
|
||
|
continue
|
||
|
|
||
|
# Consolidate produced colors for validation (prefer typed, else text)
|
||
|
produced = mapped if mapped else color_syms_mapped
|
||
|
if not produced.issubset(wanted):
|
||
|
continue
|
||
|
|
||
|
if qualifies_by_types or len(produced) >= 3:
|
||
|
pool.append(name)
|
||
|
meta[name] = tline
|
||
|
else:
|
||
|
pool.append(name)
|
||
|
meta[name] = tline + ' (heuristic-tri)'
|
||
|
except Exception: # pragma: no cover - defensive
|
||
|
continue
|
||
|
|
||
|
# De-duplicate while preserving order
|
||
|
pool = list(dict.fromkeys(pool))
|
||
|
if not pool:
|
||
|
self.output_func("Triple Lands: No candidates found.")
|
||
|
return
|
||
|
|
||
|
# Ranking heuristic: fully triple-typed > untapped > others; penalize ETB tapped
|
||
|
def rank(name: str) -> int:
|
||
|
tline = meta.get(name, '')
|
||
|
score = 0
|
||
|
if '(heuristic-tri)' not in tline:
|
||
|
score += 5
|
||
|
if 'enters the battlefield tapped' not in tline:
|
||
|
score += 2
|
||
|
if 'cycling' in tline:
|
||
|
score += 1
|
||
|
if 'enters the battlefield tapped' in tline and 'you gain' in tline:
|
||
|
score -= 1
|
||
|
return score
|
||
|
|
||
|
pool.sort(key=lambda n: rank(n), reverse=True)
|
||
|
|
||
|
# Slight randomized shuffle weighted by rank for variety
|
||
|
rng = getattr(self, 'rng', None) or self._get_rng()
|
||
|
try:
|
||
|
weighted = []
|
||
|
for n in pool:
|
||
|
w = max(1, rank(n)) + 1
|
||
|
weighted.append((n, w))
|
||
|
shuffled: List[str] = []
|
||
|
while weighted:
|
||
|
total = sum(w for _, w in weighted)
|
||
|
r = rng.random() * total
|
||
|
acc = 0.0
|
||
|
for idx, (n, w) in enumerate(weighted):
|
||
|
acc += w
|
||
|
if r <= acc:
|
||
|
shuffled.append(n)
|
||
|
del weighted[idx]
|
||
|
break
|
||
|
pool = shuffled
|
||
|
except Exception: # pragma: no cover - fallback
|
||
|
pass
|
||
|
|
||
|
# Capacity handling
|
||
|
remaining_capacity = max(0, land_target - self._current_land_count())
|
||
|
default_triple_target = getattr(bc, 'TRIPLE_LAND_DEFAULT_COUNT', 3)
|
||
|
effective_default = min(default_triple_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool))
|
||
|
if requested_count is None:
|
||
|
desired = effective_default
|
||
|
else:
|
||
|
desired = max(0, int(requested_count))
|
||
|
if desired == 0:
|
||
|
self.output_func("Triple Lands: Desired count 0; skipping.")
|
||
|
return
|
||
|
|
||
|
min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20)
|
||
|
if hasattr(self, 'ideal_counts') and self.ideal_counts:
|
||
|
min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg)
|
||
|
basic_floor = self._basic_floor(min_basic_cfg)
|
||
|
|
||
|
if remaining_capacity == 0 and desired > 0:
|
||
|
slots_needed = desired
|
||
|
freed = 0
|
||
|
while freed < slots_needed and self._count_basic_lands() > basic_floor:
|
||
|
target_basic = self._choose_basic_to_trim()
|
||
|
if not target_basic:
|
||
|
break
|
||
|
if not self._decrement_card(target_basic):
|
||
|
break
|
||
|
freed += 1
|
||
|
if freed == 0:
|
||
|
desired = 0
|
||
|
remaining_capacity = max(0, land_target - self._current_land_count())
|
||
|
desired = min(desired, remaining_capacity, len(pool))
|
||
|
if desired <= 0:
|
||
|
self.output_func("Triple Lands: No capacity after trimming; skipping.")
|
||
|
return
|
||
|
|
||
|
added: List[str] = []
|
||
|
for name in pool:
|
||
|
if len(added) >= desired or self._current_land_count() >= land_target:
|
||
|
break
|
||
|
# Infer color trio from type line basic types
|
||
|
try:
|
||
|
row_match = df[df['name'] == name]
|
||
|
tline = ''
|
||
|
text_field = ''
|
||
|
sub_role = None
|
||
|
if not row_match.empty:
|
||
|
rw = row_match.iloc[0]
|
||
|
tline = str(rw.get('type','')).lower()
|
||
|
text_field = str(rw.get('text', rw.get('oracleText',''))).lower()
|
||
|
trio = []
|
||
|
for basic, col in [('plains','W'),('island','U'),('swamp','B'),('mountain','R'),('forest','G')]:
|
||
|
if basic in tline:
|
||
|
trio.append(col)
|
||
|
if len(trio) < 3:
|
||
|
color_syms = set(re.findall(r'\{([wubrg])\}', text_field))
|
||
|
trio = [c.upper() for c in color_syms]
|
||
|
if len(trio) >= 2:
|
||
|
sub_role = ''.join(sorted(set(trio)))
|
||
|
except Exception:
|
||
|
sub_role = None
|
||
|
self.add_card(
|
||
|
name,
|
||
|
card_type='Land',
|
||
|
role='triple',
|
||
|
sub_role=sub_role,
|
||
|
added_by='lands_step6'
|
||
|
)
|
||
|
added.append(name)
|
||
|
|
||
|
self.output_func("\nTriple Lands Added (Step 6):")
|
||
|
if not added:
|
||
|
self.output_func(" (None added)")
|
||
|
else:
|
||
|
width = max(len(n) for n in added)
|
||
|
for n in added:
|
||
|
self.output_func(f" {n.ljust(width)} : 1")
|
||
|
self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}")
|
||
|
|
||
|
def run_land_step6(self, requested_count: Optional[int] = None):
|
||
|
self.add_triple_lands(requested_count=requested_count)
|
||
|
self._enforce_land_cap(step_label="Triples (Step 6)")
|