2025-08-20 10:46:23 -07:00
|
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Dict
|
|
|
|
import random
|
|
|
|
from .. import builder_constants as bc
|
|
|
|
|
|
|
|
"""Phase 2 (part 5): Dual typed lands (Land Step 5).
|
|
|
|
|
|
|
|
Extracted from `builder.py` to modularize land building. Handles addition of two-color
|
|
|
|
basic-typed dual lands (e.g., Shock lands, typed cycle) with basic land type detection
|
|
|
|
and heuristic ranking plus a weighted shuffle for variety.
|
|
|
|
|
|
|
|
Provided by LandDualsMixin:
|
|
|
|
- add_dual_lands(requested_count: int | None = None)
|
|
|
|
- run_land_step5(requested_count: int | None = None)
|
|
|
|
|
|
|
|
Host DeckBuilder must provide:
|
|
|
|
- attributes: files_to_load, color_identity, ideal_counts, card_library, _combined_cards_df
|
|
|
|
- methods: determine_color_identity(), setup_dataframes(), _current_land_count(),
|
|
|
|
_basic_floor(), _count_basic_lands(), _choose_basic_to_trim(), _decrement_card(),
|
|
|
|
add_card(), _enforce_land_cap(), output_func
|
|
|
|
"""
|
|
|
|
|
|
|
|
class LandDualsMixin:
|
|
|
|
def add_dual_lands(self, requested_count: int | None = None): # type: ignore[override]
|
|
|
|
"""Add two-color 'typed' dual lands based on color identity."""
|
|
|
|
if not getattr(self, 'files_to_load', []):
|
|
|
|
try:
|
|
|
|
self.determine_color_identity()
|
|
|
|
self.setup_dataframes()
|
|
|
|
except Exception as e: # pragma: no cover - defensive
|
|
|
|
self.output_func(f"Cannot add dual lands until color identity resolved: {e}")
|
|
|
|
return
|
|
|
|
colors = [c for c in getattr(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', {}) or {}).get('lands', getattr(bc, 'DEFAULT_LAND_COUNT', 35))
|
|
|
|
df = getattr(self, '_combined_cards_df', None)
|
|
|
|
pool: List[str] = []
|
|
|
|
type_to_card: Dict[str,str] = {}
|
|
|
|
pair_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 getattr(self, 'card_library', {}):
|
|
|
|
continue
|
|
|
|
tline = str(row.get('type','')).lower()
|
|
|
|
if 'land' not in tline:
|
|
|
|
continue
|
|
|
|
types_present = [basic for basic in ['plains','island','swamp','mountain','forest'] if basic in tline]
|
|
|
|
if len(types_present) < 2:
|
|
|
|
continue
|
|
|
|
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:
|
|
|
|
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
|
|
|
|
pool = list(dict.fromkeys(pool))
|
|
|
|
if not pool:
|
|
|
|
self.output_func("Dual Lands: No candidate dual typed lands found in dataset.")
|
|
|
|
return
|
|
|
|
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
|
|
|
|
if 'enters the battlefield tapped' not in tline:
|
|
|
|
score += 2
|
|
|
|
if 'snow' in tline:
|
|
|
|
score += 1
|
|
|
|
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)
|
|
|
|
if len(names) > 1:
|
|
|
|
rng_obj = getattr(self, 'rng', None)
|
|
|
|
try:
|
|
|
|
weighted = [(n, max(1, rank(n))+1) for n in names]
|
|
|
|
shuffled: List[str] = []
|
|
|
|
while weighted:
|
|
|
|
total = sum(w for _n, w in weighted)
|
|
|
|
r = (rng_obj.random() if rng_obj else random.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
|
|
|
|
else:
|
|
|
|
pair_buckets[key] = names
|
|
|
|
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) # type: ignore[attr-defined]
|
|
|
|
basic_floor = self._basic_floor(min_basic_cfg) # type: ignore[attr-defined]
|
|
|
|
default_dual_target = getattr(bc, 'DUAL_LAND_DEFAULT_COUNT', 6)
|
|
|
|
remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined]
|
|
|
|
effective_default = min(default_dual_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("Dual Lands: Desired count 0; skipping.")
|
|
|
|
return
|
|
|
|
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: # type: ignore[attr-defined]
|
|
|
|
target_basic = self._choose_basic_to_trim() # type: ignore[attr-defined]
|
|
|
|
if not target_basic or not self._decrement_card(target_basic): # type: ignore[attr-defined]
|
|
|
|
break
|
|
|
|
freed_slots += 1
|
|
|
|
if freed_slots == 0:
|
|
|
|
desired = 0
|
|
|
|
remaining_capacity = max(0, land_target - self._current_land_count()) # type: ignore[attr-defined]
|
|
|
|
desired = min(desired, remaining_capacity, len(pool))
|
|
|
|
if desired <= 0:
|
|
|
|
self.output_func("Dual Lands: No capacity after trimming; skipping.")
|
|
|
|
return
|
|
|
|
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: # type: ignore[attr-defined]
|
|
|
|
break
|
|
|
|
# Determine sub_role as concatenated color pair for traceability
|
|
|
|
try:
|
|
|
|
tline = type_to_card.get(name, '')
|
|
|
|
types_present = [basic for basic in ['plains','island','swamp','mountain','forest'] if basic in tline]
|
|
|
|
mapped_colors = []
|
|
|
|
for tp in types_present:
|
|
|
|
if tp == 'plains':
|
|
|
|
mapped_colors.append('W')
|
|
|
|
elif tp == 'island':
|
|
|
|
mapped_colors.append('U')
|
|
|
|
elif tp == 'swamp':
|
|
|
|
mapped_colors.append('B')
|
|
|
|
elif tp == 'mountain':
|
|
|
|
mapped_colors.append('R')
|
|
|
|
elif tp == 'forest':
|
|
|
|
mapped_colors.append('G')
|
|
|
|
sub_role = ''.join(sorted(set(mapped_colors))) if mapped_colors else None
|
|
|
|
except Exception:
|
|
|
|
sub_role = None
|
|
|
|
self.add_card(
|
|
|
|
name,
|
|
|
|
card_type='Land',
|
|
|
|
role='dual',
|
|
|
|
sub_role=sub_role,
|
|
|
|
added_by='lands_step5'
|
|
|
|
) # type: ignore[attr-defined]
|
|
|
|
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}") # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
def run_land_step5(self, requested_count: int | None = None): # type: ignore[override]
|
|
|
|
self.add_dual_lands(requested_count=requested_count)
|
|
|
|
self._enforce_land_cap(step_label="Duals (Step 5)") # type: ignore[attr-defined]
|
2025-09-10 16:20:38 -07:00
|
|
|
try:
|
|
|
|
from .. import builder_utils as _bu
|
|
|
|
_bu.export_current_land_pool(self, '5')
|
|
|
|
except Exception:
|
|
|
|
pass
|
2025-08-20 10:46:23 -07:00
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
'LandDualsMixin'
|
|
|
|
]
|