chore(release): v2.2.9 misc land variety, land alternatives randomization, scroll flicker fix

This commit is contained in:
matt 2025-09-10 16:20:38 -07:00
parent 52457f6a25
commit 07a92eb47f
22 changed files with 889 additions and 248 deletions

View file

@ -167,6 +167,77 @@ MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to
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
MISC_LAND_USE_FULL_POOL: Final[bool] = True # If True, ignore TOP_POOL_SIZE and use entire remaining land pool for misc step
MISC_LAND_EDHREC_KEEP_PERCENT: Final[float] = 0.80 # Legacy single-value fallback if min/max not set
# When both min & max are defined (0<min<=max<=1), Step 7 will roll a random % in [min,max]
# using the builder RNG to keep that share of top EDHREC-ranked candidates, injecting variety.
MISC_LAND_EDHREC_KEEP_PERCENT_MIN: Final[float] = 0.75
MISC_LAND_EDHREC_KEEP_PERCENT_MAX: Final[float] = 1.00
# Theme-based misc land weighting (applied after all reductions)
MISC_LAND_THEME_MATCH_ENABLED: Final[bool] = True
MISC_LAND_THEME_MATCH_BASE: Final[float] = 1.4 # Multiplier if at least one theme tag matches
MISC_LAND_THEME_MATCH_PER_EXTRA: Final[float] = 0.15 # Additional multiplier increment per extra matching tag beyond first
MISC_LAND_THEME_MATCH_CAP: Final[float] = 2.0 # Maximum total multiplier cap for theme boosting
# Mono-color extra rainbow filtering (text-based)
MONO_COLOR_EXCLUDE_RAINBOW_TEXT: Final[bool] = True # If True, exclude lands whose rules text implies any-color mana in mono decks (beyond explicit list)
MONO_COLOR_RAINBOW_TEXT_EXTRA: Final[List[str]] = [ # Additional substrings (lowercased) checked besides ANY_COLOR_MANA_PHRASES
'add one mana of any type',
'choose a color',
'add one mana of any color',
'add one mana of any color that a gate',
'add one mana of any color among', # e.g., Plaza of Harmony style variants (kept list overrides)
]
# Mono-color misc land exclusion (utility/rainbow) logic
# Lands in this list will be excluded from the Step 7 misc/utility selection pool
# when the deck is mono-colored UNLESS they appear in MONO_COLOR_MISC_LAND_KEEP_ALWAYS
# or are detected as kindred lands (see KINDRED_* constants below).
MONO_COLOR_MISC_LAND_EXCLUDE: Final[List[str]] = [
'Command Tower',
'Mana Confluence',
'City of Brass',
'Grand Coliseum',
'Tarnished Citadel',
'Gemstone Mine',
'Aether Hub',
'Spire of Industry',
'Exotic Orchard',
'Reflecting Pool',
'Plaza of Harmony',
'Pillar of the Paruns',
'Cascading Cataracts',
'Crystal Quarry',
'The World Tree',
# Thriving cycle functionally useless / invalid in strict mono-color builds
'Thriving Bluff',
'Thriving Grove',
'Thriving Isle',
'Thriving Heath',
'Thriving Moor'
]
# Mono-color always-keep exceptions (never excluded by the above rule)
MONO_COLOR_MISC_LAND_KEEP_ALWAYS: Final[List[str]] = [
'Forbidden Orchard',
'Plaza of Heroes',
'Path of Ancestry',
'Lotus Field',
'Lotus Vale'
]
## Kindred / creature-type / legend-supporting lands (single unified list)
# Consolidates former KINDRED_STAPLE_LANDS + KINDRED_MISC_LAND_NAMES + Plaza of Heroes
# Order is not semantically important; kept readable.
KINDRED_LAND_NAMES: Final[List[str]] = [
'Path of Ancestry',
'Three Tree City',
'Cavern of Souls',
'Unclaimed Territory',
'Secluded Courtyard',
'Plaza of Heroes'
]
# Default fetch land count & cap
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
@ -285,18 +356,11 @@ GENERIC_FETCH_LANDS: Final[List[str]] = [
'Prismatic Vista'
]
# Kindred land constants
## Backwards compatibility: expose prior names as derived values
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
{
'name': 'Path of Ancestry',
'type': 'Land'
},
{
'name': 'Three Tree City',
'type': 'Legendary Land'
},
{'name': 'Cavern of Souls', 'type': 'Land'}
{'name': n, 'type': 'Land'} for n in KINDRED_LAND_NAMES
]
KINDRED_ALL_LAND_NAMES: Final[List[str]] = list(KINDRED_LAND_NAMES)
# Color-specific fetch land mappings
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
@ -361,7 +425,7 @@ STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bo
LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
# Protected lands that cannot be removed during land removal process
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + KINDRED_LAND_NAMES
# Other defaults
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures

