diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 40870e8..ec1598d 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -168,6 +168,16 @@ class DeckBuilder: # Deck library (cards added so far) mapping name->record card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict) + # Deferred suggested lands based on tags / conditions + suggested_lands_queue: List[Dict[str, Any]] = field(default_factory=list) + # Baseline color source matrix captured after land build, before spell adjustments + color_source_matrix_baseline: Optional[Dict[str, Dict[str,int]]] = None + # Live cached color source matrix (recomputed lazily when lands change) + _color_source_matrix_cache: Optional[Dict[str, Dict[str,int]]] = None + _color_source_cache_dirty: bool = True + # Cached spell pip weights (invalidate on non-land changes) + _spell_pip_weights_cache: Optional[Dict[str, float]] = None + _spell_pip_cache_dirty: bool = True # IO injection for testing input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt)) @@ -588,7 +598,8 @@ class DeckBuilder: 'Creature Types': creature_types, 'Tags': tags, 'Commander': is_commander, - 'Count': 1 + 'Count': 1, + 'Role': None # placeholder for 'flex', 'suggested', etc. } # Keep commander dict CMC up to date if adding commander if is_commander and self.commander_dict: @@ -596,6 +607,14 @@ class DeckBuilder: self.commander_dict['CMC'] = mana_value # Remove this card from combined pool if present self._remove_from_pool(card_name) + # Invalidate color source cache if land added + try: + if 'land' in str(card_type).lower(): + self._color_source_cache_dirty = True + else: + self._spell_pip_cache_dirty = True + except Exception: + pass def _remove_from_pool(self, card_name: str): if self._combined_cards_df is None: @@ -731,11 +750,26 @@ class DeckBuilder: if land_target is None: land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) - # Early exit if already at or above target - if self._current_land_count() >= land_target: - self.output_func("Staple Lands: Land target already met; skipping step 2.") - return + # We allow swapping basics (above 90% min floor) to fit staple lands. + # If already at target, we'll attempt to free slots on-demand while iterating. + 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) + def ensure_capacity() -> bool: + """Try to free one land slot by trimming a basic (if above floor). Return True if capacity exists after call.""" + if self._current_land_count() < land_target: + return True + # Need to free one slot + if self._count_basic_lands() <= basic_floor: + return False + target_basic = self._choose_basic_to_trim() + if not target_basic: + return False + if not self._decrement_card(target_basic): + return False + return self._current_land_count() < land_target commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or []) colors = self.color_identity or [] # Commander power for conditions @@ -753,8 +787,9 @@ class DeckBuilder: added: List[str] = [] reasons: Dict[str, str] = {} for land_name, cond in getattr(bc, 'STAPLE_LAND_CONDITIONS', {}).items(): - # Stop if land target reached - if self._current_land_count() >= land_target: + # Ensure we have a slot (attempt to free basics if necessary) + if not ensure_capacity(): + self.output_func("Staple Lands: Cannot free capacity without violating basic floor; stopping additions.") break # Skip if already in library if land_name in self.card_library: @@ -793,6 +828,1226 @@ class DeckBuilder: def run_land_step2(self): """Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" self.add_staple_lands() + self._enforce_land_cap(step_label="Staples (Step 2)") + + # --------------------------- + # Land Building Step 3: Kindred / Creature-Type Focused Lands + # --------------------------- + def add_kindred_lands(self): + """Add kindred-oriented lands ONLY if a selected tag includes 'Kindred' or 'Tribal'. + + Baseline inclusions on kindred focus: + - Path of Ancestry (always when kindred) + - Cavern of Souls (<=4 colors) + - Three Tree City (>=2 colors) + Dynamic tribe-specific lands: derived only from selected tags (not all commander tags). + Capacity: may swap excess basics (above 90% floor) similar to other steps. + """ + # Ensure color identity loaded + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add kindred lands until color identity resolved: {e}") + return + + # Gate: only run if user-selected tag has kindred/tribal + if not any(('kindred' in t.lower() or 'tribal' in t.lower()) for t in (self.selected_tags or [])): + self.output_func("Kindred Lands: No selected kindred/tribal tag; skipping.") + return + + # Land target + if hasattr(self, 'ideal_counts') and self.ideal_counts: + land_target = self.ideal_counts.get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35)) + else: + land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) + + 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) + + def ensure_capacity() -> bool: + if self._current_land_count() < land_target: + return True + if self._count_basic_lands() <= basic_floor: + return False + target_basic = self._choose_basic_to_trim() + if not target_basic: + return False + if not self._decrement_card(target_basic): + return False + return self._current_land_count() < land_target + + colors = self.color_identity or [] + added: list[str] = [] + reasons: dict[str, str] = {} + + def try_add(name: str, reason: str): + if name in self.card_library: + return + if not ensure_capacity(): + return + self.add_card(name, card_type='Land') + added.append(name) + reasons[name] = reason + + # Baseline + try_add('Path of Ancestry', 'kindred focus') + if len(colors) <= 4: + try_add('Cavern of Souls', f"kindred focus ({len(colors)} colors)") + if len(colors) >= 2: + try_add('Three Tree City', f"kindred focus ({len(colors)} colors)") + + # Dynamic tribe references + tribe_terms: set[str] = set() + for tag in (self.selected_tags or []): + lower = tag.lower() + if 'kindred' in lower: + base = lower.replace('kindred', '').strip() + if base: + tribe_terms.add(base.split()[0]) + elif 'tribal' in lower: + base = lower.replace('tribal', '').strip() + if base: + tribe_terms.add(base.split()[0]) + + snapshot = self._full_cards_df + if snapshot is not None and not snapshot.empty and tribe_terms: + dynamic_limit = 5 + for tribe in sorted(tribe_terms): + if self._current_land_count() >= land_target or dynamic_limit <= 0: + break + tribe_lower = tribe.lower() + matches: list[str] = [] + for _, row in snapshot.iterrows(): + try: + nm = str(row.get('name', '')) + if not nm or nm in self.card_library: + continue + tline = str(row.get('type', row.get('type_line', ''))).lower() + if 'land' not in tline: + continue + text_field = row.get('text', row.get('oracleText', '')) + text_str = str(text_field).lower() if text_field is not None else '' + nm_lower = nm.lower() + if (tribe_lower in nm_lower or f" {tribe_lower}" in text_str or f"{tribe_lower} " in text_str or f"{tribe_lower}s" in text_str): + matches.append(nm) + except Exception: + continue + for nm in matches[:2]: + if self._current_land_count() >= land_target or dynamic_limit <= 0: + break + if nm in added or nm in getattr(bc, 'BASIC_LANDS', []): + continue + try_add(nm, f"text/name references '{tribe}'") + dynamic_limit -= 1 + + self.output_func("\nKindred Lands Added (Step 3):") + 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 ({reasons.get(n,'')})") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + + def run_land_step3(self): + """Public wrapper to add kindred-focused lands.""" + self.add_kindred_lands() + self._enforce_land_cap(step_label="Kindred (Step 3)") + + # --------------------------- + # Land Building Step 4: Fetch Lands + # --------------------------- + def add_fetch_lands(self, requested_count: Optional[int] = None): + """Add fetch lands (color-specific + generic) respecting land target. + + Steps: + 1. Ensure color identity loaded. + 2. Build candidate list (color-specific first, then generic) excluding existing. + 3. Determine desired count (prompt or provided) respecting global fetch cap. + 4. If no capacity, attempt to trim basics down to floor to free slots. + 5. Sample color-specific first, then generic; add until satisfied. + """ + # 1. Ensure color identity context + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add fetch lands until color identity resolved: {e}") + return + # 2. Land target + land_target = (self.ideal_counts.get('lands') if getattr(self, 'ideal_counts', None) else None) or getattr(bc, 'DEFAULT_LAND_COUNT', 35) + current = self._current_land_count() + color_order = [c for c in self.color_identity if c in ['W','U','B','R','G']] + color_map = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) + candidates: list[str] = [] + for c in color_order: + for nm in color_map.get(c, []): + if nm not in candidates: + candidates.append(nm) + generic_list = getattr(bc, 'GENERIC_FETCH_LANDS', []) + for nm in generic_list: + if nm not in candidates: + candidates.append(nm) + candidates = [n for n in candidates if n not in self.card_library] + if not candidates: + self.output_func("Fetch Lands: No eligible fetch lands remaining.") + return + # 3. Desired count & caps + default_fetch = getattr(bc, 'FETCH_LAND_DEFAULT_COUNT', 3) + remaining_capacity = max(0, land_target - current) + cap_for_default = remaining_capacity if remaining_capacity > 0 else len(candidates) + effective_default = min(default_fetch, cap_for_default, len(candidates)) + existing_fetches = sum(1 for n in self.card_library if n in candidates) + fetch_cap = getattr(bc, 'FETCH_LAND_MAX_CAP', 99) + remaining_fetch_slots = max(0, fetch_cap - existing_fetches) + if requested_count is None: + self.output_func("\nAdd Fetch Lands (Step 4):") + self.output_func("Fetch lands help fix colors & enable landfall / graveyard synergies.") + prompt = f"Enter desired number of fetch lands (default: {effective_default}):" + desired = self._prompt_int_with_default(prompt + ' ', effective_default, minimum=0, maximum=20) + else: + desired = max(0, int(requested_count)) + if desired > remaining_fetch_slots: + desired = remaining_fetch_slots + if desired == 0: + self.output_func("Fetch Lands: Global fetch cap reached; skipping.") + return + if desired == 0: + self.output_func("Fetch Lands: Desired count 0; skipping.") + return + # 4. Free capacity via basic trimming if needed + if remaining_capacity == 0 and desired > 0: + min_basic_cfg = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) + if getattr(self, 'ideal_counts', None): + min_basic_cfg = self.ideal_counts.get('basic_lands', min_basic_cfg) + floor_basics = self._basic_floor(min_basic_cfg) + slots_needed = desired + while slots_needed > 0 and self._count_basic_lands() > floor_basics: + target_basic = self._choose_basic_to_trim() + if not target_basic or not self._decrement_card(target_basic): + break + slots_needed -= 1 + remaining_capacity = max(0, land_target - self._current_land_count()) + if remaining_capacity > 0 and slots_needed == 0: + break + if slots_needed > 0 and remaining_capacity == 0: + desired -= slots_needed + # 5. Clamp & add + remaining_capacity = max(0, land_target - self._current_land_count()) + desired = min(desired, remaining_capacity, len(candidates), remaining_fetch_slots) + if desired <= 0: + self.output_func("Fetch Lands: No capacity (after trimming) or desired reduced to 0; skipping.") + return + import random + rng = getattr(self, 'rng', None) + color_specific_all: list[str] = [] + for c in color_order: + for n in color_map.get(c, []): + if n in candidates and n not in color_specific_all: + color_specific_all.append(n) + generic_all: list[str] = [n for n in generic_list if n in candidates] + def sampler(pool: list[str], k: int) -> list[str]: + if k <= 0 or not pool: + return [] + if k >= len(pool): + return pool.copy() + try: + return (rng.sample if rng else random.sample)(pool, k) + except Exception: + return pool[:k] + need = desired + chosen: list[str] = [] + take_color = min(need, len(color_specific_all)) + chosen.extend(sampler(color_specific_all, take_color)) + need -= len(chosen) + if need > 0: + chosen.extend(sampler(generic_all, min(need, len(generic_all)))) + if len(chosen) < desired: # fill leftovers + leftovers = [n for n in candidates if n not in chosen] + chosen.extend(leftovers[: desired - len(chosen)]) + added: list[str] = [] + for nm in chosen: + if self._current_land_count() >= land_target: + break + self.add_card(nm, card_type='Land') + added.append(nm) + self.output_func("\nFetch Lands Added (Step 4):") + if not added: + self.output_func(" (None added)") + else: + width = max(len(n) for n in added) + for n in added: + note = 'generic' if n in generic_list else 'color-specific' + self.output_func(f" {n.ljust(width)} : 1 ({note})") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + # Land cap enforcement handled in run_land_step4 wrapper + + def run_land_step4(self, requested_count: Optional[int] = None): + """Public wrapper to add fetch lands. Optional requested_count to bypass prompt.""" + self.add_fetch_lands(requested_count=requested_count) + self._enforce_land_cap(step_label="Fetch (Step 4)") + + # --------------------------- + # Internal Helper: Basic Land Floor + # --------------------------- + def _basic_floor(self, min_basic_cfg: int) -> int: + """Return the minimum number of basics we will not trim below. + + Currently defined as ceil(bc.BASIC_FLOOR_FACTOR * configured_basic_count). Centralizing here so + future tuning (e.g., dynamic by color count, bracket, or pip distribution) only + needs a single change. min_basic_cfg already accounts for ideal_counts override. + """ + import math + try: + from . import builder_constants as bc + return max(0, int(math.ceil(bc.BASIC_FLOOR_FACTOR * float(min_basic_cfg)))) + except Exception: + return max(0, min_basic_cfg) + + # --------------------------- + # Land Building Step 5: Dual Lands (Two-Color Typed Lands) + # --------------------------- + def add_dual_lands(self, requested_count: Optional[int] = None): + """Add two-color 'typed' dual lands based on color identity. + + Strategy: + - Build a pool of candidate duals whose basic land types both appear in color identity. + - Avoid duplicates or already-added lands. + - Prioritize untapped / fetchable typed duals first (simple heuristic via name substrings). + - Respect total land target; if at capacity attempt basic swaps (90% floor) like other steps. + - If requested_count provided, cap additions to that number; else use constant default per colors. + """ + # Ensure context + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add dual lands until color identity resolved: {e}") + return + colors = [c for c in self.color_identity if c in ['W','U','B','R','G']] + if len(colors) < 2: + self.output_func("Dual Lands: Not multi-color; skipping step 5.") + 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) + + # Candidate sourcing: search combined DF for lands whose type line includes exactly two relevant basic types + # Build mapping from frozenset({colorA,colorB}) -> list of candidate names + pool: list[str] = [] + type_to_card = {} + pair_buckets: dict[frozenset[str], list[str]] = {} + df = self._combined_cards_df + if df is not None and not df.empty and {'name','type'}.issubset(df.columns): + try: + for _, row in df.iterrows(): + 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 + # Basic type presence count + types_present = [basic for basic in ['plains','island','swamp','mountain','forest'] if basic in tline] + if len(types_present) < 2: + continue + # Map basic types to colors + mapped_colors = set() + for tp in types_present: + if tp == 'plains': + mapped_colors.add('W') + elif tp == 'island': + mapped_colors.add('U') + elif tp == 'swamp': + mapped_colors.add('B') + elif tp == 'mountain': + mapped_colors.add('R') + elif tp == 'forest': + mapped_colors.add('G') + if len(mapped_colors) != 2: # strictly dual typed + continue + if not mapped_colors.issubset(set(colors)): + continue + pool.append(name) + type_to_card[name] = tline + key = frozenset(mapped_colors) + pair_buckets.setdefault(key, []).append(name) + except Exception: + continue + except Exception: + pass + + # De-duplicate + pool = list(dict.fromkeys(pool)) + if not pool: + self.output_func("Dual Lands: No candidate dual typed lands found in dataset.") + return + + # Heuristic ranking inside each pair bucket: shocks > untapped > other > tapped ETB + def rank(name: str) -> int: + lname = name.lower() + tline = type_to_card.get(name,'') + score = 0 + if any(kw in lname for kw in ['temple garden','sacred foundry','stomping ground','hallowed fountain','watery grave','overgrown tomb','breeding pool','godless shrine','steam vents','blood crypt']): + score += 10 # shocks + if 'enters the battlefield tapped' not in tline: + score += 2 + if 'snow' in tline: + score += 1 + # Penalize gainlands / taplands + if 'enters the battlefield tapped' in tline and 'you gain' in tline: + score -= 1 + return score + for key, names in pair_buckets.items(): + names.sort(key=lambda n: rank(n), reverse=True) + # After deterministic ranking, perform a weighted shuffle so higher-ranked + # lands still tend to appear earlier, but we get variety across runs. + # This prevents always selecting the exact same first few duals when + # capacity is limited (e.g., consistently only the top 4 of 7 available). + if len(names) > 1: + try: + rng_obj = getattr(self, 'rng', None) + weighted: list[tuple[str, int]] = [] + for n in names: + # Base weight derived from rank() (ensure >=1) and mildly amplified + w = max(1, rank(n)) + 1 + weighted.append((n, w)) + shuffled: list[str] = [] + import random as _rand + while weighted: + total = sum(w for _, w in weighted) + r = (rng_obj.random() if rng_obj else _rand.random()) * total + acc = 0.0 + for idx, (n, w) in enumerate(weighted): + acc += w + if r <= acc: + shuffled.append(n) + del weighted[idx] + break + pair_buckets[key] = shuffled + except Exception: + pair_buckets[key] = names # fallback to deterministic order + else: + pair_buckets[key] = names + + import random + 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) + + # Desired count heuristic: min(default/requested, capacity, size of all candidates) + default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6) + remaining_capacity = max(0, land_target - self._current_land_count()) + effective_default = min(default_dual_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("Dual Lands: Desired count 0; skipping.") + return + + # If at capacity attempt to free slots (basic swapping) + if remaining_capacity == 0 and desired > 0: + slots_needed = desired + freed_slots = 0 + while freed_slots < 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_slots += 1 + if freed_slots == 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("Dual Lands: No capacity after trimming; skipping.") + return + + # Build weighted candidate list using round-robin across color pairs + chosen: list[str] = [] + bucket_keys = list(pair_buckets.keys()) + rng = getattr(self, 'rng', None) + try: + if rng: + rng.shuffle(bucket_keys) # type: ignore + else: + random.shuffle(bucket_keys) + except Exception: + pass + indices = {k:0 for k in bucket_keys} + while len(chosen) < desired and bucket_keys: + progressed = False + for k in list(bucket_keys): + idx = indices[k] + names = pair_buckets.get(k, []) + if idx >= len(names): + continue + name = names[idx] + indices[k] += 1 + if name in chosen: + continue + chosen.append(name) + progressed = True + if len(chosen) >= desired: + break + if not progressed: + break + + added: list[str] = [] + for name in chosen: + if self._current_land_count() >= land_target: + break + self.add_card(name, card_type='Land') + added.append(name) + + self.output_func("\nDual Lands Added (Step 5):") + 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}") + # Enforcement via wrapper + + def run_land_step5(self, requested_count: Optional[int] = None): + self.add_dual_lands(requested_count=requested_count) + self._enforce_land_cap(step_label="Duals (Step 5)") + + # --------------------------- + # Land Building Step 6: Triple (Tri-Color) Typed Lands + # --------------------------- + def add_triple_lands(self, requested_count: Optional[int] = None): + """Add three-color typed lands (e.g., Triomes) respecting land target and basic floor. + + Logic parallels add_dual_lands but restricted to lands whose type line contains exactly + three distinct basic land types that are all within the deck's color identity. + Selection aims for 1-2 (default) with weighted random ordering among viable tri-color combos + to avoid always choosing the same land when multiple exist. + """ + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add triple lands until color identity resolved: {e}") + return + colors = [c for c in 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 = self._combined_cards_df + pool: list[str] = [] + type_map: dict[str,str] = {} + tri_buckets: dict[frozenset[str], list[str]] = {} + if df is not None and not df.empty and {'name','type'}.issubset(df.columns): + try: + for _, row in df.iterrows(): + 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 + basics_found = [b for b in ['plains','island','swamp','mountain','forest'] if b in tline] + uniq_basics = [] + for b in basics_found: + if b not in uniq_basics: + uniq_basics.append(b) + if len(uniq_basics) != 3: + continue + mapped = set() + for b in uniq_basics: + if b == 'plains': + mapped.add('W') + elif b == 'island': + mapped.add('U') + elif b == 'swamp': + mapped.add('B') + elif b == 'mountain': + mapped.add('R') + elif b == 'forest': + mapped.add('G') + if len(mapped) != 3: + continue + if not mapped.issubset(set(colors)): + continue + pool.append(name) + type_map[name] = tline + key = frozenset(mapped) + tri_buckets.setdefault(key, []).append(name) + except Exception: + continue + except Exception: + pass + pool = list(dict.fromkeys(pool)) + if not pool: + self.output_func("Triple Lands: No candidate triple typed lands found.") + return + + # Rank tri lands: those that can enter untapped / have cycling / fetchable (heuristic), else default + def rank(name: str) -> int: + lname = name.lower() + tline = type_map.get(name,'') + score = 0 + # Triomes & similar premium typed tri-lands + if 'forest' in tline and 'plains' in tline and 'island' in tline: + score += 1 # minor bump per type already inherent; focus on special abilities + if 'cycling' in tline: + score += 3 + if 'enters the battlefield tapped' not in tline: + score += 5 + if 'trium' in lname or 'triome' in lname or 'panorama' in lname: + score += 4 + if 'domain' in tline: + score += 1 + return score + for key, names in tri_buckets.items(): + names.sort(key=lambda n: rank(n), reverse=True) + if len(names) > 1: + try: + rng_obj = getattr(self, 'rng', None) + weighted = [(n, max(1, rank(n))+1) for n in names] + import random as _rand + shuffled: list[str] = [] + while weighted: + total = sum(w for _, w in weighted) + r = (rng_obj.random() if rng_obj else _rand.random()) * total + acc = 0.0 + for idx, (n, w) in enumerate(weighted): + acc += w + if r <= acc: + shuffled.append(n) + del weighted[idx] + break + tri_buckets[key] = shuffled + except Exception: + tri_buckets[key] = names + else: + tri_buckets[key] = names + import random + 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) + + default_triple_target = getattr(bc, 'TRIPLE_LAND_DEFAULT_COUNT', 2) + remaining_capacity = max(0, land_target - self._current_land_count()) + effective_default = min(default_triple_target, remaining_capacity if remaining_capacity>0 else len(pool), len(pool)) + desired = effective_default if requested_count is None else max(0, int(requested_count)) + if desired == 0: + self.output_func("Triple Lands: Desired count 0; skipping.") + return + 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 or 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 + + chosen: list[str] = [] + bucket_keys = list(tri_buckets.keys()) + rng = getattr(self, 'rng', None) + try: + if rng: + rng.shuffle(bucket_keys) # type: ignore + else: + random.shuffle(bucket_keys) + except Exception: + pass + indices = {k:0 for k in bucket_keys} + while len(chosen) < desired and bucket_keys: + progressed = False + for k in list(bucket_keys): + idx = indices[k] + names = tri_buckets.get(k, []) + if idx >= len(names): + continue + name = names[idx] + indices[k] += 1 + if name in chosen: + continue + chosen.append(name) + progressed = True + if len(chosen) >= desired: + break + if not progressed: + break + + added: list[str] = [] + for name in chosen: + if self._current_land_count() >= land_target: + break + self.add_card(name, card_type='Land') + 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)") + + # --------------------------- + # Land Building Step 7: Misc / Utility Lands + # --------------------------- + def add_misc_utility_lands(self, requested_count: Optional[int] = None): + """Add miscellaneous utility lands chosen from the top N (default 30) remaining lands by EDHREC rank. + + Process: + 1. Build candidate set of remaining lands (not already in library, excluding basics & prior staples if desired). + 2. Filter out lands already added in earlier specialized steps. + 3. Sort by ascending edhrecRank (lower = more popular) and take top N (constant). + 4. Apply weighting: color-fixing lands (produce 2+ colors, have basic types, or include "add one mana of any color") get extra weight. + 5. Randomly select up to desired_count (or available capacity) using weighted sampling without replacement. + 6. Capacity aware: may trim basics down to 90% floor like other steps; stops when capacity or desired reached. + + requested_count overrides default. Default target is remaining nonbasic slots or heuristic 3-5 depending on colors. + """ + # Ensure dataframes loaded + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add misc utility lands until color identity resolved: {e}") + return + df = self._combined_cards_df + if df is None or df.empty: + self.output_func("Misc Lands: No card pool loaded.") + return + + # Land target and capacity + 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) + current = self._current_land_count() + remaining_capacity = max(0, land_target - current) + if remaining_capacity <= 0: + # We'll attempt basic swaps below if needed + remaining_capacity = 0 + + 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) + + # Determine desired count + if requested_count is not None: + desired = max(0, int(requested_count)) + else: + # Fill all remaining land capacity (goal: reach land_target this step) + desired = max(0, land_target - current) + if desired == 0: + self.output_func("Misc Lands: No remaining land capacity; skipping.") + return + + # Build candidate pool using helper + basics = self._basic_land_names() + already = set(self.card_library.keys()) + from . import builder_utils as bu + top_n = getattr(bc, 'MISC_LAND_TOP_POOL_SIZE', 30) + top_candidates = bu.select_top_land_candidates(df, already, basics, top_n) + if not top_candidates: + self.output_func("Misc Lands: No remaining candidate lands.") + return + + # Weight calculation for color fixing + weighted_pool: list[tuple[str,int]] = [] + base_weight_fix = getattr(bc, 'MISC_LAND_COLOR_FIX_PRIORITY_WEIGHT', 2) + fetch_names = set() + # Build a union of known fetch candidates from constants to recognize them in Step 7 + for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): + for nm in seq: + fetch_names.add(nm) + for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): + fetch_names.add(nm) + + existing_fetch_count = bu.count_existing_fetches(self.card_library) + fetch_cap = getattr(bc, 'FETCH_LAND_MAX_CAP', 99) + remaining_fetch_slots = max(0, fetch_cap - existing_fetch_count) + + for edh_val, name, tline, text_lower in top_candidates: + w = 1 + if bu.is_color_fixing_land(tline, text_lower): + w *= base_weight_fix + # If this candidate is a fetch but we've hit the fetch cap, zero weight it so it won't be chosen + if name in fetch_names and remaining_fetch_slots <= 0: + continue + weighted_pool.append((name, w)) + + # Capacity freeing if needed + if self._current_land_count() >= land_target 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 or not self._decrement_card(target_basic): + break + freed += 1 + if freed == 0 and self._current_land_count() >= land_target: + self.output_func("Misc Lands: Cannot free capacity; skipping.") + return + + remaining_capacity = max(0, land_target - self._current_land_count()) + desired = min(desired, remaining_capacity, len(weighted_pool)) + if desired <= 0: + self.output_func("Misc Lands: No capacity after trimming; skipping.") + return + + # Weighted random selection without replacement + rng = getattr(self, 'rng', None) + chosen = bu.weighted_sample_without_replacement(weighted_pool, desired, rng=rng) + + added: list[str] = [] + for nm in chosen: + if self._current_land_count() >= land_target: + break + self.add_card(nm, card_type='Land') + added.append(nm) + + self.output_func("\nMisc Utility Lands Added (Step 7):") + if not added: + self.output_func(" (None added)") + else: + width = max(len(n) for n in added) + for n in added: + note = '' + row = next((r for r in top_candidates if r[1] == n), None) + if row: + for edh_val, name2, tline2, text_lower2 in top_candidates: + if name2 == n and bu.is_color_fixing_land(tline2, text_lower2): + note = '(fixing)' + break + self.output_func(f" {n.ljust(width)} : 1 {note}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + + def run_land_step7(self, requested_count: Optional[int] = None): + self.add_misc_utility_lands(requested_count=requested_count) + self._enforce_land_cap(step_label="Utility (Step 7)") + # Build and attempt to apply tag-driven suggestions (light augmentation) + self._build_tag_driven_land_suggestions() + self._apply_land_suggestions_if_room() + + # --------------------------- + # Land Building Step 8: ETB Tapped Minimization / Optimization Pass + # --------------------------- + def optimize_tapped_lands(self): + """Attempt to reduce number of slow ETB tapped lands if exceeding bracket threshold. + + Logic: + 1. Determine threshold from power bracket (defaults if absent). + 2. Classify each land in current library as tapped or untapped (heuristic via text). + - Treat shocks ("you may pay 2 life") as untapped potential (not counted towards tapped threshold). + - Treat conditional untap ("unless you control", "if you control") as half-penalty (still counted but lower priority to remove). + 3. If tapped_count <= threshold: exit. + 4. Score tapped lands by penalty; higher penalty = more likely swap out. + Penalty factors: + +8 base if always tapped. + -3 if provides 3+ basic types (tri land) or adds any color. + -2 if cycling. + -2 if conditional untap ("unless you control", "if you control", "you may pay 2 life"). + +1 if only colorless production. + +1 if minor upside (gain life) instead of speed. + 5. Build candidate replacement pool of untapped or effectively fast lands not already in deck: + - Prioritize dual typed lands we missed, pain lands, shocks (if missing), basics if needed as fallback. + 6. Swap worst offenders until tapped_count <= threshold or replacements exhausted. + 7. Report swaps. + """ + # Need card pool dataframe + df = getattr(self, '_combined_cards_df', None) + if df is None or df.empty: + return + # Gather threshold + 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) + + # Build quick lookup for card rows by name (first occurrence) + name_to_row: dict[str, dict] = {} + for _, row in df.iterrows(): + nm = str(row.get('name','')) + if nm and nm not in name_to_row: + name_to_row[nm] = row.to_dict() + + tapped_info: list[tuple[str,int,int]] = [] # (name, penalty, tapped_flag 1/0) + total_tapped = 0 + from . import builder_utils as bu + for name, entry in list(self.card_library.items()): + # Only consider lands + row = name_to_row.get(name) + if not row: + 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() + tapped_flag, penalty = bu.tapped_land_penalty(tline, text_field) + if tapped_flag: + total_tapped += 1 + tapped_info.append((name, penalty, tapped_flag)) + + if total_tapped <= threshold: + self.output_func(f"Tapped Optimization (Step 8): {total_tapped} tapped/conditional lands (threshold {threshold}); no changes.") + return + + # Determine how many to replace + over = total_tapped - threshold + swap_min_penalty = getattr(bc, 'TAPPED_LAND_SWAP_MIN_PENALTY', 6) + # Sort by penalty descending + tapped_info.sort(key=lambda x: x[1], reverse=True) + to_consider = [t for t in tapped_info if t[1] >= swap_min_penalty] + if not to_consider: + self.output_func(f"Tapped Optimization (Step 8): Over threshold ({total_tapped}>{threshold}) but no suitable swaps (penalties too low).") + return + + # Build replacement candidate pool: untapped multi-color first + replacement_candidates: list[str] = [] + seen = set(self.card_library.keys()) + colors = [c for c in self.color_identity if c in ['W','U','B','R','G']] + for _, row in df.iterrows(): + try: + name = str(row.get('name','')) + if not name or name in seen or name in replacement_candidates: + 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() + if 'enters the battlefield tapped' in text_field and 'you may pay 2 life' not in text_field and 'unless you control' not in text_field: + # Hard tapped, skip as replacement + continue + # Color relevance: if produces at least one deck color or has matching basic types + produces_color = any(sym in text_field for sym in ['{w}','{u}','{b}','{r}','{g}']) + basic_types = [b for b in ['plains','island','swamp','mountain','forest'] if b in tline] + mapped = set() + for b in basic_types: + if b == 'plains': + mapped.add('W') + elif b == 'island': + mapped.add('U') + elif b == 'swamp': + mapped.add('B') + elif b == 'mountain': + mapped.add('R') + elif b == 'forest': + mapped.add('G') + if not produces_color and not (mapped & set(colors)): + continue + replacement_candidates.append(name) + except Exception: + continue + + # Simple ranking: prefer shocks / pain / dual typed, then any_color, then others + def repl_rank(name: str) -> int: + row = name_to_row.get(name, {}) + tline = str(row.get('type', row.get('type_line',''))) + text_field = str(row.get('text', row.get('oracleText',''))) + return bu.replacement_land_score(name, tline, text_field) + replacement_candidates.sort(key=repl_rank, reverse=True) + + swaps_made: list[tuple[str,str]] = [] + idx_rep = 0 + for name, penalty, _ in to_consider: + if over <= 0: + break + # Remove this tapped land + if not self._decrement_card(name): + continue + # Find replacement + replacement = None + while idx_rep < len(replacement_candidates): + cand = replacement_candidates[idx_rep] + idx_rep += 1 + # Skip if would exceed fetch cap + if cand in getattr(bc, 'GENERIC_FETCH_LANDS', []) or any(cand in lst for lst in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values()): + # Count existing fetches + fetch_cap = getattr(bc, 'FETCH_LAND_MAX_CAP', 99) + existing_fetches = sum(1 for n in self.card_library if n in getattr(bc, 'GENERIC_FETCH_LANDS', [])) + for lst in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): + existing_fetches += sum(1 for n in self.card_library if n in lst) + if existing_fetches >= fetch_cap: + continue + replacement = cand + break + # Fallback to a basic if no candidate + if replacement is None: + # Choose most needed basic by current counts vs color identity + basics = self._basic_land_names() + basic_counts = {b: self.card_library.get(b, {}).get('Count',0) for b in basics} + # pick basic with lowest count among colors we use + color_basic_map = {'W':'Plains','U':'Island','B':'Swamp','R':'Mountain','G':'Forest'} + usable_basics = [color_basic_map[c] for c in colors if color_basic_map[c] in basics] + usable_basics.sort(key=lambda b: basic_counts.get(b,0)) + replacement = usable_basics[0] if usable_basics else 'Wastes' + self.add_card(replacement, card_type='Land') + swaps_made.append((name, replacement)) + over -= 1 + + if not swaps_made: + self.output_func(f"Tapped Optimization (Step 8): Could not perform swaps; over threshold {total_tapped}>{threshold}.") + return + self.output_func("\nTapped Optimization (Step 8) Swaps:") + for old, new in swaps_made: + self.output_func(f" Replaced {old} -> {new}") + new_tapped = 0 + # Recount tapped + for name, entry in self.card_library.items(): + row = name_to_row.get(name) + if not row: + continue + text_field = str(row.get('text', row.get('oracleText',''))).lower() + if 'enters the battlefield tapped' in text_field and 'you may pay 2 life' not in text_field: + new_tapped += 1 + self.output_func(f" Tapped Lands After : {new_tapped} (threshold {threshold})") + + def run_land_step8(self): + self.optimize_tapped_lands() + # Land count unchanged; still enforce cap to be safe + self._enforce_land_cap(step_label="Tapped Opt (Step 8)") + # Capture color source baseline after land optimization (once) + if self.color_source_matrix_baseline is None: + self.color_source_matrix_baseline = self._compute_color_source_matrix() + + # --------------------------- + # Tag-driven utility suggestions + # --------------------------- + def _build_tag_driven_land_suggestions(self): + from . import builder_utils as bu + # Delegate construction of suggestion dicts to utility module. + suggestions = bu.build_tag_driven_suggestions(self) + if suggestions: + self.suggested_lands_queue.extend(suggestions) + + def _apply_land_suggestions_if_room(self): + if not self.suggested_lands_queue: + 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) + applied: list[dict] = [] + remaining: list[dict] = [] + 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) + for sug in self.suggested_lands_queue: + name = sug['name'] + if name in self.card_library: + continue + if not sug['condition'](self): + remaining.append(sug) + continue + if self._current_land_count() >= land_target: + if sug.get('defer_if_full'): + if self._count_basic_lands() > basic_floor: + target_basic = self._choose_basic_to_trim() + if not target_basic or not self._decrement_card(target_basic): + remaining.append(sug) + continue + else: + remaining.append(sug) + continue + self.add_card(name, card_type='Land') + if sug.get('flex') and name in self.card_library: + self.card_library[name]['Role'] = 'flex' + applied.append(sug) + self.suggested_lands_queue = remaining + if applied: + self.output_func("\nTag-Driven Utility Lands Added:") + width = max(len(s['name']) for s in applied) + for s in applied: + role = ' (flex)' if s.get('flex') else '' + self.output_func(f" {s['name'].ljust(width)} : 1 {s['reason']}{role}") + + # --------------------------- + # Color source matrix & post-spell adjustment stub + # --------------------------- + def _compute_color_source_matrix(self) -> Dict[str, Dict[str,int]]: + # Cached: recompute only if dirty + if self._color_source_matrix_cache is not None and not self._color_source_cache_dirty: + return self._color_source_matrix_cache + from . import builder_utils as bu + matrix = bu.compute_color_source_matrix(self.card_library, getattr(self, '_full_cards_df', None)) + self._color_source_matrix_cache = matrix + self._color_source_cache_dirty = False + return matrix + + # --------------------------- + # Spell pip analysis helpers + # --------------------------- + def _compute_spell_pip_weights(self) -> Dict[str, float]: + if self._spell_pip_weights_cache is not None and not self._spell_pip_cache_dirty: + return self._spell_pip_weights_cache + from . import builder_utils as bu + weights = bu.compute_spell_pip_weights(self.card_library, self.color_identity) + self._spell_pip_weights_cache = weights + self._spell_pip_cache_dirty = False + return weights + + def _current_color_source_counts(self) -> Dict[str,int]: + matrix = self._compute_color_source_matrix() + counts = {c:0 for c in ['W','U','B','R','G']} + for name, colors in matrix.items(): + entry = self.card_library.get(name, {}) + copies = entry.get('Count',1) + for c, v in colors.items(): + if v: + counts[c] += copies + return counts + + def post_spell_land_adjust(self, + pip_weights: Optional[Dict[str,float]] = None, + color_shortfall_threshold: float = 0.15, + perform_swaps: bool = False, + max_swaps: int = 3): + # Compute pip weights if not supplied + if pip_weights is None: + pip_weights = self._compute_spell_pip_weights() + if self.color_source_matrix_baseline is None: + self.color_source_matrix_baseline = self._compute_color_source_matrix() + current_counts = self._current_color_source_counts() + total_sources = sum(current_counts.values()) or 1 + source_share = {c: current_counts[c]/total_sources for c in current_counts} + deficits: list[tuple[str,float,float,float]] = [] # color, pip_share, source_share, gap + for c in ['W','U','B','R','G']: + pip_share = pip_weights.get(c,0.0) + s_share = source_share.get(c,0.0) + gap = pip_share - s_share + if gap > color_shortfall_threshold and pip_share > 0.0: + deficits.append((c,pip_share,s_share,gap)) + self.output_func("\nPost-Spell Color Distribution Analysis:") + self.output_func(" Color | Pip% | Source% | Diff%") + for c in ['W','U','B','R','G']: + self.output_func(f" {c:>1} {pip_weights.get(c,0.0)*100:5.1f}% {source_share.get(c,0.0)*100:6.1f}% {(pip_weights.get(c,0.0)-source_share.get(c,0.0))*100:6.1f}%") + if not deficits: + self.output_func(" No color deficits above threshold.") + else: + self.output_func(" Deficits (need more sources):") + for c, pip_share, s_share, gap in deficits: + self.output_func(f" {c}: need +{gap*100:.1f}% sources (pip {pip_share*100:.1f}% vs sources {s_share*100:.1f}%)") + if not perform_swaps or not deficits: + self.output_func(" (No land swaps performed.)") + return + + # --------------------------- + # Simple swap engine: attempt to add lands for deficit colors + # --------------------------- + df = getattr(self, '_combined_cards_df', None) + if df is None or df.empty: + self.output_func(" Swap engine: card pool unavailable; aborting swaps.") + return + + # Rank deficit colors by largest gap first + deficits.sort(key=lambda x: x[3], reverse=True) + swaps_done: list[tuple[str,str]] = [] # (removed, added) + + # Precompute overrepresented colors to target for removal + overages: Dict[str,float] = {} + for c in ['W','U','B','R','G']: + over = source_share.get(c,0.0) - pip_weights.get(c,0.0) + if over > 0: + overages[c] = over + + def removal_candidate(exclude_colors: set[str]) -> Optional[str]: + from . import builder_utils as bu + return bu.select_color_balance_removal(self, exclude_colors, overages) + + def addition_candidates(target_color: str) -> List[str]: + from . import builder_utils as bu + return bu.color_balance_addition_candidates(self, target_color, df) + + for color, _, _, gap in deficits: + if len(swaps_done) >= max_swaps: + break + adds = addition_candidates(color) + if not adds: + continue + to_add = adds[0] + to_remove = removal_candidate({color}) + if not to_remove: + continue + if not self._decrement_card(to_remove): + continue + self.add_card(to_add, card_type='Land') + self.card_library[to_add]['Role'] = 'color-fix' + swaps_done.append((to_remove, to_add)) + + if swaps_done: + self.output_func("\nColor Balance Swaps Performed:") + for old, new in swaps_done: + self.output_func(f" Replaced {old} -> {new}") + else: + self.output_func(" (No viable swaps executed.)") + + # --------------------------- + # Land Cap Enforcement (applies after every non-basic step) + # --------------------------- + def _basic_land_names(self) -> set: + """Return set of all basic (and snow basic) land names plus Wastes.""" + from . import builder_utils as bu + return bu.basic_land_names() + + def _count_basic_lands(self) -> int: + """Count total copies of basic lands currently in the library.""" + from . import builder_utils as bu + return bu.count_basic_lands(self.card_library) + + def _choose_basic_to_trim(self) -> Optional[str]: + """Return a basic land name to trim (highest count) or None.""" + from . import builder_utils as bu + return bu.choose_basic_to_trim(self.card_library) + + def _decrement_card(self, name: str) -> bool: + entry = self.card_library.get(name) + if not entry: + return False + cnt = entry.get('Count', 1) + was_land = 'land' in str(entry.get('Card Type','')).lower() + was_non_land = not was_land + if cnt <= 1: + # remove entire entry + try: + del self.card_library[name] + except Exception: + return False + else: + entry['Count'] = cnt - 1 + if was_land: + self._color_source_cache_dirty = True + if was_non_land: + self._spell_pip_cache_dirty = True + return True + + def _enforce_land_cap(self, step_label: str = ""): + """Delegate land cap enforcement to utility helper.""" + from . import builder_utils as bu + bu.enforce_land_cap(self, step_label) # --------------------------- # Tag Prioritization @@ -1103,7 +2358,7 @@ class DeckBuilder: self.output_func(f"\nCard Library: {total_cards} cards (Commander included). Remaining slots: {remaining}") try: - from prettytable import PrettyTable + from prettytable import PrettyTable, ALL except ImportError: self.output_func("PrettyTable not installed. Run 'pip install prettytable' to enable formatted library output.") for name, entry in self.card_library.items(): @@ -1116,6 +2371,11 @@ class DeckBuilder: ] # Name will include duplicate count suffix (e.g., "Plains x13") if Count>1 table = PrettyTable(field_names=cols) table.align = 'l' + # Add horizontal rules between all rows for clearer separation + try: + table.hrules = ALL # type: ignore[attr-defined] + except Exception: + pass # Build lookup from combined df for enrichment (prefer full snapshot so removed rows still enrich) combined = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df @@ -1243,24 +2503,108 @@ class DeckBuilder: ci = '' colors = '' + # Enrich basics (and snow basics) with canonical type line and oracle text + basic_detail_map = { + 'Plains': ('Basic Land — Plains', '{T}: Add {W}.'), + 'Island': ('Basic Land — Island', '{T}: Add {U}.'), + 'Swamp': ('Basic Land — Swamp', '{T}: Add {B}.'), + 'Mountain': ('Basic Land — Mountain', '{T}: Add {R}.'), + 'Forest': ('Basic Land — Forest', '{T}: Add {G}.'), + 'Wastes': ('Basic Land', '{T}: Add {C}.'), + 'Snow-Covered Plains': ('Basic Snow Land — Plains', '{T}: Add {W}.'), + 'Snow-Covered Island': ('Basic Snow Land — Island', '{T}: Add {U}.'), + 'Snow-Covered Swamp': ('Basic Snow Land — Swamp', '{T}: Add {B}.'), + 'Snow-Covered Mountain': ('Basic Snow Land — Mountain', '{T}: Add {R}.'), + 'Snow-Covered Forest': ('Basic Snow Land — Forest', '{T}: Add {G}.'), + } + if name in basic_detail_map: + canonical_type, canonical_text = basic_detail_map[name] + type_line = canonical_type + if not text_field: + text_field = canonical_text + # Ensure ci/colors set (if missing due to csv NaN) + if name in basic_names: + ci = rev_basic.get(name, ci) + colors = rev_basic.get(name, colors) + elif name in snow_basic_names: + ci = rev_snow.get(name, ci) + colors = rev_snow.get(name, colors) + + # Sanitize NaN / 'nan' strings for display cleanliness + import math + def _sanitize(val): + if val is None: + return '' + if isinstance(val, float) and math.isnan(val): + return '' + if isinstance(val, str) and val.lower() == 'nan': + return '' + return val + mana_cost = _sanitize(mana_cost) + mana_value = _sanitize(mana_value) + type_line = _sanitize(type_line) + creature_types = _sanitize(creature_types) + power = _sanitize(power) + toughness = _sanitize(toughness) + keywords = _sanitize(keywords) + theme_tags = _sanitize(theme_tags) + text_field = _sanitize(text_field) + ci = _sanitize(ci) + colors = _sanitize(colors) + + # Strip embedded newline characters/sequences from text and theme tags for cleaner single-row display + if text_field: + text_field = text_field.replace('\\n', ' ').replace('\n', ' ') + # Collapse multiple spaces + while ' ' in text_field: + text_field = text_field.replace(' ', ' ') + if theme_tags: + theme_tags = theme_tags.replace('\n', ' ').replace('\\n', ' ') + table.add_row([ display_name, ci, colors, mana_cost, mana_value, - type_line, + self._wrap_cell(type_line, width=30), creature_types, power, toughness, keywords, - theme_tags, - text_field + self._wrap_cell(theme_tags, width=60), + self._wrap_cell(text_field, prefer_long=True) ]) self.output_func(table.get_string()) + # Internal helper for wrapping cell contents to keep table readable + def _wrap_cell(self, text: str, width: int = 60, prefer_long: bool = False) -> str: + """Word-wrap a cell's text. + + prefer_long: if True, uses a slightly larger width (e.g. for oracle text). + """ + if not text: + return '' + if prefer_long: + width = 80 + # Normalize whitespace but preserve existing newlines (treat each paragraph separately) + import textwrap + paragraphs = str(text).split('\n') + wrapped_parts = [] + for p in paragraphs: + p = p.strip() + if not p: + wrapped_parts.append('') + continue + # If already shorter than width, keep + if len(p) <= width: + wrapped_parts.append(p) + continue + wrapped_parts.append('\n'.join(textwrap.wrap(p, width=width))) + return '\n'.join(wrapped_parts) + # Convenience to run Step 1 & 2 sequentially (future orchestrator) def run_deck_build_steps_1_2(self): self.run_deck_build_step1() - self.run_deck_build_step2() \ No newline at end of file + self.run_deck_build_step2() diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index c06bb43..25be332 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -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 diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index e69de29..af588b6 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -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}).") + diff --git a/code/non_interactive_test.py b/code/non_interactive_test.py index cfae6e9..3307780 100644 --- a/code/non_interactive_test.py +++ b/code/non_interactive_test.py @@ -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__":