View file

@ -364,7 +364,6 @@ def is_color_fixing_land(tline: str, text_lower: str) -> bool:
distinct = {cw for cw in bc.COLORED_MANA_SYMBOLS if cw in text_lower}
return len(distinct) >= 2
# ---------------------------------------------------------------------------
# Weighted sampling & fetch helpers
# ---------------------------------------------------------------------------
@ -395,6 +394,43 @@ def weighted_sample_without_replacement(pool: list[tuple[str, int | float]], k:
chosen.append(nm)
return chosen
# -----------------------------
# Land Debug Export Helper
# -----------------------------
def export_current_land_pool(builder, label: str) -> None:
"""Write a CSV snapshot of current land candidates (full dataframe filtered to lands).
Outputs to logs/debug/land_step_{label}_test.csv. Guarded so it only runs if the combined
dataframe exists. Designed for diagnosing filtering shrinkage between land steps.
"""
try: # pragma: no cover - diagnostics
df = getattr(builder, '_combined_cards_df', None)
if df is None or getattr(df, 'empty', True):
return
col = 'type' if 'type' in df.columns else ('type_line' if 'type_line' in df.columns else None)
if not col:
return
land_df = df[df[col].fillna('').str.contains('Land', case=False, na=False)].copy()
if land_df.empty:
return
import os
os.makedirs(os.path.join('logs','debug'), exist_ok=True)
export_cols = [c for c in ['name','type','type_line','manaValue','edhrecRank','colorIdentity','manaCost','themeTags','oracleText'] if c in land_df.columns]
path = os.path.join('logs','debug', f'land_step_{label}_test.csv')
try:
if export_cols:
land_df[export_cols].to_csv(path, index=False, encoding='utf-8')
else:
land_df.to_csv(path, index=False, encoding='utf-8')
except Exception:
land_df.to_csv(path, index=False)
try:
builder.output_func(f"[DEBUG] Wrote land_step_{label}_test.csv ({len(land_df)} rows)")
except Exception:
pass
except Exception:
pass
def count_existing_fetches(card_library: dict) -> int:
bc = __import__('deck_builder.builder_constants', fromlist=['FETCH_LAND_MAX_CAP'])
@ -439,6 +475,74 @@ def select_top_land_candidates(df, already: set[str], basics: set[str], top_n: i
return out[:top_n]
# ---------------------------------------------------------------------------
# Misc land filtering helpers (mono-color exclusions & tribal weighting)
# ---------------------------------------------------------------------------
def is_mono_color(builder) -> bool:
try:
ci = getattr(builder, 'color_identity', []) or []
return len([c for c in ci if c in ('W','U','B','R','G')]) == 1
except Exception:
return False
def has_kindred_theme(builder) -> bool:
try:
tags = [t.lower() for t in (getattr(builder, 'selected_tags', []) or [])]
return any(('kindred' in t or 'tribal' in t) for t in tags)
except Exception:
return False
def is_kindred_land(name: str) -> bool:
"""Return True if the land is considered kindred-oriented (unified constant)."""
from . import builder_constants as bc # local import to avoid cycles
kindred = set(getattr(bc, 'KINDRED_LAND_NAMES', [])) or {d['name'] for d in getattr(bc, 'KINDRED_STAPLE_LANDS', [])}
return name in kindred
def misc_land_excluded_in_mono(builder, name: str) -> bool:
"""Return True if a land should be excluded in mono-color decks per constant list.
Exclusion rules:
- Only applies if deck is mono-color.
- Never exclude items in MONO_COLOR_MISC_LAND_KEEP_ALWAYS.
- Never exclude tribal/kindred lands (they may be down-weighted separately if no theme).
- Always exclude The World Tree if not 5-color identity.
"""
from . import builder_constants as bc
try:
ci = getattr(builder, 'color_identity', []) or []
# World Tree legality check (needs all five colors in identity)
if name == 'The World Tree' and set(ci) != {'W','U','B','R','G'}:
return True
if not is_mono_color(builder):
return False
if name in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', []):
return False
if is_kindred_land(name):
return False
if name in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', []):
return True
except Exception:
return False
return False
def adjust_misc_land_weight(builder, name: str, base_weight: int | float) -> int | float:
"""Adjust weight for tribal lands when no tribal theme present.
If land is tribal and no kindred theme, weight is reduced (min 1) by factor.
"""
if is_kindred_land(name) and not has_kindred_theme(builder):
try:
# Ensure we don't drop below 1 (else risk exclusion by sampling step)
return max(1, int(base_weight * 0.5))
except Exception:
return base_weight
return base_weight
# ---------------------------------------------------------------------------
# Generic DataFrame helpers (tag normalization & sorting)
# ---------------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Dict, Optional
from .. import builder_constants as bc
import os
"""Phase 2 (part 1): Basic land addition logic (Land Step 1).
@ -39,6 +40,33 @@ class LandBasicsMixin:
self.output_func(f"Cannot add basics until color identity resolved: {e}")
return
# DEBUG EXPORT: write full land pool snapshot the first time basics are added
# Purpose: allow inspection of all candidate land cards before other land steps mutate state.
try: # pragma: no cover (diagnostic aid)
full_df = getattr(self, '_combined_cards_df', None)
marker_attr = '_land_debug_export_done'
if full_df is not None and not getattr(self, marker_attr, False):
land_df = full_df
# Prefer 'type' column (common) else attempt 'type_line'
col = 'type' if 'type' in land_df.columns else ('type_line' if 'type_line' in land_df.columns else None)
if col:
work = land_df[land_df[col].fillna('').str.contains('Land', case=False, na=False)].copy()
if not work.empty:
os.makedirs(os.path.join('logs', 'debug'), exist_ok=True)
export_cols = [c for c in ['name','type','type_line','manaValue','edhrecRank','colorIdentity','manaCost','themeTags','oracleText'] if c in work.columns]
path = os.path.join('logs','debug','land_test.csv')
try:
if export_cols:
work[export_cols].to_csv(path, index=False, encoding='utf-8')
else:
work.to_csv(path, index=False, encoding='utf-8')
except Exception:
work.to_csv(path, index=False)
self.output_func(f"[DEBUG] Wrote land_test.csv ({len(work)} rows)")
setattr(self, marker_attr, True)
except Exception:
pass
# Ensure ideal counts (for min basics & total lands)
basic_min: Optional[int] = None
land_total: Optional[int] = None
@ -108,6 +136,11 @@ class LandBasicsMixin:
def run_land_step1(self): # type: ignore[override]
"""Public wrapper to execute land building step 1 (basics)."""
self.add_basic_lands()
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '1')
except Exception:
pass
__all__ = [

View file

@ -212,6 +212,11 @@ class LandDualsMixin:
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]
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '5')
except Exception:
pass
__all__ = [
'LandDualsMixin'

View file

@ -156,6 +156,11 @@ class LandFetchMixin:
desired = requested_count
self.add_fetch_lands(requested_count=desired)
self._enforce_land_cap(step_label="Fetch (Step 4)") # type: ignore[attr-defined]
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '4')
except Exception:
pass
__all__ = [
'LandFetchMixin'

View file

@ -145,6 +145,11 @@ class LandKindredMixin:
"""Public wrapper to add kindred-focused lands."""
self.add_kindred_lands()
self._enforce_land_cap(step_label="Kindred (Step 3)") # type: ignore[attr-defined]
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '3')
except Exception:
pass
__all__ = [

View file

@ -1,6 +1,8 @@
from __future__ import annotations
from typing import Optional, List, Dict
import os
import csv
from .. import builder_constants as bc
from .. import builder_utils as bu
@ -9,15 +11,16 @@ from .. import builder_utils as bu
class LandMiscUtilityMixin:
"""Mixin for Land Building Step 7: Misc / Utility Lands.
Provides:
- add_misc_utility_lands
- run_land_step7
- tag-driven suggestion queue helpers (_build_tag_driven_land_suggestions, _apply_land_suggestions_if_room)
Extracted verbatim (with light path adjustments) from original monolithic builder.
Clean, de-duplicated implementation with:
- Dynamic EDHREC percent (roll between MIN/MAX for variety)
- Theme weighting
- Mono-color rainbow text filtering
- Exclusion of all fetch lands (fetch step handles them earlier)
- Diagnostics & CSV exports
"""
def add_misc_utility_lands(self, requested_count: Optional[int] = None): # type: ignore[override]
# --- Initialization & candidate collection ---
if not getattr(self, 'files_to_load', None):
try:
self.determine_color_identity()
@ -29,54 +32,191 @@ class LandMiscUtilityMixin:
if df is None or df.empty:
self.output_func("Misc Lands: No card pool loaded.")
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)
current = self._current_land_count()
remaining_capacity = max(0, land_target - current)
if remaining_capacity <= 0:
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)
if requested_count is not None:
desired = max(0, int(requested_count))
else:
desired = max(0, land_target - current)
desired = max(0, int(requested_count)) if requested_count is not None else max(0, land_target - current)
if desired == 0:
self.output_func("Misc Lands: No remaining land capacity; skipping.")
return
basics = self._basic_land_names()
already = set(self.card_library.keys())
top_n = getattr(bc, 'MISC_LAND_TOP_POOL_SIZE', 30)
top_candidates = bu.select_top_land_candidates(df, already, basics, top_n)
use_full = getattr(bc, 'MISC_LAND_USE_FULL_POOL', False)
effective_n = 999999 if use_full else top_n
top_candidates = bu.select_top_land_candidates(df, already, basics, effective_n)
# Dynamic EDHREC keep percent
pct_min = getattr(bc, 'MISC_LAND_EDHREC_KEEP_PERCENT_MIN', None)
pct_max = getattr(bc, 'MISC_LAND_EDHREC_KEEP_PERCENT_MAX', None)
if isinstance(pct_min, float) and isinstance(pct_max, float) and 0 < pct_min <= pct_max <= 1:
rng = getattr(self, 'rng', None)
keep_pct = rng.uniform(pct_min, pct_max) if rng else (pct_min + pct_max) / 2.0
else:
keep_pct = getattr(bc, 'MISC_LAND_EDHREC_KEEP_PERCENT', 1.0)
if 0 < keep_pct < 1 and top_candidates:
orig_len = len(top_candidates)
trimmed_len = max(1, int(orig_len * keep_pct))
if trimmed_len < orig_len:
top_candidates = top_candidates[:trimmed_len]
if getattr(self, 'show_diagnostics', False):
self.output_func(f"[Diagnostics] Misc Step EDHREC top% applied: kept {trimmed_len}/{orig_len} (rolled pct={keep_pct:.3f})")
if use_full and getattr(self, 'show_diagnostics', False):
self.output_func(f"[Diagnostics] Misc Step using FULL pool (size request={effective_n}, actual candidates={len(top_candidates)})")
if not top_candidates:
self.output_func("Misc Lands: No remaining candidate lands.")
return
weighted_pool: List[tuple[str,int]] = []
# --- Setup weighting state ---
base_weight_fix = getattr(bc, 'MISC_LAND_COLOR_FIX_PRIORITY_WEIGHT', 2)
fetch_names = set()
fetch_names: set[str] = set()
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)
colors = list(getattr(self, 'color_identity', []) or [])
mono = len(colors) <= 1
selected_tags_lower = [t.lower() for t in (getattr(self, 'selected_tags', []) or [])]
kindred_deck = any('kindred' in t or 'tribal' in t for t in selected_tags_lower)
mono_exclude = set(getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', []))
mono_keep_always = set(getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', []))
kindred_all = set(getattr(bc, 'KINDRED_ALL_LAND_NAMES', []))
text_rainbow_enabled = getattr(bc, 'MONO_COLOR_EXCLUDE_RAINBOW_TEXT', True)
extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])]
any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])]
weighted_pool: List[tuple[str,int]] = []
detail_rows: List[Dict[str,str]] = []
filtered_out: List[str] = []
considered = 0
debug_entries: List[tuple[str,int,str]] = []
dump_pool = getattr(self, 'show_diagnostics', False) or bool(os.getenv('SHOW_MISC_POOL'))
# Pre-filter export
debug_enabled = getattr(self, 'show_diagnostics', False) or bool(os.getenv('MISC_LAND_DEBUG'))
if debug_enabled:
try: # pragma: no cover
os.makedirs(os.path.join('logs','debug'), exist_ok=True)
cand_path = os.path.join('logs','debug','land_step7_candidates.csv')
with open(cand_path, 'w', newline='', encoding='utf-8') as fh:
wcsv = csv.writer(fh)
wcsv.writerow(['name','edhrecRank','type_line','has_color_fixing_terms'])
for edh_val, cname, ctline, ctext_lower in top_candidates:
wcsv.writerow([cname, edh_val, ctline, int(bu.is_color_fixing_land(ctline, ctext_lower))])
except Exception:
pass
deck_theme_tags = [t.lower() for t in (getattr(self, 'selected_tags', []) or [])]
theme_enabled = getattr(bc, 'MISC_LAND_THEME_MATCH_ENABLED', True) and bool(deck_theme_tags)
for edh_val, name, tline, text_lower in top_candidates:
considered += 1
note_parts: List[str] = []
if name in self.card_library:
note_parts.append('already-added')
if mono and name in mono_exclude and name not in mono_keep_always and name not in kindred_all:
filtered_out.append(name)
detail_rows.append({'name': name,'status':'filtered','reason':'mono-exclude','weight':'0'})
continue
if mono and text_rainbow_enabled and name not in mono_keep_always and name not in kindred_all:
if any(p in text_lower for p in any_color_phrases + extra_rainbow_terms):
filtered_out.append(name)
detail_rows.append({'name': name,'status':'filtered','reason':'mono-rainbow-text','weight':'0'})
continue
if name == 'The World Tree' and set(colors) != {'W','U','B','R','G'}:
filtered_out.append(name)
detail_rows.append({'name': name,'status':'filtered','reason':'world-tree-illegal','weight':'0'})
continue
# Exclude all fetch lands entirely in this phase
if name in fetch_names:
filtered_out.append(name)
detail_rows.append({'name': name,'status':'filtered','reason':'fetch-skip-misc','weight':'0'})
continue
w = 1
if bu.is_color_fixing_land(tline, text_lower):
w *= base_weight_fix
if name in fetch_names and remaining_fetch_slots <= 0:
continue
note_parts.append('fixing')
if 'already-added' in note_parts:
w = max(1, int(w * 0.2))
if (not kindred_deck) and name in kindred_all and name not in mono_keep_always:
original = w
w = max(1, int(w * 0.3))
if w < original:
note_parts.append('kindred-down')
if name == 'Yavimaya, Cradle of Growth' and 'G' not in colors:
original = w
w = max(1, int(w * 0.25))
if w < original:
note_parts.append('offcolor-yavimaya')
if name == 'Urborg, Tomb of Yawgmoth' and 'B' not in colors:
original = w
w = max(1, int(w * 0.25))
if w < original:
note_parts.append('offcolor-urborg')
adj = bu.adjust_misc_land_weight(self, name, w)
if adj != w:
note_parts.append('helper-adj')
w = adj
if theme_enabled:
try:
crow = df.loc[df['name'] == name].head(1)
if not crow.empty and 'themeTags' in crow.columns:
raw_tags = crow.iloc[0].get('themeTags', []) or []
norm_tags: List[str] = []
if isinstance(raw_tags, list):
for v in raw_tags:
s = str(v).strip().lower()
if s:
norm_tags.append(s)
elif isinstance(raw_tags, str):
rt = raw_tags.lower()
for ch in '[]"':
rt = rt.replace(ch, ' ')
norm_tags = [p.strip().strip("'\"") for p in rt.replace(';', ',').split(',') if p.strip()]
matches = [t for t in norm_tags if t in deck_theme_tags]
if matches:
base_mult = getattr(bc, 'MISC_LAND_THEME_MATCH_BASE', 1.4)
per_extra = getattr(bc, 'MISC_LAND_THEME_MATCH_PER_EXTRA', 0.15)
cap_mult = getattr(bc, 'MISC_LAND_THEME_MATCH_CAP', 2.0)
extra = max(0, len(matches) - 1)
mult = base_mult + extra * per_extra
if mult > cap_mult:
mult = cap_mult
themed_w = int(max(1, w * mult))
if themed_w != w:
w = themed_w
note_parts.append(f"theme+{len(matches)}")
except Exception:
pass
weighted_pool.append((name, w))
if dump_pool:
debug_entries.append((name, w, ','.join(note_parts) if note_parts else ''))
detail_rows.append({'name': name,'status':'kept','reason':','.join(note_parts) if note_parts else '', 'weight':str(w)})
if dump_pool:
debug_entries.sort(key=lambda x: (-x[1], x[0]))
self.output_func("\nMisc Lands Pool (post-filter, top {} shown):".format(len(debug_entries)))
width = max((len(n) for n,_,_ in debug_entries), default=0)
for n, w, notes in debug_entries[:80]:
suffix = f" [{notes}]" if notes else ''
self.output_func(f" {n.ljust(width)} w={w}{suffix}")
if debug_enabled:
try: # pragma: no cover
os.makedirs(os.path.join('logs','debug'), exist_ok=True)
detail_path = os.path.join('logs','debug','land_step7_postfilter.csv')
kept = [r for r in detail_rows if r['status']=='kept']
filt = [r for r in detail_rows if r['status']=='filtered']
other = [r for r in detail_rows if r['status'] not in {'kept','filtered'}]
if detail_rows:
kept.sort(key=lambda r: (-int(r.get('weight','1')), r['name']))
ordered = kept + filt + other
with open(detail_path,'w',newline='',encoding='utf-8') as fh:
wcsv = csv.writer(fh)
wcsv.writerow(['name','status','reason','weight'])
for r in ordered:
wcsv.writerow([r['name'], r['status'], r.get('reason',''), r.get('weight','')])
except Exception:
pass
if getattr(self, 'show_diagnostics', False):
self.output_func(f"Misc Lands Debug: considered={considered} kept={len(weighted_pool)} filtered={len(filtered_out)}")
# Capacity adjustment (trim basics if needed)
if self._current_land_count() >= land_target and desired > 0:
slots_needed = desired
freed = 0
@ -88,25 +228,38 @@ class LandMiscUtilityMixin:
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
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
# Misc utility lands baseline role
self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7')
added.append(nm)
if debug_enabled:
try: # pragma: no cover
os.makedirs(os.path.join('logs','debug'), exist_ok=True)
final_path = os.path.join('logs','debug','land_step7_final_selection.csv')
with open(final_path,'w',newline='',encoding='utf-8') as fh:
wcsv = csv.writer(fh)
wcsv.writerow(['name','weight','selected','reason'])
reason_map = {r['name']:(r.get('weight',''), r.get('reason','')) for r in detail_rows if r['status']=='kept'}
chosen_set = set(added)
for name, w in weighted_pool:
wt, rsn = reason_map.get(name,(str(w),''))
wcsv.writerow([name, wt, 1 if name in chosen_set else 0, rsn])
wcsv.writerow([])
wcsv.writerow(['__meta__','desired', desired])
wcsv.writerow(['__meta__','pool_size', len(weighted_pool)])
wcsv.writerow(['__meta__','considered', considered])
wcsv.writerow(['__meta__','filtered_out', len(filtered_out)])
except Exception:
pass
self.output_func("\nMisc Utility Lands Added (Step 7):")
if not added:
self.output_func(" (None added)")
@ -114,20 +267,36 @@ class LandMiscUtilityMixin:
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
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}")
if getattr(self, 'show_diagnostics', False) and filtered_out:
self.output_func(f" (Excluded candidates: {', '.join(filtered_out)})")
width = max(len(n) for n in added)
for n in added:
note = ''
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}")
if getattr(self, 'show_diagnostics', False) and filtered_out:
self.output_func(f" (Mono-color excluded candidates: {', '.join(filtered_out)})")
def run_land_step7(self, requested_count: Optional[int] = None): # type: ignore[override]
self.add_misc_utility_lands(requested_count=requested_count)
self._enforce_land_cap(step_label="Utility (Step 7)")
self._build_tag_driven_land_suggestions()
self._apply_land_suggestions_if_room()
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '7')
except Exception:
pass
# ---- Tag-driven suggestion helpers (used after Step 7) ----
def _build_tag_driven_land_suggestions(self): # type: ignore[override]

View file

@ -151,3 +151,8 @@ class LandOptimizationMixin:
self._enforce_land_cap(step_label="Tapped Opt (Step 8)")
if self.color_source_matrix_baseline is None:
self.color_source_matrix_baseline = self._compute_color_source_matrix()
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '8')
except Exception:
pass

View file

@ -143,6 +143,11 @@ class LandStaplesMixin:
"""Public wrapper for adding generic staple nonbasic lands (excluding kindred)."""
self.add_staple_lands()
self._enforce_land_cap(step_label="Staples (Step 2)") # type: ignore[attr-defined]
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '2')
except Exception:
pass
__all__ = [

View file

@ -230,3 +230,8 @@ class LandTripleMixin:
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)")
try:
from .. import builder_utils as _bu
_bu.export_current_land_pool(self, '6')
except Exception:
pass

View file

@ -4751,7 +4751,6 @@ def create_burn_damage_mask(df: pd.DataFrame) -> pd.Series:
# Create general damage trigger patterns
trigger_patterns = [
'deals combat damage',
'deals damage',
'deals noncombat damage',
'deals that much damage',

View file

@ -1786,6 +1786,13 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
"creatures": "creature",
"primary": "creature",
"secondary": "creature",
# Land-related hints
"land": "land",
"lands": "land",
"utility": "land",
"misc": "land",
"fetch": "land",
"dual": "land",
}
hinted_role = stage_map.get(stage_hint) if stage_hint else None
lib = getattr(b, "card_library", {}) or {}
@ -1807,6 +1814,14 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
# Build role-specific pool from combined DataFrame
items: list[dict] = []
used_role = role if isinstance(role, str) and role else None
# Promote to 'land' role when the seed card is a land (regardless of stored role)
try:
if entry and isinstance(entry, dict):
ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower()
if "land" in ctype:
used_role = "land"
except Exception:
pass
df = getattr(b, "_combined_cards_df", None)
# Compute current deck fingerprint to avoid stale cached alternatives after stage changes
@ -1821,7 +1836,8 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
# Use a cache key that includes the exclusions version and deck fingerprint
cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp)
cached = _alts_get_cached(cache_key)
# Disable caching for land alternatives to keep randomness per request
cached = None if used_role == 'land' else _alts_get_cached(cache_key)
if cached is not None:
return HTMLResponse(cached)
@ -1832,10 +1848,12 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
"require_owned": require_owned,
"items": _items,
})
try:
_alts_set_cached(cache_key, html_str)
except Exception:
pass
# Skip caching when used_role == land for per-call randomness
if used_role != 'land':
try:
_alts_set_cached(cache_key, html_str)
except Exception:
pass
return HTMLResponse(html_str)
# Helper: map display names
@ -1857,16 +1875,126 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
return out
# If we have data and a recognized role, mirror the phase logic
if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature"}):
if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}):
pool = df.copy()
try:
pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell)
except Exception:
# best-effort normalize
pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else [])
# Exclude lands for all these roles
if "type" in pool.columns:
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
# Role-specific base filtering
if used_role != "land":
# Exclude lands for non-land roles
if "type" in pool.columns:
pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)]
else:
# Keep only lands
if "type" in pool.columns:
pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)]
# Seed info to guide filtering
seed_is_basic = False
try:
seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])})
except Exception:
seed_is_basic = False
if seed_is_basic:
# For basics: show other basics (different colors) to allow quick swaps
try:
pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
except Exception:
pass
else:
# For non-basics: prefer other non-basics
try:
pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})]
except Exception:
pass
# Apply mono-color misc land filters (no debug CSV dependency)
try:
colors = list(getattr(b, 'color_identity', []) or [])
mono = len(colors) <= 1
mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])}
mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])}
kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])}
any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])]
extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])]
fetch_names = set()
for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values():
for nm in seq:
fetch_names.add(nm.lower())
for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []):
fetch_names.add(nm.lower())
# World Tree check needs all five colors
need_all_colors = {'w','u','b','r','g'}
def _illegal_world_tree(nm: str) -> bool:
return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors
# Text column fallback
text_col = 'text'
if text_col not in pool.columns:
for c in pool.columns:
if 'text' in c.lower():
text_col = c
break
def _exclude_row(row) -> bool:
nm_l = str(row['name']).strip().lower()
if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all:
return True
if mono and nm_l not in mono_keep and nm_l not in kindred_all:
try:
txt = str(row.get(text_col, '') or '').lower()
if any(p in txt for p in any_color_phrases + extra_rainbow_terms):
return True
except Exception:
pass
if nm_l in fetch_names:
return True
if _illegal_world_tree(nm_l):
return True
return False
pool = pool[~pool.apply(_exclude_row, axis=1)]
except Exception:
pass
# Optional sub-role filtering (only if enough depth)
try:
subrole = str((entry or {}).get('SubRole') or '').strip().lower()
if subrole:
# Heuristic categories for grouping
cat_map = {
'fetch': 'fetch',
'dual': 'dual',
'triple': 'triple',
'misc': 'misc',
'utility': 'misc',
'basic': 'basic'
}
target_cat = None
for key, val in cat_map.items():
if key in subrole:
target_cat = val
break
if target_cat and len(pool) > 25:
# Lightweight textual filter using known markers
def _cat_row(rname: str, rtype: str) -> str:
rl = rname.lower()
rt = rtype.lower()
if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')):
return 'fetch'
if 'triple' in rt or 'three' in rt:
return 'triple'
if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt:
# Basic-check crude
return 'basic'
return 'misc'
try:
tmp = pool.copy()
tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1)
sub_pool = tmp[tmp['_cat'] == target_cat]
if len(sub_pool) >= 10:
pool = sub_pool.drop(columns=['_cat'])
except Exception:
pass
except Exception:
pass
# Exclude commander explicitly
if "name" in pool.columns and commander_l:
pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l]
@ -1904,44 +2032,90 @@ async def build_alternatives(request: Request, name: str, stage: str | None = No
pool = pool[pool["_ltags"].apply(_matches_selected)]
except Exception:
pass
elif used_role == "land":
# Already constrained to lands; no additional tag filter needed
pass
# Sort by priority like the builder
try:
pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) # type: ignore[arg-type]
except Exception:
pass
# Exclusions and ownership
# Exclusions and ownership (for non-random roles this stays before slicing)
pool = _exclude(pool)
# Prefer-owned bias: stable reorder to put owned first if user prefers owned
try:
if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None):
pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())})
except Exception:
pass
# Build final items
lower_pool: list[str] = []
try:
lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist()
except Exception:
lower_pool = []
display_map = _display_map_for(set(lower_pool))
for nm_l in lower_pool:
is_owned = (nm_l in owned_set)
if require_owned and not is_owned:
continue
# Extra safety: exclude the seed card or anything already in deck
if nm_l == name_l or (in_deck and nm_l in in_deck):
continue
items.append({
"name": display_map.get(nm_l, nm_l),
"name_lower": nm_l,
"owned": is_owned,
"tags": [], # can be filled from index below if needed
})
if len(items) >= 10:
break
# If we collected role-aware items, render
if items:
return _render_and_cache(items)
# Land role: random 12 from top 60-100 window
if used_role == 'land':
import random as _rnd
total = len(pool)
if total == 0:
pass
else:
cap = min(100, total)
floor = min(60, cap) # if fewer than 60 just use all
if cap <= 12:
window_size = cap
else:
if cap == floor:
window_size = cap
else:
rng_obj = getattr(b, 'rng', None)
if rng_obj:
window_size = rng_obj.randint(floor, cap)
else:
window_size = _rnd.randint(floor, cap)
window_df = pool.head(window_size)
names = window_df['name'].astype(str).str.strip().tolist()
# Random sample up to 12 distinct names
sample_n = min(12, len(names))
if sample_n > 0:
if getattr(b, 'rng', None):
chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names
else:
chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names
lower_map = {n.strip().lower(): n for n in chosen}
display_map = _display_map_for(set(k for k in lower_map.keys()))
for nm_lc, orig in lower_map.items():
is_owned = (nm_lc in owned_set)
if require_owned and not is_owned:
continue
if nm_lc == name_l or (in_deck and nm_lc in in_deck):
continue
items.append({
'name': display_map.get(nm_lc, orig),
'name_lower': nm_lc,
'owned': is_owned,
'tags': []
})
if items:
return _render_and_cache(items)
else:
# Default deterministic top-N (increase to 12 for parity)
lower_pool: list[str] = []
try:
lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist()
except Exception:
lower_pool = []
display_map = _display_map_for(set(lower_pool))
for nm_l in lower_pool:
is_owned = (nm_l in owned_set)
if require_owned and not is_owned:
continue
if nm_l == name_l or (in_deck and nm_l in in_deck):
continue
items.append({
"name": display_map.get(nm_l, nm_l),
"name_lower": nm_l,
"owned": is_owned,
"tags": [],
})
if len(items) >= 12:
break
if items:
return _render_and_cache(items)
# Fallback: tag-similarity suggestions (previous behavior)
tags_idx = getattr(b, "_card_name_tags_index", {}) or {}

View file

@ -483,6 +483,15 @@
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
if (ownedGrid) { source = ownedGrid; }
var all = Array.prototype.slice.call(source.children);
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
// is cheaper than the complexity for small sets.
var MIN_VIRT_ITEMS = 80;
if (all.length < MIN_VIRT_ITEMS){
// Mark as processed so we don't attempt again on HTMX swaps.
return; // children remain in place; no virtualization applied.
}
var store = document.createElement('div');
store.style.display = 'none';
all.forEach(function(n){ store.appendChild(n); });

View file

@ -219,6 +219,8 @@ small, .muted{ color: var(--muted); }
gap: .5rem;
margin-top:.5rem;
justify-content: start; /* pack as many as possible per row */
/* Prevent scroll chaining bounce that can cause flicker near bottom */
overscroll-behavior: contain;
}
@media (max-width: 420px){
.card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }